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

2414 lines
130 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* TEE OFF ADMIN DASHBOARD v4.0 - KONTROLLPANEL
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
import Link from 'next/link';
type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'golfpakker' | 'vtg';
type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed';
type ScrapeJobEvent = {
timestamp?: string;
facility_id?: number | null;
facility_name?: string;
outcome?: 'success' | 'warning' | 'error' | 'info' | string;
message?: string;
processed?: number;
total?: number;
};
type ScrapeJob = {
id: number;
job_type: AdminTab;
status: ScrapeJobStatus;
facility_ids: number[];
total_facilities: number;
error_message?: string | null;
error_code?: string | null;
retryable?: boolean;
attempt_count?: number;
max_attempts?: number;
next_retry_at?: string | null;
last_error_at?: string | null;
progress_total?: number;
progress_completed?: number;
progress_ok?: number;
progress_failed?: number;
progress_skipped?: number;
current_facility_id?: number | null;
current_facility_name?: string | null;
recent_events?: ScrapeJobEvent[];
result_summary?: Record<string, number | string | null>;
created_at?: string | null;
started_at?: string | null;
finished_at?: string | null;
};
type QueueFeedback = {
tone: 'success' | 'warning' | 'info' | 'error';
title: string;
message: string;
};
type TwoFactorSetupResponse = {
issuer: string;
account_name: string;
otp_secret: string;
provisioning_uri: string;
qr_svg: string;
};
type ManualOverrideTab = 'medlemskap' | 'greenfee' | 'vtg';
type GreenfeeRow = {
banenavn: string;
priskategori: string;
pris_voksne: string;
pris_junior: string;
};
type VtgDateRow = {
dato: string;
status: string;
};
type ManualOverrideForm = {
medlemskap_url: string;
navn_standard_medlemskap: string;
standard_medlemskap: string;
standard_medlemskap_kommentarer: string;
navn_rimeligste_alternativ: string;
rimeligste_alternativ: string;
greenfee_url: string;
guest_requirements: string;
greenfee: GreenfeeRow[];
vtg_lenke: string;
vtg_pris: string;
vtg_beskrivelse: string;
vtg_datoer: VtgDateRow[];
};
const JOB_LABELS: Record<AdminTab, string> = {
banestatus: 'Banestatus',
medlemskap: 'Medlemskap',
greenfee: 'Greenfee',
golfpakker: 'Golfpakker',
vtg: 'VTG',
};
const JOB_STATUS_LABELS: Record<ScrapeJobStatus, string> = {
pending: 'I kø',
running: 'Kjører',
completed: 'Fullført',
failed: 'Feilet',
};
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',
};
const JOB_EVENT_TONE_CLASSES: Record<string, string> = {
success: 'bg-[#edf6e3] text-[#11280f] border-[#d8e8c8]',
warning: 'bg-amber-50 text-amber-800 border-amber-200',
error: 'bg-red-50 text-red-700 border-red-200',
info: 'bg-slate-50 text-slate-700 border-slate-200',
};
const EMPTY_GREENFEE_ROW: GreenfeeRow = {
banenavn: '',
priskategori: '',
pris_voksne: '',
pris_junior: '',
};
const EMPTY_VTG_DATE_ROW: VtgDateRow = {
dato: '',
status: 'Ledig',
};
const EMPTY_MANUAL_OVERRIDE_FORM: ManualOverrideForm = {
medlemskap_url: '',
navn_standard_medlemskap: '',
standard_medlemskap: '',
standard_medlemskap_kommentarer: '',
navn_rimeligste_alternativ: '',
rimeligste_alternativ: '',
greenfee_url: '',
guest_requirements: '',
greenfee: [],
vtg_lenke: '',
vtg_pris: '',
vtg_beskrivelse: '',
vtg_datoer: [],
};
const toInputString = (value: unknown) => {
if (value === null || value === undefined) return '';
return String(value);
};
const normalizeGreenfeeRows = (value: unknown): GreenfeeRow[] => {
if (!Array.isArray(value)) return [];
return value.map((row: any) => ({
banenavn: toInputString(row?.banenavn),
priskategori: toInputString(row?.priskategori),
pris_voksne: toInputString(row?.pris_voksne),
pris_junior: toInputString(row?.pris_junior),
}));
};
const normalizeVtgDates = (value: unknown): VtgDateRow[] => {
if (!Array.isArray(value)) return [];
return value.map((row: any) => ({
dato: toInputString(row?.dato),
status: toInputString(row?.status) || 'Ledig',
}));
};
const toNullableNumber = (value: unknown) => {
const normalized = toInputString(value).trim();
if (!normalized) return null;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
};
type InlineEditProps = {
facilityId: number;
field: string;
initialValue: string;
onSave: (id: number, field: string, val: string) => void;
placeholder?: string;
emptyLabel?: string;
title?: string;
inputRows?: number;
editorWidthClassName?: string;
displayClassName?: string;
renderHtml?: boolean;
};
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const sanitizeHref = (value: string) => {
const href = value.trim();
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
};
const sanitizeInlineRichText = (value: string | null | undefined) => {
const source = String(value || "").replace(/\r\n?/g, "\n");
if (!source.trim()) return "";
const placeholders = new Map<string, string>();
let index = 0;
const keep = (html: string) => {
const key = `__HTML_TOKEN_${index++}__`;
placeholders.set(key, html);
return key;
};
let safe = source
.replace(/<\s*br\s*\/?\s*>/gi, () => keep("<br />"))
.replace(/<\s*(strong|b)\s*>/gi, () => keep("<strong>"))
.replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("</strong>"))
.replace(/<\s*(em|i)\s*>/gi, () => keep("<em>"))
.replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("</em>"))
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
if (/^(\/|#|mailto:|tel:)/i.test(href) || /^https?:\/\/([^/]+\.)?teeoff\.no(\/|$)/i.test(href)) {
return keep(`<a href="${escapeHtml(href)}">`);
}
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">`);
})
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
safe = escapeHtml(safe).replace(/\n/g, "<br />");
for (const [token, html] of placeholders) {
safe = safe.replaceAll(token, html);
}
return safe;
};
const InlineEdit = ({
facilityId,
field,
initialValue,
onSave,
placeholder = 'Lim inn verdi...',
emptyLabel = 'Mangler verdi',
title = 'Klikk for å redigere',
inputRows = 2,
editorWidthClassName = 'max-w-[200px]',
displayClassName = 'text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2',
renderHtml = false,
}: InlineEditProps) => {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue || '');
useEffect(() => {
setValue(initialValue || '');
}, [initialValue]);
const handleSave = () => {
setIsEditing(false);
if (value !== initialValue) {
onSave(facilityId, field, value);
}
};
if (isEditing) {
return (
<div className={`flex w-full flex-col gap-1 animate-fade-in ${editorWidthClassName}`}>
<textarea autoFocus rows={inputRows} 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={placeholder} />
<div className="flex gap-1">
<button onClick={handleSave} className="btn btn-sm btn-primary flex-1">Lagre</button>
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="btn btn-sm btn-secondary">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={title}>
<div className={displayClassName}>
{initialValue ? (
renderHtml ? (
<span
className="[&_a]:font-bold [&_a]:text-[#C94F2D] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#9F3117] [&_em]:italic [&_strong]:font-extrabold"
dangerouslySetInnerHTML={{ __html: sanitizeInlineRichText(initialValue) }}
/>
) : (
initialValue
)
) : <span className="text-red-400 italic">{emptyLabel}</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() {
const [facilities, setFacilities] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
const [facilityJumpSlug, setFacilityJumpSlug] = useState('');
const [showBackToTop, setShowBackToTop] = useState(false);
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
const [isQueueing, setIsQueueing] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false);
const [editingFacility, setEditingFacility] = useState<any | null>(null);
const [activeTab, setActiveTab] = useState<AdminTab>('banestatus');
const [statusFilter, setStatusFilter] = useState('alle');
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
const [isSaving, setIsSaving] = useState(false);
const latestJobStateRef = useRef<string | null>(null);
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);
const [queueFeedback, setQueueFeedback] = useState<QueueFeedback | null>(null);
const [manualEditFacility, setManualEditFacility] = useState<any | null>(null);
const [manualEditTab, setManualEditTab] = useState<ManualOverrideTab | null>(null);
const [manualEditForm, setManualEditForm] = useState<ManualOverrideForm>(EMPTY_MANUAL_OVERRIDE_FORM);
const [isManualSaving, setIsManualSaving] = useState(false);
const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({});
const mainContentRef = useRef<HTMLElement | null>(null);
const fetchFacilities = () => {
adminFetch(`${API_URL}/admin/facilities`)
.then(res => res.json())
.then(data => {
setFacilities(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => setLoading(false));
};
const fetchScrapeJobs = (tab: AdminTab = activeTab) => {
adminFetch(`${API_URL}/admin/scrape-jobs?job_type=${tab}&limit=5`)
.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;
const latestJobCardKey = latestJob
? `${latestJob.job_type}:${latestJob.id}:${latestJob.status}:${latestJob.attempt_count ?? 0}:${latestJob.next_retry_at ?? ''}:${latestJob.finished_at ?? ''}`
: null;
const isLatestJobDismissed =
!!latestJobCardKey &&
latestJob.job_type === activeTab &&
dismissedLatestJobKeys[activeTab] === latestJobCardKey;
useEffect(() => {
fetchFacilities();
fetchScrapeJobs('banestatus');
}, []);
useEffect(() => {
const interval = setInterval(() => fetchScrapeJobs(activeTab), 5000);
return () => clearInterval(interval);
}, [activeTab]);
useEffect(() => {
setSelectedFacilities([]);
setQueueFeedback(null);
fetchScrapeJobs(activeTab);
}, [activeTab]);
useEffect(() => {
if (!isScraping) return;
const interval = setInterval(() => fetchFacilities(), 10000);
return () => clearInterval(interval);
}, [isScraping]);
useEffect(() => {
const currentState = latestJob
? `${latestJob.id}:${latestJob.status}:${latestJob.attempt_count ?? 0}:${latestJob.next_retry_at ?? ''}:${latestJob.finished_at ?? ''}`
: null;
const previousState = latestJobStateRef.current;
latestJobStateRef.current = currentState;
if (
previousState &&
previousState !== currentState &&
latestJob &&
(latestJob.status === 'completed' || latestJob.status === 'failed')
) {
fetchFacilities();
fetchScrapeJobs(activeTab);
}
}, [activeTab, latestJob]);
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]);
useEffect(() => {
const container = mainContentRef.current;
if (!container) return;
const handleScroll = () => {
setShowBackToTop(container.scrollTop > 500);
};
handleScroll();
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
}, []);
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';
if (statusFilter === 'aapne') return s === 'aapen';
if (statusFilter === 'ikke_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';
return true;
});
return { ...facility, course_statuses: filteredCourses };
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
}, [facilities, statusFilter]);
const facilityJumpOptions = useMemo(
() =>
[...facilities]
.filter((facility) => facility?.slug && facility?.name)
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'nb-NO'))
.map((facility) => ({
slug: String(facility.slug),
label: String(facility.name),
city: String(facility.city || '').trim(),
})),
[facilities]
);
useEffect(() => {
if (!facilityJumpSlug) return;
if (activeTab === 'banestatus' && statusFilter !== 'alle') {
setStatusFilter('alle');
return;
}
const targetId = `facility-card-${facilityJumpSlug}`;
const frameId = window.requestAnimationFrame(() => {
const target = document.getElementById(targetId);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
return () => window.cancelAnimationFrame(frameId);
}, [activeTab, facilityJumpSlug, filteredFacilities, statusFilter]);
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]);
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]);
const showJobHistory = recentJobs.length > 0 && !isLatestJobDismissed;
const latestJobProgress = useMemo(() => {
const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0;
const completed = Math.min(latestJob?.progress_completed ?? 0, total || 0);
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
return { total, completed, percent };
}, [latestJob]);
const latestJobEvents = useMemo(() => {
const events = Array.isArray(latestJob?.recent_events) ? latestJob.recent_events : [];
return [...events].reverse().slice(0, 6);
}, [latestJob]);
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id));
else setSelectedFacilities([]);
};
const handleSelectOne = (id: number, checked: boolean) => {
if (checked) setSelectedFacilities([...selectedFacilities, id]);
else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
};
const handleQuickEdit = async (id: number, field: string, value: string) => {
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
try {
const res = await adminFetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
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();
}
};
const handleRunScrapers = async () => {
if (selectedFacilities.length === 0) return;
setIsQueueing(true);
setQueueFeedback(null);
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
activeTab === 'golfpakker' ? '/admin/run-golfpakker-scraper' :
'/admin/run-vtg-scraper';
try {
const response = await adminFetch(`${API_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ facility_ids: selectedFacilities })
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || "Kunne ikke starte skraping");
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.')
});
}
fetchScrapeJobs(activeTab);
} catch (error) {
setQueueFeedback({
tone: 'error',
title: 'Kunne ikke starte jobben',
message: `Det oppstod en feil ved start av ${activeTab}-skraperen.`
});
alert(`Feil ved start av ${activeTab}-skraperen.`);
} finally {
setIsQueueing(false);
}
};
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 {
const response = await adminFetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
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); }
};
const closeManualOverrideModal = () => {
setManualEditFacility(null);
setManualEditTab(null);
setManualEditForm(EMPTY_MANUAL_OVERRIDE_FORM);
setIsManualSaving(false);
};
const openManualOverrideModal = (facility: any, tab: ManualOverrideTab) => {
setManualEditFacility(facility);
setManualEditTab(tab);
setManualEditForm({
medlemskap_url: facility.medlemskap_url || '',
navn_standard_medlemskap: facility.navn_standard_medlemskap || '',
standard_medlemskap: toInputString(facility.standard_medlemskap),
standard_medlemskap_kommentarer: facility.standard_medlemskap_kommentarer || '',
navn_rimeligste_alternativ: facility.navn_rimeligste_alternativ || '',
rimeligste_alternativ: toInputString(facility.rimeligste_alternativ),
greenfee_url: facility.greenfee_url || '',
guest_requirements: facility.guest_requirements || '',
greenfee: normalizeGreenfeeRows(facility.greenfee),
vtg_lenke: facility.vtg_lenke || '',
vtg_pris: toInputString(facility.vtg_pris),
vtg_beskrivelse: facility.vtg_beskrivelse || '',
vtg_datoer: normalizeVtgDates(facility.vtg_datoer),
});
};
const updateManualOverrideField = (field: keyof ManualOverrideForm, value: string) => {
setManualEditForm((prev) => ({ ...prev, [field]: value }));
};
const updateManualGreenfeeRow = (index: number, field: keyof GreenfeeRow, value: string) => {
setManualEditForm((prev) => {
const nextRows = [...prev.greenfee];
nextRows[index] = { ...nextRows[index], [field]: value };
return { ...prev, greenfee: nextRows };
});
};
const addManualGreenfeeRow = () => {
setManualEditForm((prev) => ({ ...prev, greenfee: [...prev.greenfee, { ...EMPTY_GREENFEE_ROW }] }));
};
const removeManualGreenfeeRow = (index: number) => {
setManualEditForm((prev) => ({ ...prev, greenfee: prev.greenfee.filter((_, rowIndex) => rowIndex !== index) }));
};
const updateManualVtgDateRow = (index: number, field: keyof VtgDateRow, value: string) => {
setManualEditForm((prev) => {
const nextRows = [...prev.vtg_datoer];
nextRows[index] = { ...nextRows[index], [field]: value };
return { ...prev, vtg_datoer: nextRows };
});
};
const addManualVtgDateRow = () => {
setManualEditForm((prev) => ({ ...prev, vtg_datoer: [...prev.vtg_datoer, { ...EMPTY_VTG_DATE_ROW }] }));
};
const removeManualVtgDateRow = (index: number) => {
setManualEditForm((prev) => ({ ...prev, vtg_datoer: prev.vtg_datoer.filter((_, rowIndex) => rowIndex !== index) }));
};
const handleSaveManualOverride = async () => {
if (!manualEditFacility || !manualEditTab) return;
const now = new Date().toISOString();
let payload: Record<string, unknown> = {};
if (manualEditTab === 'medlemskap') {
payload = {
medlemskap_url: manualEditForm.medlemskap_url.trim(),
navn_standard_medlemskap: manualEditForm.navn_standard_medlemskap.trim(),
standard_medlemskap: toNullableNumber(manualEditForm.standard_medlemskap),
standard_medlemskap_kommentarer: manualEditForm.standard_medlemskap_kommentarer.trim(),
navn_rimeligste_alternativ: manualEditForm.navn_rimeligste_alternativ.trim(),
rimeligste_alternativ: toNullableNumber(manualEditForm.rimeligste_alternativ),
membership_updated_at: now,
membership_draft: null,
};
}
if (manualEditTab === 'greenfee') {
payload = {
greenfee_url: manualEditForm.greenfee_url.trim(),
guest_requirements: manualEditForm.guest_requirements.trim(),
greenfee: manualEditForm.greenfee
.map((row) => ({
banenavn: row.banenavn.trim(),
priskategori: row.priskategori.trim(),
pris_voksne: toNullableNumber(row.pris_voksne),
pris_junior: toNullableNumber(row.pris_junior),
}))
.filter((row) => row.banenavn || row.priskategori || row.pris_voksne !== null || row.pris_junior !== null),
greenfee_updated_at: now,
greenfee_draft: null,
};
}
if (manualEditTab === 'vtg') {
payload = {
vtg_lenke: manualEditForm.vtg_lenke.trim(),
vtg_pris: toNullableNumber(manualEditForm.vtg_pris),
vtg_beskrivelse: manualEditForm.vtg_beskrivelse.trim(),
vtg_datoer: manualEditForm.vtg_datoer
.map((row) => ({
dato: row.dato.trim(),
status: row.status.trim() || 'Ledig',
}))
.filter((row) => row.dato),
vtg_updated_at: now,
vtg_draft: null,
};
}
setIsManualSaving(true);
try {
const response = await adminFetch(`${API_URL}/admin/facilities/${manualEditFacility.id}/full`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("Feil ved lagring");
closeManualOverrideModal();
fetchFacilities();
} catch (error) {
alert("Kunne ikke lagre de manuelle verdiene.");
} finally {
setIsManualSaving(false);
}
};
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 = '/';
}
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER KONTROLLPANEL...</div>;
return (
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-x-hidden">
{/* REDIGER-MODAL FOR BANESTATUS */}
{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>
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
</div>
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
<div>
<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" />
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
<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">
<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>
</div>
{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>
<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} />
</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>
<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">
<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>
)}
{(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>
<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" />
</div>
)}
</div>
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
<button onClick={() => setEditingFacility(null)} className="btn btn-md btn-secondary">Avbryt</button>
<button onClick={handleSaveEdit} disabled={isSaving} className="btn btn-md btn-primary disabled:opacity-50">
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
</button>
</div>
</div>
</div>
)}
{manualEditFacility && manualEditTab && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-3xl bg-white shadow-2xl">
<div className="shrink-0 bg-[#11280f] p-6 text-white">
<h3 className="text-xl font-black uppercase tracking-widest">
Manuell overstyring: {JOB_LABELS[manualEditTab]}
</h3>
<p className="text-sm text-[#7ca982]">{manualEditFacility.name}</p>
</div>
<div className="flex-grow space-y-6 overflow-y-auto p-8">
{manualEditTab === 'medlemskap' && (
<div className="space-y-6">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Kilde-URL</label>
<input
type="text"
value={manualEditForm.medlemskap_url}
onChange={(e) => updateManualOverrideField('medlemskap_url', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="https://..."
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Standard medlemskap</p>
<div className="mt-4 space-y-3">
<input
type="text"
value={manualEditForm.navn_standard_medlemskap}
onChange={(e) => updateManualOverrideField('navn_standard_medlemskap', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Navn på standard medlemskap"
/>
<input
type="number"
value={manualEditForm.standard_medlemskap}
onChange={(e) => updateManualOverrideField('standard_medlemskap', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Pris i kroner"
/>
<textarea
rows={3}
value={manualEditForm.standard_medlemskap_kommentarer}
onChange={(e) => updateManualOverrideField('standard_medlemskap_kommentarer', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm outline-none focus:border-[#8bc34a]"
placeholder="Kommentar eller forbehold"
/>
</div>
</section>
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste alternativ</p>
<div className="mt-4 space-y-3">
<input
type="text"
value={manualEditForm.navn_rimeligste_alternativ}
onChange={(e) => updateManualOverrideField('navn_rimeligste_alternativ', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Navn på billigste alternativ"
/>
<input
type="number"
value={manualEditForm.rimeligste_alternativ}
onChange={(e) => updateManualOverrideField('rimeligste_alternativ', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Pris i kroner"
/>
</div>
</section>
</div>
</div>
)}
{manualEditTab === 'greenfee' && (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Kilde-URL</label>
<input
type="text"
value={manualEditForm.greenfee_url}
onChange={(e) => updateManualOverrideField('greenfee_url', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="https://..."
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Krav til gjestespill</label>
<input
type="text"
value={manualEditForm.guest_requirements}
onChange={(e) => updateManualOverrideField('guest_requirements', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="F.eks. klubbhandicap"
/>
</div>
</div>
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Greenfee-priser</p>
<button type="button" onClick={addManualGreenfeeRow} className="btn btn-sm btn-secondary">
+ Legg til prisrad
</button>
</div>
<div className="mt-4 space-y-3">
{manualEditForm.greenfee.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500">
Ingen priser registrert ennå.
</div>
) : (
manualEditForm.greenfee.map((row, index) => (
<div key={index} className="grid gap-3 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_110px_110px_auto] md:items-center">
<input
type="text"
value={row.banenavn}
onChange={(e) => updateManualGreenfeeRow(index, 'banenavn', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Bane"
/>
<input
type="text"
value={row.priskategori}
onChange={(e) => updateManualGreenfeeRow(index, 'priskategori', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm outline-none focus:border-[#8bc34a]"
placeholder="Kategori"
/>
<input
type="number"
value={row.pris_voksne}
onChange={(e) => updateManualGreenfeeRow(index, 'pris_voksne', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-center text-sm outline-none focus:border-[#8bc34a]"
placeholder="Voksen"
/>
<input
type="number"
value={row.pris_junior}
onChange={(e) => updateManualGreenfeeRow(index, 'pris_junior', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-center text-sm outline-none focus:border-[#8bc34a]"
placeholder="Junior"
/>
<button type="button" onClick={() => removeManualGreenfeeRow(index)} className="btn btn-sm btn-danger">
</button>
</div>
))
)}
</div>
</section>
</div>
)}
{manualEditTab === 'vtg' && (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">VTG-lenke</label>
<input
type="text"
value={manualEditForm.vtg_lenke}
onChange={(e) => updateManualOverrideField('vtg_lenke', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="https://..."
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Pris</label>
<input
type="number"
value={manualEditForm.vtg_pris}
onChange={(e) => updateManualOverrideField('vtg_pris', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm font-bold outline-none transition-colors focus:border-[#8bc34a]"
placeholder="Pris i kroner"
/>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Beskrivelse</label>
<textarea
rows={4}
value={manualEditForm.vtg_beskrivelse}
onChange={(e) => updateManualOverrideField('vtg_beskrivelse', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="Beskriv hva som inngår i kurset"
/>
</div>
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Kursdatoer</p>
<button type="button" onClick={addManualVtgDateRow} className="btn btn-sm btn-secondary">
+ Legg til dato
</button>
</div>
<div className="mt-4 space-y-3">
{manualEditForm.vtg_datoer.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500">
Ingen kursdatoer registrert ennå.
</div>
) : (
manualEditForm.vtg_datoer.map((row, index) => (
<div key={index} className="grid gap-3 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(0,1fr)_180px_auto] md:items-center">
<input
type="text"
value={row.dato}
onChange={(e) => updateManualVtgDateRow(index, 'dato', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="F.eks. 12.-14. mai"
/>
<select
value={row.status}
onChange={(e) => updateManualVtgDateRow(index, 'status', e.target.value)}
className="w-full rounded-xl border border-gray-200 bg-white p-3 text-sm outline-none focus:border-[#8bc34a]"
>
<option value="Ledig">Ledig</option>
<option value="Fulltegnet">Fulltegnet</option>
<option value="Venteliste">Venteliste</option>
<option value="Få plasser"> plasser</option>
</select>
<button type="button" onClick={() => removeManualVtgDateRow(index)} className="btn btn-sm btn-danger">
</button>
</div>
))
)}
</div>
</section>
</div>
)}
</div>
<div className="flex shrink-0 justify-end gap-4 bg-gray-50 p-6">
<button onClick={closeManualOverrideModal} className="btn btn-md btn-secondary">
Avbryt
</button>
<button onClick={handleSaveManualOverride} disabled={isManualSaving} className="btn btn-md btn-primary disabled:opacity-50">
{isManualSaving ? 'Lagrer...' : 'Lagre manuelt'}
</button>
</div>
</div>
</div>
)}
{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="btn-icon absolute right-7 top-7 z-30 border-white/20 bg-white/12 text-lg text-white backdrop-blur 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="btn btn-sm btn-secondary-dark hidden md:inline-flex">
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="btn btn-lg btn-primary w-full 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="btn btn-md btn-secondary mt-4 w-full 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="btn btn-md btn-secondary mt-4 w-full 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/golfpakker" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Golfpakker
</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">Innhold</div>
<Link href="/admin/steder" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Steder
</Link>
<Link href="/admin/artikler" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Artikler
</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>
)}
{/* SIDEBAR */}
<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>}
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny"></button>
</div>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
<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">
<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/golfpakker" 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="Golfpakker">
{isSidebarCollapsed ? 'GP' : 'Golfpakker'}
</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>
<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">Innhold</div>
<Link href="/admin/steder" 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="Steder">
{isSidebarCollapsed ? 'S' : 'Steder'}
</Link>
<Link href="/admin/artikler" 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="Artikler">
{isSidebarCollapsed ? 'A' : 'Artikler'}
</Link>
</div>
<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>
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
<button onClick={handleLogout} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
</button>
</div>
</aside>
{/* HOVEDINNHOLD */}
<main ref={mainContentRef} className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-auto">
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
<div className="mb-6 flex md:hidden">
<button
onClick={() => setShowMobileAdminMenu(true)}
className="btn btn-md btn-primary"
>
<span className="text-lg leading-none"></span>
Adminmeny
</button>
</div>
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8">
<div>
<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>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/steder"
className="btn btn-md btn-secondary"
>
Sted-sider
</Link>
<Link
href="/admin/artikler"
className="btn btn-md btn-secondary"
>
Artikler / Banebesøk
</Link>
<button
onClick={openTwoFactorModal}
className="btn btn-md btn-secondary"
>
2FA / 1Password
</button>
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isQueueing}
className={`btn btn-lg whitespace-nowrap disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400 ${
isQueueing ? 'bg-yellow-500 text-white animate-pulse' : 'btn-primary'
}`}
>
{isQueueing ? 'Legger i kø...' : isScraping ? `Legg ${activeTab}-skraping i kø (${selectedFacilities.length})` : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
</button>
</div>
</header>
{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="btn btn-sm btn-secondary self-start"
>
Lukk
</button>
</div>
</div>
)}
{latestJob && latestJob.job_type === activeTab && !isLatestJobDismissed && (
<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>
{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>
)}
{latestJob.created_at && (
<span className="text-xs text-gray-500">
Opprettet {new Date(latestJob.created_at).toLocaleString('nb-NO')}
</span>
)}
</div>
{latestJobRetryLabel && (
<p className="text-xs font-bold text-amber-700 leading-relaxed">
{latestJobRetryLabel}
</p>
)}
{latestJobProgress.total > 0 && (
<div className="mt-4 space-y-4">
<div className="rounded-[1.5rem] border border-white/70 bg-white/70 p-4 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Fremdrift i jobben</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-black text-[#11280f]">
{latestJobProgress.completed} av {latestJobProgress.total} anlegg behandlet
</span>
<span className="inline-flex rounded-xl bg-[#f4f7ef] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600">
{latestJobProgress.percent}%
</span>
</div>
{latestJob.current_facility_name && latestJob.status === 'running' && (
<p className="text-xs text-gray-600">
Behandler <span className="font-black text-[#11280f]">{latestJob.current_facility_name}</span>
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<span className="inline-flex rounded-xl bg-[#edf6e3] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#11280f]">
OK {latestJob.progress_ok ?? 0}
</span>
<span className="inline-flex rounded-xl bg-red-50 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-red-700">
Feil {latestJob.progress_failed ?? 0}
</span>
<span className="inline-flex rounded-xl bg-amber-50 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-amber-800">
Hoppet over {latestJob.progress_skipped ?? 0}
</span>
</div>
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-white">
<div
className="h-full rounded-full bg-[#8bc34a] transition-all duration-500"
style={{ width: `${latestJobProgress.percent}%` }}
/>
</div>
</div>
{latestJobEvents.length > 0 && (
<div className="space-y-3">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Siste hendelser</p>
<div className="grid gap-3 xl:grid-cols-2">
{latestJobEvents.map((event, index) => {
const toneClass = JOB_EVENT_TONE_CLASSES[event.outcome || 'info'] || JOB_EVENT_TONE_CLASSES.info;
const progressLabel =
event.processed && event.total
? `${event.processed} av ${event.total}`
: latestJobProgress.total > 0
? `${latestJobProgress.completed} av ${latestJobProgress.total}`
: '';
return (
<article
key={`${event.timestamp || 'event'}-${index}`}
className={`rounded-[1.25rem] border p-4 ${toneClass}`}
>
<div className="flex flex-wrap items-center gap-2">
{progressLabel && (
<span className="inline-flex rounded-xl bg-white/80 px-3 py-1 text-[10px] font-black uppercase tracking-widest">
{progressLabel}
</span>
)}
{event.facility_name && (
<span className="text-xs font-black">{event.facility_name}</span>
)}
{event.timestamp && (
<span className="text-[10px] text-gray-500">
{new Date(event.timestamp).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
</div>
<p className="mt-2 text-sm leading-relaxed">{event.message || 'Hendelse registrert.'}</p>
</article>
);
})}
</div>
</div>
)}
</div>
)}
{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>
<div className="flex items-center gap-2 self-start">
<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>
<button
type="button"
onClick={() => {
if (!latestJobCardKey) return;
setDismissedLatestJobKeys((prev) => ({ ...prev, [activeTab]: latestJobCardKey }));
}}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-white text-xl leading-none text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
aria-label="Lukk jobbstatus"
title="Lukk"
>
×
</button>
</div>
</div>
</div>
)}
{showJobHistory && (
<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>
)}
{/* VELDIG SYNLIGE FANER */}
<div className="mb-8 flex flex-wrap gap-2 border-b-2 border-gray-100 pb-3">
<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('golfpakker')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'golfpakker' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Golfpakker</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>
</div>
{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_aapne">🟠 Alle som ikke er åpne</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>
)}
<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>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="block min-w-[18rem]">
<span className="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
Hopp til golfanlegg
</span>
<select
value={facilityJumpSlug}
onChange={(e) => setFacilityJumpSlug(e.target.value)}
className="w-full rounded-2xl border-2 border-gray-200 bg-white px-4 py-3 text-sm font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
>
<option value="">Velg anlegg</option>
{facilityJumpOptions.map((facility) => (
<option key={facility.slug} value={facility.slug}>
{facility.city ? `${facility.label}${facility.city}` : facility.label}
</option>
))}
</select>
</label>
<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>
<div className="mb-6 rounded-[1.75rem] border border-[#dbe7cf] bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Hvordan Skraping Oppfører Seg</p>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Banestatus</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping oppdaterer live banestatus direkte. Hvis skraperen ikke finner en trygg status, beholdes eksisterende status som før.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Medlemskap</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live medlemskapsdata endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Greenfee</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live greenfee endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Golfpakker</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live golfpakker endres først når du godkjenner i vaskeriet eller redigerer manuelt.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">VTG</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live VTG-data endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
</p>
</article>
</div>
</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 hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_draft).length > 0;
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'golfpakker' && hasGpDraft) || (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}
id={`facility-card-${f.slug}`}
className={`rounded-[1.9rem] border p-5 shadow-sm transition-all scroll-mt-6 md:p-6 ${accentStyle} ${
selectedFacilities.includes(f.id)
? 'ring-2 ring-[#8bc34a]/35 shadow-lg'
: facilityJumpSlug === f.slug
? 'ring-2 ring-[#11280f]/15 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>
{f.is_published === false && (
<span className="rounded-xl bg-amber-100 px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-amber-800">
Skjult
</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 === 'golfpakker' && (
<span>{f.golfpakker_updated_at ? `Vasket ${new Date(f.golfpakker_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="btn btn-md btn-secondary">
Innstillinger
</button>
)}
{activeTab === 'medlemskap' && (
<button onClick={() => openManualOverrideModal(f, 'medlemskap')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'medlemskap' && hasMemDraft && (
<Link href="/admin/medlemskap" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
{activeTab === 'greenfee' && (
<button onClick={() => openManualOverrideModal(f, 'greenfee')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'greenfee' && hasGfDraft && (
<Link href="/admin/greenfee" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
{activeTab === 'golfpakker' && hasGpDraft && (
<Link href="/admin/golfpakker" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
{activeTab === 'vtg' && (
<button onClick={() => openManualOverrideModal(f, 'vtg')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'vtg' && hasVtgDraft && (
<Link href="/admin/vtg" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
<Link href={`/admin/rediger/${f.slug}`} className="btn btn-md btn-ink text-center">
Rediger alt
</Link>
</div>
</div>
{activeTab === 'banestatus' && (
<div className="space-y-4">
<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} placeholder="Lim inn URL(er)..." emptyLabel="Mangler URL" title="Klikk for å redigere URL" />
<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>
<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">Viktig beskjed</p>
<div className="mt-3 rounded-2xl bg-[#f8fbf4] p-3">
<InlineEdit
facilityId={f.id}
field="footnote"
initialValue={f.footnote || ''}
onSave={handleQuickEdit}
placeholder="Skriv viktig beskjed / kursiv intro-tekst..."
emptyLabel="Ingen viktig beskjed"
title="Klikk for å redigere viktig beskjed"
inputRows={4}
editorWidthClassName="max-w-full"
displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap"
renderHtml
/>
</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>
)}
{activeTab === 'golfpakker' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)_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="golfpakker_url" initialValue={f.golfpakker_url || ''} onSave={handleQuickEdit} placeholder="Lim inn golfpakke-URL..." emptyLabel={f.website_url ? 'Bruker website_url som fallback' : 'Mangler golfpakke-URL'} title="Klikk for å redigere golfpakke-URL" />
{f.website_url && (
<p className="mt-2 text-[10px] font-mono text-gray-400 break-all">
Fallback: {f.website_url}
</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">Registrerte golfpakker</p>
<div className="mt-4 space-y-2">
{f.golfpakker && f.golfpakker.length > 0 ? f.golfpakker.map((pakke: any, i: number) => (
<div key={i} className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<p className="text-xs font-black text-[#11280f]">{pakke.navn || 'Uten navn'}</p>
<p className="mt-1 text-[10px] text-gray-500">{pakke.beskrivelse || 'Ingen beskrivelse registrert.'}</p>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#7ca982]">
{pakke.pris ? `${pakke.pris},-` : 'Pris ikke registrert'}
</p>
</div>
)) : (
<p className="text-sm text-gray-500">Ingen golfpakker 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 ${hasGpDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasGpDraft ? '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]">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100">
<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>
{activeTab === 'banestatus' && (
<>
<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' && (
<>
<th className="pb-4">Medlemskap-side (Klikk for å redigere)</th>
<th className="pb-4">Nåværende Priser</th>
<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>
<th className="pb-4 text-center">Nytt Utkast?</th>
<th className="pb-4">Sist Vasket</th>
</>
)}
{activeTab === 'golfpakker' && (
<>
<th className="pb-4">Nettside</th>
<th className="pb-4">Golfpakker</th>
<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>
<th className="pb-4 w-64">Registrert Informasjon</th>
<th className="pb-4 text-center">Nytt Utkast?</th>
<th className="pb-4">Sist Vasket</th>
</>
)}
<th className="pb-4 text-right pr-4 sticky right-0 z-20 bg-white min-w-[150px]">Handling</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{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 hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_draft).length > 0;
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'golfpakker' && hasGpDraft) || (activeTab === 'vtg' && hasVtgDraft);
return (
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50'}`}>
<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'}`}>
<div className="flex items-center gap-2">
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
{f.is_published === false && (
<span className="rounded-lg bg-amber-100 px-2 py-1 text-[10px] font-black uppercase tracking-widest text-amber-800">
Skjult
</span>
)}
</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
{activeTab === 'banestatus' && (
<>
<td className="py-6 pr-4">
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} placeholder="Lim inn URL(er)..." emptyLabel="Mangler URL" title="Klikk for å redigere URL" />
<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>
<td className="py-6 pr-4">
<div className="flex flex-col gap-1">
<InlineEdit
facilityId={f.id}
field="footnote"
initialValue={f.footnote || ''}
onSave={handleQuickEdit}
placeholder="Skriv viktig beskjed / kursiv intro-tekst..."
emptyLabel="Ingen viktig beskjed"
title="Klikk for å redigere viktig beskjed"
inputRows={4}
editorWidthClassName="max-w-[320px]"
displayClassName="mb-2 text-[11px] italic leading-5 text-[#11280f] whitespace-pre-wrap"
renderHtml
/>
{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>
)
})}
</div>
</td>
</>
)}
{activeTab === 'medlemskap' && (
<>
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} /></td>
<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>
<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">
<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) => (
<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>
</td>
<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">
<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>
</td>
<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>
</>
)}
{activeTab === 'golfpakker' && (
<>
<td className="py-6 pr-4">
<InlineEdit facilityId={f.id} field="golfpakker_url" initialValue={f.golfpakker_url || ''} onSave={handleQuickEdit} placeholder="Lim inn golfpakke-URL..." emptyLabel={f.website_url ? 'Bruker website_url som fallback' : 'Mangler golfpakke-URL'} title="Klikk for å redigere golfpakke-URL" />
{f.website_url && <div className="mt-1 text-[9px] font-mono text-gray-300 truncate max-w-[180px]" title={f.website_url}>{f.website_url}</div>}
</td>
<td className="py-6 pr-4">
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2">
{f.golfpakker && f.golfpakker.length > 0 ? f.golfpakker.map((pakke: any, i: number) => (
<div key={i} className="border-b border-gray-50 pb-1">
<span className="font-bold text-[#11280f]">{pakke.navn || 'Uten navn'}</span>
<span className="ml-2">{pakke.pris ? `${pakke.pris},-` : 'Uten pris'}</span>
</div>
)) : 'Ingen golfpakker'}
</div>
</td>
<td className="py-6 pr-4 text-center">{hasGpDraft ? <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.golfpakker_updated_at ? new Date(f.golfpakker_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
</>
)}
<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'}`}>
<div className="flex flex-col gap-2 items-end">
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="btn btn-sm btn-secondary whitespace-nowrap">Innstillinger</button>}
{activeTab === 'medlemskap' && <button onClick={() => openManualOverrideModal(f, 'medlemskap')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'greenfee' && <button onClick={() => openManualOverrideModal(f, 'greenfee')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'golfpakker' && hasGpDraft && <Link href="/admin/golfpakker" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'vtg' && <button onClick={() => openManualOverrideModal(f, 'vtg')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
<Link href={`/admin/rediger/${f.slug}`} className="btn btn-sm btn-ink whitespace-nowrap text-center">Rediger alt</Link>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</main>
{showBackToTop && (
<button
type="button"
onClick={() => mainContentRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className="btn btn-ink fixed bottom-4 right-4 z-[95] flex h-12 items-center justify-center gap-2 rounded-full px-4 shadow-2xl md:bottom-8 md:right-8 md:h-14"
aria-label="Tilbake til toppen"
title="Tilbake til toppen"
>
<span className="text-base leading-none"></span>
<span className="hidden text-[10px] font-black uppercase tracking-[0.18em] sm:inline">Toppen</span>
</button>
)}
</div>
);
}