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

2421 lines
130 KiB
TypeScript
Raw Normal View History

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