Nye-TeeOff/frontend/src/app/admin/page.tsx

1403 lines
81 KiB
TypeScript
Raw Normal View History

"use client";
/**
2026-04-10 09:52:34 +02:00
* TEE OFF ADMIN DASHBOARD v4.0 - KONTROLLPANEL
*/
2026-04-10 18:37:33 +02:00
import { useEffect, useMemo, useRef, useState } from 'react';
import { API_URL } from "@/config/constants";
2026-04-11 09:54:54 +02:00
import { adminFetch } from "@/config/adminFetch";
2026-03-05 09:25:15 +01:00
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
2026-03-07 09:46:33 +01:00
import Link from 'next/link';
2026-04-10 18:37:33 +02:00
type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'vtg';
type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed';
type ScrapeJob = {
id: number;
job_type: AdminTab;
status: ScrapeJobStatus;
facility_ids: number[];
total_facilities: number;
error_message?: string | null;
2026-04-11 16:01:36 +02:00
error_code?: string | null;
retryable?: boolean;
attempt_count?: number;
max_attempts?: number;
next_retry_at?: string | null;
last_error_at?: string | null;
2026-04-10 18:37:33 +02:00
result_summary?: Record<string, number | string | null>;
created_at?: string | null;
started_at?: string | null;
finished_at?: string | null;
};
2026-04-11 16:01:36 +02:00
type QueueFeedback = {
tone: 'success' | 'warning' | 'info' | 'error';
title: string;
message: string;
};
2026-04-11 09:54:54 +02:00
type TwoFactorSetupResponse = {
issuer: string;
account_name: string;
otp_secret: string;
provisioning_uri: string;
qr_svg: string;
};
2026-04-10 18:37:33 +02:00
const JOB_LABELS: Record<AdminTab, string> = {
banestatus: 'Banestatus',
medlemskap: 'Medlemskap',
greenfee: 'Greenfee',
vtg: 'VTG',
};
const JOB_STATUS_LABELS: Record<ScrapeJobStatus, string> = {
pending: 'I kø',
running: 'Kjører',
completed: 'Fullført',
failed: 'Feilet',
};
2026-04-11 16:01:36 +02:00
const JOB_ERROR_LABELS: Record<string, string> = {
json_parse: 'JSON-tolkning',
configuration: 'Konfigurasjon',
timeout: 'Tidsavbrudd',
browser: 'Nettleser / Playwright',
network: 'Nettverk',
database: 'Database',
validation: 'Validering',
unknown: 'Ukjent feil',
worker_stale: 'Worker mistet heartbeat',
};
2026-03-12 13:39:10 +01:00
const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue || '');
const handleSave = () => {
setIsEditing(false);
if (value !== initialValue) {
onSave(facilityId, field, value);
}
};
if (isEditing) {
return (
<div className="flex flex-col gap-1 w-full max-w-[200px] animate-fade-in">
<textarea autoFocus rows={2} className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white" value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} placeholder="Lim inn URL(er)..." />
2026-03-12 13:39:10 +01:00
<div className="flex gap-1">
<button onClick={handleSave} className="bg-[#8bc34a] text-white px-3 py-1.5 rounded-md text-[10px] font-black uppercase flex-1 shadow-sm hover:bg-[#7ca982]">Lagre</button>
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="bg-gray-200 text-gray-600 px-3 py-1.5 rounded-md text-[10px] font-black uppercase hover:bg-gray-300">Avbryt</button>
</div>
</div>
);
}
return (
<div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title="Klikk for å redigere URL">
<div className="text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2">
{initialValue ? initialValue : <span className="text-red-400 italic">Mangler URL</span>}
</div>
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity"></span>
</div>
);
};
export default function AdminDashboard() {
2026-03-05 05:18:03 +01:00
const [facilities, setFacilities] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
2026-03-05 05:18:03 +01:00
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
2026-04-10 18:37:33 +02:00
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
const [isQueueing, setIsQueueing] = useState(false);
2026-03-05 09:25:15 +01:00
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
2026-04-11 09:54:54 +02:00
const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false);
2026-03-05 09:25:15 +01:00
const [editingFacility, setEditingFacility] = useState<any | null>(null);
2026-04-10 18:37:33 +02:00
const [activeTab, setActiveTab] = useState<AdminTab>('banestatus');
2026-03-06 09:39:32 +01:00
const [statusFilter, setStatusFilter] = useState('alle');
2026-03-12 13:39:10 +01:00
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
2026-03-05 09:25:15 +01:00
const [isSaving, setIsSaving] = useState(false);
2026-04-10 18:37:33 +02:00
const latestJobStateRef = useRef<string | null>(null);
2026-04-11 09:54:54 +02:00
const [showTwoFactorModal, setShowTwoFactorModal] = useState(false);
const [twoFactorPassword, setTwoFactorPassword] = useState('');
const [twoFactorError, setTwoFactorError] = useState('');
const [isLoadingTwoFactor, setIsLoadingTwoFactor] = useState(false);
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResponse | null>(null);
const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null);
2026-04-11 16:01:36 +02:00
const [queueFeedback, setQueueFeedback] = useState<QueueFeedback | null>(null);
2026-03-05 09:25:15 +01:00
2026-03-05 05:18:03 +01:00
const fetchFacilities = () => {
fetch(`${API_URL}/facilities`)
.then(res => res.json())
.then(data => {
setFacilities(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => setLoading(false));
2026-03-05 05:18:03 +01:00
};
2026-04-10 18:37:33 +02:00
const fetchScrapeJobs = (tab: AdminTab = activeTab) => {
2026-04-11 09:54:54 +02:00
adminFetch(`${API_URL}/admin/scrape-jobs?job_type=${tab}&limit=5`)
2026-04-10 18:37:33 +02:00
.then(res => res.json())
.then(data => {
setScrapeJobs(Array.isArray(data) ? data : []);
})
.catch(() => setScrapeJobs([]));
};
const activeJob = useMemo(
() => scrapeJobs.find(job => job.status === 'pending' || job.status === 'running') || null,
[scrapeJobs]
);
const latestJob = scrapeJobs[0] || null;
const isScraping = !!activeJob;
2026-03-05 05:18:03 +01:00
useEffect(() => {
2026-04-10 18:37:33 +02:00
fetchFacilities();
fetchScrapeJobs('banestatus');
}, []);
useEffect(() => {
const interval = setInterval(() => fetchScrapeJobs(activeTab), 5000);
return () => clearInterval(interval);
}, [activeTab]);
useEffect(() => {
setSelectedFacilities([]);
2026-04-11 16:01:36 +02:00
setQueueFeedback(null);
2026-04-10 18:37:33 +02:00
fetchScrapeJobs(activeTab);
}, [activeTab]);
useEffect(() => {
if (!isScraping) return;
const interval = setInterval(() => fetchFacilities(), 10000);
2026-03-05 05:18:03 +01:00
return () => clearInterval(interval);
}, [isScraping]);
2026-04-10 18:37:33 +02:00
useEffect(() => {
2026-04-11 16:01:36 +02:00
const currentState = latestJob
? `${latestJob.id}:${latestJob.status}:${latestJob.attempt_count ?? 0}:${latestJob.next_retry_at ?? ''}:${latestJob.finished_at ?? ''}`
: null;
2026-04-10 18:37:33 +02:00
const previousState = latestJobStateRef.current;
latestJobStateRef.current = currentState;
if (
previousState &&
previousState !== currentState &&
latestJob &&
(latestJob.status === 'completed' || latestJob.status === 'failed')
) {
fetchFacilities();
fetchScrapeJobs(activeTab);
}
}, [activeTab, latestJob]);
2026-03-12 13:39:10 +01:00
2026-04-11 09:54:54 +02:00
useEffect(() => {
if (!showTwoFactorModal) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeTwoFactorModal();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showTwoFactorModal]);
useEffect(() => {
if (!showMobileAdminMenu) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setShowMobileAdminMenu(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showMobileAdminMenu]);
2026-03-06 09:39:32 +01:00
const filteredFacilities = useMemo(() => {
if (statusFilter === 'alle') return facilities;
return facilities.map(facility => {
if (!facility.course_statuses) return facility;
const filteredCourses = facility.course_statuses.filter((cs: any) => {
const s = cs.status || 'ukjent';
2026-03-12 13:39:10 +01:00
if (statusFilter === 'aapne') return s === 'aapen';
if (statusFilter === 'ikke_stengt') return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
if (statusFilter === 'stengt') return s === 'stengt' || s === 'nedlagt';
if (statusFilter === 'ukjent_feil') return s === 'ukjent' || s === 'NOT_FOUND';
2026-03-06 09:39:32 +01:00
return true;
});
return { ...facility, course_statuses: filteredCourses };
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
}, [facilities, statusFilter]);
2026-04-10 18:37:33 +02:00
const latestJobSummary = useMemo(() => {
if (!latestJob?.result_summary) return '';
return Object.entries(latestJob.result_summary)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`)
.join(' • ');
}, [latestJob]);
2026-04-11 16:01:36 +02:00
const latestJobAttemptLabel = useMemo(() => {
if (!latestJob) return '';
const attempts = latestJob.attempt_count ?? 0;
const maxAttempts = latestJob.max_attempts ?? 0;
if (!attempts || !maxAttempts) return '';
return `Forsøk ${attempts} av ${maxAttempts}`;
}, [latestJob]);
const latestJobRetryLabel = useMemo(() => {
if (!latestJob?.next_retry_at || latestJob.status !== 'pending') return '';
return `Nytt forsøk planlagt ${new Date(latestJob.next_retry_at).toLocaleString('nb-NO')}`;
}, [latestJob]);
const latestJobErrorLabel = useMemo(() => {
if (!latestJob?.error_code) return '';
return JOB_ERROR_LABELS[latestJob.error_code] || latestJob.error_code.replaceAll('_', ' ');
}, [latestJob]);
const recentJobs = useMemo(() => {
if (!latestJob) return scrapeJobs.slice(0, 4);
return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4);
}, [latestJob, scrapeJobs]);
2026-03-05 05:18:03 +01:00
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
2026-03-12 13:39:10 +01:00
if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id));
else setSelectedFacilities([]);
2026-03-05 05:18:03 +01:00
};
const handleSelectOne = (id: number, checked: boolean) => {
2026-03-12 13:39:10 +01:00
if (checked) setSelectedFacilities([...selectedFacilities, id]);
else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
2026-03-05 05:18:03 +01:00
};
2026-03-12 13:39:10 +01:00
const handleQuickEdit = async (id: number, field: string, value: string) => {
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
try {
2026-04-11 09:54:54 +02:00
const res = await adminFetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
2026-03-12 13:39:10 +01:00
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field, value })
});
if (!res.ok) throw new Error("Feil ved lagring");
} catch (e) {
alert("Kunne ikke lagre endringen i databasen.");
fetchFacilities();
2026-03-05 09:25:15 +01:00
}
2026-03-12 13:39:10 +01:00
};
2026-03-05 09:25:15 +01:00
2026-03-12 13:39:10 +01:00
const handleRunScrapers = async () => {
2026-04-10 18:37:33 +02:00
if (selectedFacilities.length === 0) return;
setIsQueueing(true);
2026-04-11 16:01:36 +02:00
setQueueFeedback(null);
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
'/admin/run-vtg-scraper';
2026-03-05 05:18:03 +01:00
try {
2026-04-11 09:54:54 +02:00
const response = await adminFetch(`${API_URL}${endpoint}`, {
2026-03-05 05:18:03 +01:00
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ facility_ids: selectedFacilities })
});
2026-04-10 18:37:33 +02:00
const data = await response.json();
if (!response.ok) throw new Error(data.detail || "Kunne ikke starte skraping");
2026-04-11 16:01:36 +02:00
if (data.status === 'queued') {
setQueueFeedback({
tone: 'success',
title: 'Jobb lagt i kø',
message: data.message || 'Skrapejobben ble lagt i kø.'
});
setSelectedFacilities([]);
} else if (data.status === 'already_queued') {
setQueueFeedback({
tone: 'info',
title: 'Jobben finnes allerede',
message: data.message || 'Det finnes allerede en aktiv jobb for dette utvalget.'
});
setSelectedFacilities([]);
} else if (data.status === 'conflict') {
const conflictingCount = Array.isArray(data.conflicting_facility_ids) ? data.conflicting_facility_ids.length : 0;
const idleCount = Array.isArray(data.idle_facility_ids) ? data.idle_facility_ids.length : 0;
setQueueFeedback({
tone: 'warning',
title: 'Noen anlegg er allerede i arbeid',
message:
conflictingCount > 0
? `${data.message || 'Utvalget overlapper med en aktiv jobb.'}${idleCount > 0 ? ` ${idleCount} andre anlegg i valget er fortsatt ledige hvis du vil justere utvalget.` : ''}`
: (data.message || 'Utvalget overlapper med en aktiv jobb.')
});
}
2026-04-10 18:37:33 +02:00
fetchScrapeJobs(activeTab);
2026-03-05 05:18:03 +01:00
} catch (error) {
2026-04-11 16:01:36 +02:00
setQueueFeedback({
tone: 'error',
title: 'Kunne ikke starte jobben',
message: `Det oppstod en feil ved start av ${activeTab}-skraperen.`
});
2026-03-12 13:39:10 +01:00
alert(`Feil ved start av ${activeTab}-skraperen.`);
2026-04-10 18:37:33 +02:00
} finally {
setIsQueueing(false);
2026-03-05 05:18:03 +01:00
}
};
2026-03-05 09:25:15 +01:00
const openEditModal = (facility: any) => {
setEditingFacility(facility);
setEditForm({
scrape_status_url: facility.scrape_status_url || '',
scrape_status_selector: facility.scrape_status_selector || '',
scrape_method: facility.scrape_method || 'css_selector',
ai_instruction: facility.ai_instruction || '',
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
});
};
const handleSaveEdit = async () => {
setIsSaving(true);
try {
2026-04-11 09:54:54 +02:00
const response = await adminFetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
2026-03-05 09:25:15 +01:00
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
});
if (!response.ok) throw new Error("Feil ved lagring");
setEditingFacility(null);
fetchFacilities();
} catch (error) {
alert("Kunne ikke lagre endringene.");
} finally { setIsSaving(false); }
2026-03-05 09:25:15 +01:00
};
2026-04-11 09:54:54 +02:00
const openTwoFactorModal = () => {
setShowTwoFactorModal(true);
setTwoFactorPassword('');
setTwoFactorError('');
setTwoFactorSetup(null);
setCopiedTwoFactorField(null);
};
const closeTwoFactorModal = () => {
setShowTwoFactorModal(false);
setTwoFactorPassword('');
setTwoFactorError('');
setTwoFactorSetup(null);
setCopiedTwoFactorField(null);
};
const handleLoadTwoFactorSetup = async (event: React.FormEvent) => {
event.preventDefault();
setTwoFactorError('');
setIsLoadingTwoFactor(true);
setCopiedTwoFactorField(null);
try {
const response = await adminFetch(`${API_URL}/admin/2fa/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: twoFactorPassword })
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente 2FA-oppsett');
setTwoFactorSetup(data);
setTwoFactorPassword('');
} catch (error) {
if (error instanceof Error && error.message === 'ADMIN_UNAUTHORIZED') return;
setTwoFactorSetup(null);
setTwoFactorError(error instanceof Error ? error.message : 'Kunne ikke hente 2FA-oppsett');
} finally {
setIsLoadingTwoFactor(false);
}
};
const copyTwoFactorValue = async (field: 'secret' | 'uri', value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedTwoFactorField(field);
window.setTimeout(() => setCopiedTwoFactorField(null), 2000);
} catch {
setCopiedTwoFactorField(null);
}
};
const handleLogout = async () => {
try {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} finally {
window.location.href = '/';
}
};
2026-04-10 09:52:34 +02:00
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER KONTROLLPANEL...</div>;
return (
2026-04-11 09:54:54 +02:00
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-x-hidden">
2026-03-05 09:25:15 +01:00
{/* REDIGER-MODAL FOR BANESTATUS */}
2026-03-05 09:25:15 +01:00
{editingFacility && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
<div className="bg-[#11280f] text-white p-6 shrink-0">
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
2026-03-05 09:25:15 +01:00
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
</div>
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
<div>
2026-03-12 13:39:10 +01:00
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL (Banestatus)</label>
<input type="text" value={editForm.scrape_status_url} onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="f.eks. https://golfklubb.no/banestatus" />
2026-03-05 09:25:15 +01:00
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
2026-03-12 13:39:10 +01:00
<select value={editForm.scrape_method} onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors">
2026-03-05 09:25:15 +01:00
<option value="css_selector">Standard (CSS)</option>
<option value="llm_parse"> Gemini AI (LLM)</option>
<option value="iframe_golfbox">Golfbox iframe</option>
<option value="click_then_css">Auto-klikk + CSS</option>
<option value="manual">🚨 Manuell (Ikke skrap)</option>
</select>
2026-03-06 09:39:32 +01:00
</div>
2026-03-05 09:25:15 +01:00
{editForm.scrape_method === 'llm_parse' && (
<div className="animate-fade-in">
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2"> AI-Hviskeren (Instruks til Gemini)</label>
2026-03-12 13:39:10 +01:00
<textarea value={editForm.ai_instruction || ''} onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})} className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen." rows={3} />
2026-03-05 09:25:15 +01:00
</div>
)}
{editForm.scrape_method === 'manual' && (
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
<div className="space-y-4">
{editForm.courses.map((course: any, idx: number) => (
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
2026-03-12 13:39:10 +01:00
<select value={course.status || 'ukjent'} onChange={(e) => { const newCourses = [...editForm.courses]; newCourses[idx].status = e.target.value; setEditForm({...editForm, courses: newCourses}); }} className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0">
2026-03-05 09:25:15 +01:00
<option value="aapen">🟢 Åpen</option>
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
<option value="aapner_snart">🟡 Åpner Snart</option>
<option value="stengt">🔴 Stengt</option>
<option value="stenger_snart">🔴 Stenger Snart</option>
<option value="under_utvikling">🔨 Under Utvikling</option>
<option value="nedlagt"> Nedlagt</option>
<option value="ukjent"> Ukjent</option>
</select>
</div>
))}
</div>
</div>
2026-03-06 09:39:32 +01:00
)}
2026-03-05 09:25:15 +01:00
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
2026-03-12 13:39:10 +01:00
<input type="text" value={editForm.scrape_status_selector} onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono" placeholder="f.eks. .status-text" />
2026-03-05 09:25:15 +01:00
</div>
)}
</div>
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
<button onClick={() => setEditingFacility(null)} className="px-6 py-3 rounded-xl text-xs font-bold uppercase tracking-widest text-gray-500 hover:bg-gray-200 transition-colors">Avbryt</button>
<button onClick={handleSaveEdit} disabled={isSaving} className="bg-[#8bc34a] text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
</button>
</div>
</div>
</div>
)}
2026-04-11 09:54:54 +02:00
{showTwoFactorModal && (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-black/60 p-3 md:p-4"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeTwoFactorModal();
}
}}
>
<div className="relative mx-auto my-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl">
<button
onClick={closeTwoFactorModal}
className="absolute right-7 top-7 z-30 inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/12 text-lg font-black text-white backdrop-blur transition-colors hover:bg-white/20"
aria-label="Lukk 2FA-vindu"
title="Lukk"
>
×
</button>
<div className="sticky top-0 z-20 flex items-start justify-between gap-4 bg-[#11280f] p-6 pr-20 text-white">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">1Password og TOTP</p>
<h3 className="mt-2 text-2xl font-black tracking-tight">Sett opp 2FA i 1Password</h3>
<p className="mt-2 max-w-2xl text-sm text-white/80">
Bekreft passordet ditt nytt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan brukes i 1Password.
</p>
</div>
<button onClick={closeTwoFactorModal} className="hidden rounded-xl bg-white/10 px-4 py-2 text-xs font-black uppercase tracking-widest text-white hover:bg-white/20 md:block">
Lukk
</button>
</div>
<div className="grid gap-0 lg:grid-cols-[20rem_minmax(0,1fr)]">
<aside className="border-b border-gray-100 bg-[#f8fbf4] p-6 lg:border-b-0 lg:border-r">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Før du starter</p>
<div className="mt-5 space-y-4 text-sm leading-relaxed text-gray-600">
<p>1Password støtter samme TOTP-standard som systemet allerede bruker.</p>
<p>Dette viser eksisterende 2FA-oppsett. Vi regenererer ikke nøkkelen, vanlig login fortsetter å virke som før.</p>
<p>Hvis du vil kan du skanne QR-koden eller kopiere den manuelle nøkkelen direkte inn i 1Password.</p>
</div>
</aside>
<div className="space-y-6 p-5 md:p-6">
<section className="space-y-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Sikker bekreftelse</p>
<h4 className="mt-2 text-xl font-black tracking-tight text-[#11280f]">Vis QR-kode</h4>
</div>
<form onSubmit={handleLoadTwoFactorSetup} className="space-y-4 rounded-[1.75rem] border border-gray-100 bg-gray-50 p-5">
<label className="block">
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-gray-500">Bekreft passord</span>
<input
type="password"
value={twoFactorPassword}
onChange={(e) => setTwoFactorPassword(e.target.value)}
className="w-full rounded-2xl border-2 border-gray-200 bg-white px-5 py-4 text-base font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
placeholder="Skriv inn admin-passordet ditt"
required
/>
</label>
{twoFactorError && (
<div className="rounded-2xl border border-red-100 bg-red-50 px-4 py-3 text-sm font-bold text-red-600">
{twoFactorError}
</div>
)}
<button
type="submit"
disabled={isLoadingTwoFactor || twoFactorPassword.trim().length === 0}
className="w-full rounded-2xl bg-[#8bc34a] px-6 py-4 text-xs font-black uppercase tracking-[0.2em] text-white shadow-lg transition-all hover:scale-[1.01] disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
>
{isLoadingTwoFactor ? 'Henter oppsett...' : 'Vis oppsett for 1Password'}
</button>
</form>
</section>
<section className="space-y-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">2FA-oppsett</p>
<h4 className="mt-2 text-xl font-black tracking-tight text-[#11280f]">QR-kode og nøkkel</h4>
</div>
{!twoFactorSetup ? (
<div className="flex min-h-[360px] items-center justify-center rounded-[1.75rem] border border-dashed border-gray-200 bg-white p-8 text-center">
<div className="max-w-md">
<p className="text-lg font-black text-[#11280f]">Ingen QR-kode vist ennå</p>
<p className="mt-3 text-sm text-gray-500">
Bekreft passordet ditt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan legges inn i 1Password.
</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="rounded-[1.75rem] border border-gray-100 bg-[#f8fbf4] p-5 shadow-sm">
<div className="mx-auto w-full max-w-[18rem] rounded-[1.5rem] bg-white p-4 shadow-inner" dangerouslySetInnerHTML={{ __html: twoFactorSetup.qr_svg }} />
<p className="mt-4 text-center text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">
Skann i 1Password
</p>
</div>
<div className="space-y-4 rounded-[1.75rem] border border-gray-100 bg-gray-50 p-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Konto</p>
<p className="mt-2 break-words text-lg font-black text-[#11280f]">{twoFactorSetup.account_name}</p>
<p className="text-sm text-gray-500">Issuer: {twoFactorSetup.issuer}</p>
</div>
<div className="rounded-2xl bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Manuell oppsettsnøkkel</p>
<p className="mt-3 break-all font-mono text-sm font-bold text-[#11280f]">{twoFactorSetup.otp_secret}</p>
<button
type="button"
onClick={() => copyTwoFactorValue('secret', twoFactorSetup.otp_secret)}
className="mt-4 w-full rounded-xl border border-gray-200 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-gray-500 transition-colors hover:border-[#8bc34a] hover:text-[#11280f] sm:w-auto"
>
{copiedTwoFactorField === 'secret' ? 'Kopiert' : 'Kopier nøkkel'}
</button>
</div>
<div className="rounded-2xl bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Avansert URI</p>
<p className="mt-3 break-all font-mono text-xs font-bold text-gray-600">{twoFactorSetup.provisioning_uri}</p>
<button
type="button"
onClick={() => copyTwoFactorValue('uri', twoFactorSetup.provisioning_uri)}
className="mt-4 w-full rounded-xl border border-gray-200 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-gray-500 transition-colors hover:border-[#8bc34a] hover:text-[#11280f] sm:w-auto"
>
{copiedTwoFactorField === 'uri' ? 'Kopiert' : 'Kopier URI'}
</button>
</div>
</div>
</div>
)}
</section>
</div>
</div>
</div>
</div>
)}
{showMobileAdminMenu && (
<div
className="fixed inset-0 z-50 bg-black/50 md:hidden"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setShowMobileAdminMenu(false);
}
}}
>
<div className="h-full w-[88%] max-w-[22rem] bg-[#11280f] p-6 text-white shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Adminmeny</p>
<h3 className="mt-2 text-2xl font-black tracking-tight">TeeOff Admin</h3>
</div>
<button
onClick={() => setShowMobileAdminMenu(false)}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10 text-xl font-black text-white hover:bg-white/20"
aria-label="Lukk adminmeny"
>
×
</button>
</div>
<nav className="mt-6 space-y-6 text-[11px] font-black uppercase tracking-[0.2em] text-[#7ca982]">
<Link href="/admin" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl border border-[#8bc34a]/30 bg-white/5 px-4 py-4 text-white">
Kontrollpanel
</Link>
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Datavask</div>
<Link href="/admin/medlemskap" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Medlemskap
</Link>
<Link href="/admin/greenfee" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Greenfee
</Link>
<Link href="/admin/vtg" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
VTG
</Link>
</div>
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
<button
onClick={() => {
setShowMobileAdminMenu(false);
openTwoFactorModal();
}}
className="block w-full rounded-2xl px-4 py-3 text-left hover:bg-white/5 hover:text-white"
>
2FA / 1Password
</button>
</div>
</nav>
<div className="mt-8 border-t border-white/10 pt-6">
<button
onClick={handleLogout}
className="w-full rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-4 text-left text-[11px] font-black uppercase tracking-[0.2em] text-red-300 hover:bg-red-500/20"
>
Logg ut
</button>
</div>
</div>
</div>
)}
2026-03-06 09:39:32 +01:00
{/* SIDEBAR */}
2026-03-05 09:25:15 +01:00
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
2026-03-12 13:39:10 +01:00
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny"></button>
2026-03-05 09:25:15 +01:00
</div>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
2026-04-10 09:52:34 +02:00
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Kontrollpanel">
{isSidebarCollapsed ? 'KP' : 'Kontrollpanel'}
</Link>
<div className="space-y-2 mt-4">
2026-04-10 09:52:34 +02:00
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Datavask</div>
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
</Link>
<Link href="/admin/greenfee" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Greenfee">
{isSidebarCollapsed ? 'G' : 'Greenfee'}
</Link>
<Link href="/admin/vtg" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Veien til Golf (VTG)">
{isSidebarCollapsed ? 'V' : 'VTG'}
</Link>
</div>
2026-04-11 09:54:54 +02:00
<div className="space-y-2 mt-6">
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Konto</div>
<button
onClick={openTwoFactorModal}
className={`block w-full text-left hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`}
title="2FA i 1Password"
>
{isSidebarCollapsed ? '2F' : '2FA / 1Password'}
</button>
</div>
</nav>
2026-03-05 09:25:15 +01:00
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
2026-04-11 09:54:54 +02:00
<button onClick={handleLogout} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
2026-03-05 09:25:15 +01:00
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
</button>
</div>
</aside>
2026-03-06 09:39:32 +01:00
{/* HOVEDINNHOLD */}
2026-04-11 09:54:54 +02:00
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-auto">
2026-03-05 09:25:15 +01:00
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
2026-04-11 09:54:54 +02:00
<div className="mb-6 flex md:hidden">
<button
onClick={() => setShowMobileAdminMenu(true)}
className="inline-flex items-center gap-3 rounded-2xl bg-[#11280f] px-5 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-white shadow-lg"
>
<span className="text-lg leading-none"></span>
Adminmeny
</button>
</div>
2026-03-12 13:39:10 +01:00
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8">
<div>
2026-04-10 09:52:34 +02:00
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Kontrollpanel</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Oversikt over {filteredFacilities.length} anlegg</p>
</div>
2026-03-12 13:39:10 +01:00
2026-04-11 09:54:54 +02:00
<div className="flex flex-wrap items-center gap-3">
<button
onClick={openTwoFactorModal}
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#8bc34a] hover:text-[#11280f]"
>
2FA / 1Password
</button>
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isQueueing}
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
${isQueueing ? 'bg-yellow-500 animate-pulse' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'}`}
>
{isQueueing ? 'Legger i kø...' : isScraping ? `Legg ${activeTab}-skraping i kø (${selectedFacilities.length})` : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
</button>
</div>
</header>
2026-03-06 09:39:32 +01:00
2026-04-11 16:01:36 +02:00
{queueFeedback && (
<div className={`mb-6 rounded-[1.5rem] border px-5 py-4 md:px-6 ${
queueFeedback.tone === 'success'
? 'border-[#d8e8c8] bg-[#f1f7ed]'
: queueFeedback.tone === 'warning'
? 'border-amber-200 bg-amber-50'
: queueFeedback.tone === 'error'
? 'border-red-100 bg-red-50'
: 'border-slate-200 bg-slate-50'
}`}>
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">Køstatus</p>
<p className="text-sm font-black text-[#11280f]">{queueFeedback.title}</p>
<p className="text-sm leading-relaxed text-gray-600">{queueFeedback.message}</p>
</div>
<button
onClick={() => setQueueFeedback(null)}
className="self-start rounded-xl border border-white/70 bg-white/80 px-3 py-2 text-[10px] font-black uppercase tracking-widest text-gray-500 transition-colors hover:text-[#11280f]"
>
Lukk
</button>
</div>
</div>
)}
2026-04-10 18:37:33 +02:00
{latestJob && latestJob.job_type === activeTab && (
<div className={`mb-8 rounded-[1.75rem] border p-5 md:p-6 animate-fade-in ${
latestJob.status === 'failed'
? 'bg-red-50 border-red-100'
: latestJob.status === 'completed'
? 'bg-[#f1f7ed] border-[#d8e8c8]'
: 'bg-amber-50 border-amber-100'
}`}>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
{JOB_LABELS[activeTab]} jobb #{latestJob.id}
</p>
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex px-3 py-1 rounded-xl text-[10px] font-black uppercase tracking-widest ${
latestJob.status === 'failed'
? 'bg-red-100 text-red-700'
: latestJob.status === 'completed'
? 'bg-[#d8e8c8] text-[#11280f]'
: 'bg-amber-100 text-amber-700 animate-pulse'
}`}>
{JOB_STATUS_LABELS[latestJob.status]}
</span>
<span className="text-xs font-bold text-[#11280f]">
{latestJob.total_facilities} anlegg
</span>
2026-04-11 16:01:36 +02:00
{latestJobAttemptLabel && (
<span className="inline-flex rounded-xl bg-white/80 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-white/70">
{latestJobAttemptLabel}
</span>
)}
{latestJobErrorLabel && (
<span className="inline-flex rounded-xl bg-white/80 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-white/70">
{latestJobErrorLabel}
</span>
)}
2026-04-10 18:37:33 +02:00
{latestJob.created_at && (
<span className="text-xs text-gray-500">
Opprettet {new Date(latestJob.created_at).toLocaleString('nb-NO')}
</span>
)}
</div>
2026-04-11 16:01:36 +02:00
{latestJobRetryLabel && (
<p className="text-xs font-bold text-amber-700 leading-relaxed">
{latestJobRetryLabel}
</p>
)}
2026-04-10 18:37:33 +02:00
{latestJobSummary && (
<p className="text-xs text-gray-600 leading-relaxed">{latestJobSummary}</p>
)}
{latestJob.error_message && (
<p className="text-xs text-red-600 leading-relaxed">{latestJob.error_message}</p>
)}
</div>
<button
onClick={() => fetchScrapeJobs(activeTab)}
className="px-4 py-2 rounded-xl bg-white text-[10px] font-black uppercase tracking-widest text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
>
Oppdater status
</button>
</div>
</div>
)}
2026-04-11 16:01:36 +02:00
{recentJobs.length > 0 && (
<section className="mb-8 rounded-[1.75rem] border border-gray-100 bg-[#fbfcf8] p-5 md:p-6">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Jobbhistorikk</p>
<h3 className="text-lg font-black tracking-tight text-[#11280f]">Siste jobber i {JOB_LABELS[activeTab].toLowerCase()}</h3>
</div>
<p className="text-xs text-gray-500">Kortene viser status, forsøk og siste kjente resultat uten sideveis scrolling.</p>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{recentJobs.map(job => {
const historyErrorLabel = job.error_code ? (JOB_ERROR_LABELS[job.error_code] || job.error_code.replaceAll('_', ' ')) : '';
const historyAttemptLabel = job.attempt_count && job.max_attempts ? `Forsøk ${job.attempt_count} av ${job.max_attempts}` : '';
const historySummary = job.result_summary
? Object.entries(job.result_summary)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`)
.join(' • ')
: '';
return (
<article
key={job.id}
className={`rounded-[1.5rem] border p-4 shadow-sm ${
job.status === 'failed'
? 'border-red-100 bg-white'
: job.status === 'completed'
? 'border-[#d8e8c8] bg-white'
: 'border-amber-100 bg-white'
}`}
>
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${
job.status === 'failed'
? 'bg-red-100 text-red-700'
: job.status === 'completed'
? 'bg-[#d8e8c8] text-[#11280f]'
: 'bg-amber-100 text-amber-700'
}`}>
{JOB_STATUS_LABELS[job.status]}
</span>
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Jobb #{job.id}</span>
<span className="text-xs font-bold text-[#11280f]">{job.total_facilities} anlegg</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{historyAttemptLabel && (
<span className="inline-flex rounded-xl bg-[#f4f7ef] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600">
{historyAttemptLabel}
</span>
)}
{historyErrorLabel && (
<span className="inline-flex rounded-xl bg-[#f4f7ef] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600">
{historyErrorLabel}
</span>
)}
{job.finished_at && (
<span className="text-xs text-gray-500">
Ferdig {new Date(job.finished_at).toLocaleString('nb-NO')}
</span>
)}
{!job.finished_at && job.created_at && (
<span className="text-xs text-gray-500">
Opprettet {new Date(job.created_at).toLocaleString('nb-NO')}
</span>
)}
</div>
{job.next_retry_at && job.status === 'pending' && (
<p className="mt-3 text-xs font-bold text-amber-700">
Nytt forsøk planlagt {new Date(job.next_retry_at).toLocaleString('nb-NO')}
</p>
)}
{historySummary && (
<p className="mt-3 text-xs leading-relaxed text-gray-600">{historySummary}</p>
)}
{job.error_message && (
<p className="mt-3 text-xs leading-relaxed text-red-600">{job.error_message}</p>
)}
</article>
);
})}
</div>
</section>
)}
2026-04-10 09:52:34 +02:00
{/* VELDIG SYNLIGE FANER */}
2026-04-11 09:54:54 +02:00
<div className="mb-8 flex flex-wrap gap-2 border-b-2 border-gray-100 pb-3">
2026-04-10 09:52:34 +02:00
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap</button>
<button onClick={() => setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee</button>
<button onClick={() => setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs</button>
2026-03-06 09:39:32 +01:00
</div>
2026-03-12 13:39:10 +01:00
{activeTab === 'banestatus' && (
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8 animate-fade-in">
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer status:</label>
<select id="statusFilter" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer">
<option value="alle">Vis alle anlegg</option>
<option value="aapne">🟢 Kun åpne baner</option>
<option value="ikke_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
<option value="stengt">🔴 Kun stengte baner</option>
<option value="ukjent_feil"> Ukjent / Skrapefeil</option>
</select>
</div>
)}
2026-04-11 09:54:54 +02:00
<div className="mb-6 flex flex-col gap-4 rounded-[1.75rem] border border-gray-100 bg-[#f8fbf4] p-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Arbeidsvisning</p>
<p className="text-sm font-bold text-[#11280f]">
Hvert anlegg vises som et eget arbeidskort, slik at du ser innhold, status og handlinger samlet uten sideveis scrolling.
</p>
</div>
<label className="inline-flex items-center gap-3 rounded-2xl bg-white px-4 py-3 text-xs font-black uppercase tracking-widest text-gray-500 shadow-sm">
<input
type="checkbox"
className="h-5 w-5 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
onChange={handleSelectAll}
/>
Velg alle i visningen
</label>
</div>
<div className="space-y-5 pb-4">
{filteredFacilities.map((f: any, index: number) => {
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
const accentStyles = [
'bg-white border-gray-100',
'bg-[#fbfdf8] border-[#e3edd7]',
'bg-[#f8fbff] border-[#dbe7f5]',
];
const accentStyle = isHighlighted ? 'bg-[#f3f9ea] border-[#b9d88d]' : accentStyles[index % accentStyles.length];
return (
<article
key={f.id}
className={`rounded-[1.9rem] border p-5 shadow-sm transition-all md:p-6 ${accentStyle} ${selectedFacilities.includes(f.id) ? 'ring-2 ring-[#8bc34a]/35 shadow-lg' : ''}`}
>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-4">
<input
type="checkbox"
className="mt-1 h-5 w-5 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.includes(f.id)}
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
/>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-2xl font-black tracking-tight text-[#11280f]">{f.name}</h3>
<span className="rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-gray-400 shadow-sm">ID {f.id}</span>
{isHighlighted && (
<span className="rounded-xl bg-[#8bc34a] px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-white">
Trenger oppmerksomhet
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs font-bold uppercase tracking-[0.18em] text-[#7ca982]">
<span>{f.city || 'Ukjent sted'}</span>
{activeTab === 'banestatus' && (
<span>{f.status_updated_at ? `Sjekket ${new Date(f.status_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri sjekket'}</span>
)}
{activeTab === 'medlemskap' && (
<span>{f.membership_updated_at ? `Vasket ${new Date(f.membership_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)}
{activeTab === 'greenfee' && (
<span>{f.greenfee_updated_at ? `Vasket ${new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)}
{activeTab === 'vtg' && (
<span>{f.vtg_updated_at ? `Vasket ${new Date(f.vtg_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)}
</div>
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:min-w-[180px]">
{activeTab === 'banestatus' && (
<button onClick={() => openEditModal(f)} className="rounded-2xl bg-white px-4 py-3 text-[10px] font-black uppercase tracking-widest text-[#11280f] shadow-sm transition-colors hover:bg-gray-100">
Innstillinger
</button>
)}
{activeTab === 'medlemskap' && hasMemDraft && (
<Link href="/admin/medlemskap" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
Til vaskeri
</Link>
)}
{activeTab === 'greenfee' && hasGfDraft && (
<Link href="/admin/greenfee" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
Til vaskeri
</Link>
)}
{activeTab === 'vtg' && hasVtgDraft && (
<Link href="/admin/vtg" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
Til vaskeri
</Link>
)}
<Link href={`/admin/rediger/${f.slug}`} className="rounded-2xl bg-[#11280f] px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-white transition-colors hover:bg-[#8bc34a]">
Rediger alt
</Link>
</div>
</div>
{activeTab === 'banestatus' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde og metode</p>
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
<p className="break-all text-[10px] font-mono text-gray-400">{f.scrape_status_selector || 'Ingen selector lagret'}</p>
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Metode</p>
<ScrapeMethodSelect facility={f} />
</div>
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Banestatus</p>
<div className="mt-4 space-y-2">
{f.course_statuses && f.course_statuses.length > 0 ? f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
return (
<div key={idx} className="flex items-center justify-between gap-3 rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="truncate text-xs font-black uppercase tracking-[0.18em] text-gray-500">{cs.name}</span>
<span className={`rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
</div>
);
}) : (
<p className="text-sm text-gray-500">Ingen baner registrert.</p>
)}
</div>
</section>
</div>
)}
{activeTab === 'medlemskap' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_220px]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
<div className="mt-4">
<InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} />
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Aktuelle priser</p>
<div className="mt-4 space-y-2 text-sm">
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Standard</span>
<span className="mt-1 block font-black text-[#11280f]">{f.standard_medlemskap ? `${f.standard_medlemskap},-` : 'Ikke registrert'}</span>
</div>
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Rimeligste</span>
<span className="mt-1 block font-black text-[#11280f]">{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : 'Ikke registrert'}</span>
</div>
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
<div className="mt-4 space-y-3">
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasMemDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasMemDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
</span>
</div>
</section>
</div>
)}
{activeTab === 'greenfee' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_220px]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
<div className="mt-4">
<InlineEdit facilityId={f.id} field="greenfee_url" initialValue={f.greenfee_url} onSave={handleQuickEdit} />
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Aktive priser</p>
<div className="mt-4 space-y-2">
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
<div key={i} className="grid gap-2 rounded-2xl bg-[#f8fbf4] px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div>
<p className="text-xs font-black text-[#11280f]">{g.banenavn || 'Uten banenavn'}</p>
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-gray-400">{g.priskategori || 'Standard'}</p>
</div>
<p className="text-xs font-black text-gray-600">V: {g.pris_voksne || '-'} J: {g.pris_junior || '-'}</p>
</div>
)) : (
<p className="text-sm text-gray-500">Ingen priser registrert.</p>
)}
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
<div className="mt-4 space-y-3">
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasGfDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasGfDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
</span>
</div>
</section>
</div>
)}
{activeTab === 'vtg' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_220px]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
<div className="mt-4">
<InlineEdit facilityId={f.id} field="vtg_lenke" initialValue={f.vtg_lenke} onSave={handleQuickEdit} />
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Registrert informasjon</p>
<div className="mt-4 space-y-3">
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Pris</span>
<span className="mt-1 block font-black text-[#8bc34a]">{f.vtg_pris ? `${f.vtg_pris},-` : 'Ikke registrert'}</span>
</div>
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Beskrivelse</span>
<span className="mt-1 block text-sm text-gray-600">{f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}</span>
</div>
<div className="inline-flex rounded-xl bg-white px-3 py-2 text-[10px] font-black uppercase tracking-widest text-[#11280f] shadow-sm">
{f.vtg_datoer && f.vtg_datoer.length > 0 ? `${f.vtg_datoer.length} kursdatoer` : 'Ingen kursdatoer'}
</div>
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
<div className="mt-4 space-y-3">
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasVtgDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasVtgDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
</span>
</div>
</section>
</div>
)}
</div>
</article>
);
})}
</div>
<div className="hidden mb-4 items-center justify-between gap-4 text-[10px] font-black uppercase tracking-widest text-gray-400">
<span>Scroll sidelengs for flere kolonner</span>
<span className="text-[#7ca982]">Venstre og høyre kant er låst</span>
</div>
<div className="hidden overflow-x-auto overflow-y-visible pb-4 rounded-[1.5rem] border border-gray-100 bg-white">
<table className="w-max min-w-full text-left border-collapse min-w-[1100px]">
2026-03-06 09:39:32 +01:00
<thead>
2026-04-10 09:52:34 +02:00
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100">
2026-04-11 09:54:54 +02:00
<th className="pb-4 pl-4 w-10 sticky left-0 z-20 bg-white"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} /></th>
<th className="pb-4 w-12 text-center sticky left-[56px] z-20 bg-white">ID</th>
<th className="pb-4 pr-6 sticky left-[104px] z-20 bg-white min-w-[220px]">Anlegg</th>
2026-03-12 13:39:10 +01:00
{activeTab === 'banestatus' && (
2026-03-12 13:39:10 +01:00
<>
<th className="pb-4">Konfigurasjon (URL & Selektor)</th>
<th className="pb-4">Metode</th>
<th className="pb-4">Siste Sjekk</th>
<th className="pb-4">Banestatus</th>
</>
)}
{activeTab === 'medlemskap' && (
2026-03-12 13:39:10 +01:00
<>
2026-04-10 09:52:34 +02:00
<th className="pb-4">Medlemskap-side (Klikk for å redigere)</th>
2026-03-12 13:39:10 +01:00
<th className="pb-4">Nåværende Priser</th>
2026-04-10 09:52:34 +02:00
<th className="pb-4 text-center">Nytt Utkast?</th>
<th className="pb-4">Sist Vasket</th>
</>
)}
{activeTab === 'greenfee' && (
<>
<th className="pb-4">Greenfee-side (Klikk for å redigere)</th>
<th className="pb-4">Aktive priser</th>
2026-04-10 09:52:34 +02:00
<th className="pb-4 text-center">Nytt Utkast?</th>
<th className="pb-4">Sist Vasket</th>
</>
)}
{activeTab === 'vtg' && (
<>
<th className="pb-4">VTG-side (Klikk for å redigere)</th>
2026-04-10 09:52:34 +02:00
<th className="pb-4 w-64">Registrert Informasjon</th>
<th className="pb-4 text-center">Nytt Utkast?</th>
2026-03-12 13:39:10 +01:00
<th className="pb-4">Sist Vasket</th>
</>
)}
2026-04-11 09:54:54 +02:00
<th className="pb-4 text-right pr-4 sticky right-0 z-20 bg-white min-w-[150px]">Handling</th>
</tr>
</thead>
2026-04-11 09:54:54 +02:00
<tbody className="text-sm font-bold text-[#11280f]">
2026-03-12 13:39:10 +01:00
{filteredFacilities.map((f: any) => {
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
2026-03-12 13:39:10 +01:00
return (
2026-04-10 09:52:34 +02:00
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50'}`}>
2026-04-11 09:54:54 +02:00
<td className={`py-6 pl-4 w-10 sticky left-0 z-10 ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.includes(f.id)} onChange={(e) => handleSelectOne(f.id, e.target.checked)} /></td>
<td className={`py-6 text-center text-xs font-mono text-gray-400 sticky left-[56px] z-10 ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>#{f.id}</td>
<td className={`py-6 pr-6 sticky left-[104px] z-10 min-w-[220px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
2026-03-12 13:39:10 +01:00
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
{activeTab === 'banestatus' && (
2026-03-12 13:39:10 +01:00
<>
<td className="py-6 pr-4">
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div>
</td>
<td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td>
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
2026-03-12 13:39:10 +01:00
<td className="py-6 pr-4">
<div className="flex flex-col gap-1">
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
return (
<div key={idx} className="flex items-center gap-2">
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>{cs.name}</span>
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
</div>
)
})}
2026-03-05 05:18:03 +01:00
</div>
2026-03-12 13:39:10 +01:00
</td>
</>
)}
{activeTab === 'medlemskap' && (
2026-03-12 13:39:10 +01:00
<>
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} /></td>
2026-03-12 13:39:10 +01:00
<td className="py-6 pr-4">
<div className="flex flex-col gap-1">
<span className="text-xs">Standard: <strong>{f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}</strong></span>
<span className="text-xs text-gray-500">Rimeligste: <strong>{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}</strong></span>
</div>
</td>
2026-04-10 09:52:34 +02:00
<td className="py-6 pr-4 text-center">{hasMemDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
</>
)}
{activeTab === 'greenfee' && (
<>
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="greenfee_url" initialValue={f.greenfee_url} onSave={handleQuickEdit} /></td>
<td className="py-6 pr-4">
2026-04-10 09:52:34 +02:00
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2">
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
2026-04-10 09:52:34 +02:00
<div key={i} className="flex justify-between border-b border-gray-50 pb-1">
<span className="truncate max-w-[120px]">{g.banenavn}</span>
<span className="font-bold text-[#11280f]">V: {g.pris_voksne} J: {g.pris_junior}</span>
</div>
)) : 'Ingen priser'}
</div>
2026-03-12 13:39:10 +01:00
</td>
2026-04-10 09:52:34 +02:00
<td className="py-6 pr-4 text-center">{hasGfDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.greenfee_updated_at ? new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
</>
)}
{activeTab === 'vtg' && (
<>
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="vtg_lenke" initialValue={f.vtg_lenke} onSave={handleQuickEdit} /></td>
<td className="py-6 pr-4 max-w-[250px]">
<div className="flex flex-col gap-1">
2026-04-10 09:52:34 +02:00
<span className="text-xs">Pris: <strong className="text-[#8bc34a]">{f.vtg_pris ? `${f.vtg_pris},-` : '---'}</strong></span>
<span className="text-[10px] text-gray-500 line-clamp-2" title={f.vtg_beskrivelse}>{f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}</span>
<span className="text-[10px] font-bold text-[#11280f] mt-1 bg-gray-50 px-2 py-1 rounded-md inline-block w-max">
{f.vtg_datoer && f.vtg_datoer.length > 0 ? `📅 ${f.vtg_datoer.length} kursdato(er)` : '📅 Ingen datoer registrert'}
</span>
</div>
2026-03-12 13:39:10 +01:00
</td>
2026-04-10 09:52:34 +02:00
<td className="py-6 pr-4 text-center">{hasVtgDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
2026-03-12 13:39:10 +01:00
</>
)}
2026-04-11 09:54:54 +02:00
<td className={`py-6 text-right pr-4 sticky right-0 z-10 min-w-[150px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
2026-03-12 13:39:10 +01:00
<div className="flex flex-col gap-2 items-end">
2026-04-10 09:52:34 +02:00
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap">Innstillinger</button>}
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200"> til Vaskeri</Link>}
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200"> til Vaskeri</Link>}
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200"> til Vaskeri</Link>}
2026-03-12 13:39:10 +01:00
<Link href={`/admin/rediger/${f.slug}`} className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center">Rediger alt</Link>
</div>
</td>
</tr>
);
})}
</tbody>
2026-03-06 09:39:32 +01:00
</table>
</div>
</div>
</main>
</div>
);
2026-04-10 18:37:33 +02:00
}