"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; 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 = { banestatus: 'Banestatus', medlemskap: 'Medlemskap', greenfee: 'Greenfee', golfpakker: 'Golfpakker', vtg: 'VTG', }; const JOB_STATUS_LABELS: Record = { pending: 'I kø', running: 'Kjører', completed: 'Fullført', failed: 'Feilet', }; const JOB_ERROR_LABELS: Record = { 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 = { 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); 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(); 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("
")) .replace(/<\s*(strong|b)\s*>/gi, () => keep("")) .replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("")) .replace(/<\s*(em|i)\s*>/gi, () => keep("")) .replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("")) .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(``); } return keep(``); }) .replace(/<\s*\/\s*a\s*>/gi, () => keep("")); safe = escapeHtml(safe).replace(/\n/g, "
"); 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 (