2634 lines
140 KiB
TypeScript
2634 lines
140 KiB
TypeScript
"use client";
|
||
/**
|
||
* TEE OFF ADMIN DASHBOARD v4.0 - KONTROLLPANEL
|
||
*/
|
||
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { API_URL } from "@/config/constants";
|
||
import { adminFetch } from "@/config/adminFetch";
|
||
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
||
import Link from 'next/link';
|
||
|
||
type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'golfpakker' | 'vtg';
|
||
|
||
type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||
|
||
type ScrapeJobEvent = {
|
||
timestamp?: string;
|
||
facility_id?: number | null;
|
||
facility_name?: string;
|
||
outcome?: 'success' | 'warning' | 'error' | 'info' | string;
|
||
message?: string;
|
||
processed?: number;
|
||
total?: number;
|
||
};
|
||
|
||
type ScrapeJob = {
|
||
id: number;
|
||
job_type: AdminTab;
|
||
status: ScrapeJobStatus;
|
||
facility_ids: number[];
|
||
total_facilities: number;
|
||
error_message?: string | null;
|
||
error_code?: string | null;
|
||
retryable?: boolean;
|
||
attempt_count?: number;
|
||
max_attempts?: number;
|
||
next_retry_at?: string | null;
|
||
last_error_at?: string | null;
|
||
progress_total?: number;
|
||
progress_completed?: number;
|
||
progress_ok?: number;
|
||
progress_failed?: number;
|
||
progress_skipped?: number;
|
||
current_facility_id?: number | null;
|
||
current_facility_name?: string | null;
|
||
recent_events?: ScrapeJobEvent[];
|
||
result_summary?: Record<string, number | string | null>;
|
||
created_at?: string | null;
|
||
started_at?: string | null;
|
||
finished_at?: string | null;
|
||
};
|
||
|
||
type CourseStatusHistoryEntry = {
|
||
id: number;
|
||
course_id: number;
|
||
facility_id: number;
|
||
old_status: string | null;
|
||
new_status: string | null;
|
||
change_source: string;
|
||
changed_by?: string | null;
|
||
changed_at?: string | null;
|
||
course_name?: string | null;
|
||
facility_name?: string | null;
|
||
facility_slug?: string | null;
|
||
};
|
||
|
||
type QueueFeedback = {
|
||
tone: 'success' | 'warning' | 'info' | 'error';
|
||
title: string;
|
||
message: string;
|
||
};
|
||
|
||
type TwoFactorSetupResponse = {
|
||
issuer: string;
|
||
account_name: string;
|
||
otp_secret: string;
|
||
provisioning_uri: string;
|
||
qr_svg: string;
|
||
};
|
||
|
||
type ManualOverrideTab = 'medlemskap' | 'greenfee' | 'vtg';
|
||
|
||
type GreenfeeRow = {
|
||
banenavn: string;
|
||
priskategori: string;
|
||
pris_voksne: string;
|
||
pris_junior: string;
|
||
};
|
||
|
||
type VtgDateRow = {
|
||
dato: string;
|
||
status: string;
|
||
};
|
||
|
||
type ManualOverrideForm = {
|
||
medlemskap_url: string;
|
||
navn_standard_medlemskap: string;
|
||
standard_medlemskap: string;
|
||
standard_medlemskap_kommentarer: string;
|
||
navn_rimeligste_alternativ: string;
|
||
rimeligste_alternativ: string;
|
||
greenfee_url: string;
|
||
guest_requirements: string;
|
||
greenfee: GreenfeeRow[];
|
||
vtg_lenke: string;
|
||
vtg_pris: string;
|
||
vtg_beskrivelse: string;
|
||
vtg_datoer: VtgDateRow[];
|
||
};
|
||
|
||
const JOB_LABELS: Record<AdminTab, string> = {
|
||
banestatus: 'Banestatus',
|
||
medlemskap: 'Medlemskap',
|
||
greenfee: 'Greenfee',
|
||
golfpakker: 'Golfpakker',
|
||
vtg: 'VTG',
|
||
};
|
||
|
||
const JOB_STATUS_LABELS: Record<ScrapeJobStatus, string> = {
|
||
pending: 'I kø',
|
||
running: 'Kjører',
|
||
completed: 'Fullført',
|
||
failed: 'Feilet',
|
||
};
|
||
|
||
const JOB_ERROR_LABELS: Record<string, string> = {
|
||
json_parse: 'JSON-tolkning',
|
||
configuration: 'Konfigurasjon',
|
||
timeout: 'Tidsavbrudd',
|
||
browser: 'Nettleser / Playwright',
|
||
network: 'Nettverk',
|
||
database: 'Database',
|
||
validation: 'Validering',
|
||
unknown: 'Ukjent feil',
|
||
worker_stale: 'Worker mistet heartbeat',
|
||
};
|
||
|
||
const JOB_EVENT_TONE_CLASSES: Record<string, string> = {
|
||
success: 'bg-[#edf6e3] text-[#11280f] border-[#d8e8c8]',
|
||
warning: 'bg-amber-50 text-amber-800 border-amber-200',
|
||
error: 'bg-red-50 text-red-700 border-red-200',
|
||
info: 'bg-slate-50 text-slate-700 border-slate-200',
|
||
};
|
||
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
aapen: 'Åpen',
|
||
stengt: 'Stengt',
|
||
aapen_med_vintergreener: 'Vintergreener',
|
||
aapner_snart: 'Åpner snart',
|
||
stenger_snart: 'Stenger snart',
|
||
under_utvikling: 'Under utvikling',
|
||
nedlagt: 'Nedlagt',
|
||
ukjent: 'Ukjent',
|
||
};
|
||
|
||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||
aapen: 'bg-green-100 text-green-700',
|
||
stengt: 'bg-red-100 text-red-700',
|
||
nedlagt: 'bg-red-100 text-red-700',
|
||
aapen_med_vintergreener: 'bg-yellow-100 text-yellow-700',
|
||
aapner_snart: 'bg-yellow-100 text-yellow-700',
|
||
stenger_snart: 'bg-orange-100 text-orange-700',
|
||
under_utvikling: 'bg-slate-200 text-slate-700',
|
||
ukjent: 'bg-gray-100 text-gray-500',
|
||
};
|
||
|
||
const CHANGE_SOURCE_LABELS: Record<string, string> = {
|
||
scraper: 'Skraper',
|
||
manual: 'Manuell',
|
||
};
|
||
|
||
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: [],
|
||
};
|
||
|
||
function normalizeScrapeMethod(scrapeMethod: string | null | undefined) {
|
||
if (typeof scrapeMethod === 'string' && scrapeMethod.trim() === '') return 'disabled';
|
||
return scrapeMethod ?? 'css_selector';
|
||
}
|
||
|
||
const toInputString = (value: unknown) => {
|
||
if (value === null || value === undefined) return '';
|
||
return String(value);
|
||
};
|
||
|
||
const normalizeGreenfeeRows = (value: unknown): GreenfeeRow[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.map((row: any) => ({
|
||
banenavn: toInputString(row?.banenavn),
|
||
priskategori: toInputString(row?.priskategori),
|
||
pris_voksne: toInputString(row?.pris_voksne),
|
||
pris_junior: toInputString(row?.pris_junior),
|
||
}));
|
||
};
|
||
|
||
const normalizeVtgDates = (value: unknown): VtgDateRow[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.map((row: any) => ({
|
||
dato: toInputString(row?.dato),
|
||
status: toInputString(row?.status) || 'Ledig',
|
||
}));
|
||
};
|
||
|
||
const toNullableNumber = (value: unknown) => {
|
||
const normalized = toInputString(value).trim();
|
||
if (!normalized) return null;
|
||
const parsed = Number(normalized);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
|
||
type InlineEditProps = {
|
||
facilityId: number;
|
||
field: string;
|
||
initialValue: string;
|
||
onSave: (id: number, field: string, val: string) => void;
|
||
placeholder?: string;
|
||
emptyLabel?: string;
|
||
title?: string;
|
||
inputRows?: number;
|
||
editorWidthClassName?: string;
|
||
displayClassName?: string;
|
||
renderHtml?: boolean;
|
||
};
|
||
|
||
const escapeHtml = (value: string) =>
|
||
value
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
|
||
const sanitizeHref = (value: string) => {
|
||
const href = value.trim();
|
||
return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
||
};
|
||
|
||
const sanitizeInlineRichText = (value: string | null | undefined) => {
|
||
const source = String(value || "").replace(/\r\n?/g, "\n");
|
||
if (!source.trim()) return "";
|
||
|
||
const placeholders = new Map<string, string>();
|
||
let index = 0;
|
||
const keep = (html: string) => {
|
||
const key = `__HTML_TOKEN_${index++}__`;
|
||
placeholders.set(key, html);
|
||
return key;
|
||
};
|
||
|
||
let safe = source
|
||
.replace(/<\s*br\s*\/?\s*>/gi, () => keep("<br />"))
|
||
.replace(/<\s*(strong|b)\s*>/gi, () => keep("<strong>"))
|
||
.replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("</strong>"))
|
||
.replace(/<\s*(em|i)\s*>/gi, () => keep("<em>"))
|
||
.replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("</em>"))
|
||
.replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => {
|
||
const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||
const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#");
|
||
if (/^(\/|#|mailto:|tel:)/i.test(href) || /^https?:\/\/([^/]+\.)?teeoff\.no(\/|$)/i.test(href)) {
|
||
return keep(`<a href="${escapeHtml(href)}">`);
|
||
}
|
||
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">`);
|
||
})
|
||
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
||
|
||
safe = escapeHtml(safe).replace(/\n/g, "<br />");
|
||
|
||
for (const [token, html] of placeholders) {
|
||
safe = safe.replaceAll(token, html);
|
||
}
|
||
|
||
return safe;
|
||
};
|
||
|
||
const InlineEdit = ({
|
||
facilityId,
|
||
field,
|
||
initialValue,
|
||
onSave,
|
||
placeholder = 'Lim inn verdi...',
|
||
emptyLabel = 'Mangler verdi',
|
||
title = 'Klikk for å redigere',
|
||
inputRows = 2,
|
||
editorWidthClassName = 'max-w-[200px]',
|
||
displayClassName = 'text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2',
|
||
renderHtml = false,
|
||
}: InlineEditProps) => {
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [value, setValue] = useState(initialValue || '');
|
||
|
||
useEffect(() => {
|
||
setValue(initialValue || '');
|
||
}, [initialValue]);
|
||
|
||
const handleSave = () => {
|
||
setIsEditing(false);
|
||
if (value !== initialValue) {
|
||
onSave(facilityId, field, value);
|
||
}
|
||
};
|
||
|
||
if (isEditing) {
|
||
return (
|
||
<div className={`flex w-full flex-col gap-1 animate-fade-in ${editorWidthClassName}`}>
|
||
<textarea autoFocus rows={inputRows} className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white" value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} placeholder={placeholder} />
|
||
<div className="flex gap-1">
|
||
<button onClick={handleSave} className="btn btn-sm btn-primary flex-1">Lagre</button>
|
||
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="btn btn-sm btn-secondary">Avbryt</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title={title}>
|
||
<div className={displayClassName}>
|
||
{initialValue ? (
|
||
renderHtml ? (
|
||
<span
|
||
className="[&_a]:font-bold [&_a]:text-[#C94F2D] [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-[#9F3117] [&_em]:italic [&_strong]:font-extrabold"
|
||
dangerouslySetInnerHTML={{ __html: sanitizeInlineRichText(initialValue) }}
|
||
/>
|
||
) : (
|
||
initialValue
|
||
)
|
||
) : <span className="text-red-400 italic">{emptyLabel}</span>}
|
||
</div>
|
||
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity">✏️</span>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default function AdminDashboard() {
|
||
const [facilities, setFacilities] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
|
||
const [facilityJumpSlug, setFacilityJumpSlug] = useState('');
|
||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
|
||
const [courseStatusHistory, setCourseStatusHistory] = useState<CourseStatusHistoryEntry[]>([]);
|
||
const [statusHistoryDate, setStatusHistoryDate] = useState('');
|
||
const [isLoadingStatusHistory, setIsLoadingStatusHistory] = useState(false);
|
||
const [isQueueing, setIsQueueing] = useState(false);
|
||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||
const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false);
|
||
const [editingFacility, setEditingFacility] = useState<any | null>(null);
|
||
const [activeTab, setActiveTab] = useState<AdminTab>('banestatus');
|
||
const [statusFilter, setStatusFilter] = useState('alle');
|
||
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const latestJobStateRef = useRef<string | null>(null);
|
||
const [showTwoFactorModal, setShowTwoFactorModal] = useState(false);
|
||
const [twoFactorPassword, setTwoFactorPassword] = useState('');
|
||
const [twoFactorError, setTwoFactorError] = useState('');
|
||
const [isLoadingTwoFactor, setIsLoadingTwoFactor] = useState(false);
|
||
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResponse | null>(null);
|
||
const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null);
|
||
const [queueFeedback, setQueueFeedback] = useState<QueueFeedback | null>(null);
|
||
const [manualEditFacility, setManualEditFacility] = useState<any | null>(null);
|
||
const [manualEditTab, setManualEditTab] = useState<ManualOverrideTab | null>(null);
|
||
const [manualEditForm, setManualEditForm] = useState<ManualOverrideForm>(EMPTY_MANUAL_OVERRIDE_FORM);
|
||
const [isManualSaving, setIsManualSaving] = useState(false);
|
||
const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({});
|
||
const mainContentRef = useRef<HTMLElement | null>(null);
|
||
|
||
const fetchFacilities = () => {
|
||
adminFetch(`${API_URL}/admin/facilities`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
setFacilities(Array.isArray(data) ? data : []);
|
||
setLoading(false);
|
||
})
|
||
.catch(() => setLoading(false));
|
||
};
|
||
|
||
const fetchScrapeJobs = (tab: AdminTab = activeTab) => {
|
||
adminFetch(`${API_URL}/admin/scrape-jobs?job_type=${tab}&limit=5`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
setScrapeJobs(Array.isArray(data) ? data : []);
|
||
})
|
||
.catch(() => setScrapeJobs([]));
|
||
};
|
||
|
||
const fetchCourseStatusHistory = (dateFilter: string = statusHistoryDate) => {
|
||
const params = new URLSearchParams({ limit: '100' });
|
||
if (dateFilter) {
|
||
params.set('changed_on', dateFilter);
|
||
}
|
||
|
||
setIsLoadingStatusHistory(true);
|
||
adminFetch(`${API_URL}/admin/course-status-history?${params.toString()}`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
setCourseStatusHistory(Array.isArray(data) ? data : []);
|
||
})
|
||
.catch(() => setCourseStatusHistory([]))
|
||
.finally(() => setIsLoadingStatusHistory(false));
|
||
};
|
||
|
||
const activeJob = useMemo(
|
||
() => scrapeJobs.find(job => job.status === 'pending' || job.status === 'running') || null,
|
||
[scrapeJobs]
|
||
);
|
||
const latestJob = scrapeJobs[0] || null;
|
||
const isScraping = !!activeJob;
|
||
const latestJobCardKey = latestJob
|
||
? `${latestJob.job_type}:${latestJob.id}:${latestJob.status}:${latestJob.attempt_count ?? 0}:${latestJob.next_retry_at ?? ''}:${latestJob.finished_at ?? ''}`
|
||
: null;
|
||
const isLatestJobDismissed =
|
||
!!latestJobCardKey &&
|
||
latestJob.job_type === activeTab &&
|
||
dismissedLatestJobKeys[activeTab] === latestJobCardKey;
|
||
|
||
useEffect(() => {
|
||
fetchFacilities();
|
||
fetchScrapeJobs('banestatus');
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(() => fetchScrapeJobs(activeTab), 5000);
|
||
return () => clearInterval(interval);
|
||
}, [activeTab]);
|
||
|
||
useEffect(() => {
|
||
setSelectedFacilities([]);
|
||
setQueueFeedback(null);
|
||
fetchScrapeJobs(activeTab);
|
||
}, [activeTab]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab !== 'banestatus') return;
|
||
fetchCourseStatusHistory();
|
||
}, [activeTab, statusHistoryDate]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab !== 'banestatus') return;
|
||
const interval = setInterval(() => fetchCourseStatusHistory(), 15000);
|
||
return () => clearInterval(interval);
|
||
}, [activeTab, statusHistoryDate]);
|
||
|
||
useEffect(() => {
|
||
if (!isScraping) return;
|
||
const interval = setInterval(() => fetchFacilities(), 10000);
|
||
return () => clearInterval(interval);
|
||
}, [isScraping]);
|
||
|
||
useEffect(() => {
|
||
const currentState = latestJob
|
||
? `${latestJob.id}:${latestJob.status}:${latestJob.attempt_count ?? 0}:${latestJob.next_retry_at ?? ''}:${latestJob.finished_at ?? ''}`
|
||
: null;
|
||
const previousState = latestJobStateRef.current;
|
||
latestJobStateRef.current = currentState;
|
||
|
||
if (
|
||
previousState &&
|
||
previousState !== currentState &&
|
||
latestJob &&
|
||
(latestJob.status === 'completed' || latestJob.status === 'failed')
|
||
) {
|
||
fetchFacilities();
|
||
fetchScrapeJobs(activeTab);
|
||
if (activeTab === 'banestatus') {
|
||
fetchCourseStatusHistory();
|
||
}
|
||
}
|
||
}, [activeTab, latestJob, statusHistoryDate]);
|
||
|
||
useEffect(() => {
|
||
if (!showTwoFactorModal) return;
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
closeTwoFactorModal();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [showTwoFactorModal]);
|
||
|
||
useEffect(() => {
|
||
if (!showMobileAdminMenu) return;
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
setShowMobileAdminMenu(false);
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [showMobileAdminMenu]);
|
||
|
||
useEffect(() => {
|
||
const container = mainContentRef.current;
|
||
if (!container) return;
|
||
|
||
const handleScroll = () => {
|
||
setShowBackToTop(container.scrollTop > 500);
|
||
};
|
||
|
||
handleScroll();
|
||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||
return () => container.removeEventListener('scroll', handleScroll);
|
||
}, []);
|
||
|
||
const filteredFacilities = useMemo(() => {
|
||
if (statusFilter === 'alle') return facilities;
|
||
return facilities.map(facility => {
|
||
if (!facility.course_statuses) return facility;
|
||
const filteredCourses = facility.course_statuses.filter((cs: any) => {
|
||
const s = cs.status || 'ukjent';
|
||
if (statusFilter === 'aapne') return s === 'aapen';
|
||
if (statusFilter === 'ikke_aapne') return s !== 'aapen';
|
||
if (statusFilter === 'ikke_stengt') return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
|
||
if (statusFilter === 'stengt') return s === 'stengt';
|
||
if (statusFilter === 'nedlagt') return s === 'nedlagt';
|
||
if (statusFilter === 'ukjent_feil') return s === 'ukjent' || s === 'NOT_FOUND';
|
||
return true;
|
||
});
|
||
return { ...facility, course_statuses: filteredCourses };
|
||
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
|
||
}, [facilities, statusFilter]);
|
||
|
||
const facilityJumpOptions = useMemo(
|
||
() =>
|
||
[...facilities]
|
||
.filter((facility) => facility?.slug && facility?.name)
|
||
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'nb-NO'))
|
||
.map((facility) => ({
|
||
slug: String(facility.slug),
|
||
label: String(facility.name),
|
||
city: String(facility.city || '').trim(),
|
||
})),
|
||
[facilities]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!facilityJumpSlug) return;
|
||
if (activeTab === 'banestatus' && statusFilter !== 'alle') {
|
||
setStatusFilter('alle');
|
||
return;
|
||
}
|
||
|
||
const targetId = `facility-card-${facilityJumpSlug}`;
|
||
const frameId = window.requestAnimationFrame(() => {
|
||
const target = document.getElementById(targetId);
|
||
if (!target) return;
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
|
||
return () => window.cancelAnimationFrame(frameId);
|
||
}, [activeTab, facilityJumpSlug, filteredFacilities, statusFilter]);
|
||
|
||
const latestJobSummary = useMemo(() => {
|
||
if (!latestJob?.result_summary) return '';
|
||
|
||
return Object.entries(latestJob.result_summary)
|
||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||
.map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`)
|
||
.join(' • ');
|
||
}, [latestJob]);
|
||
|
||
const latestJobAttemptLabel = useMemo(() => {
|
||
if (!latestJob) return '';
|
||
const attempts = latestJob.attempt_count ?? 0;
|
||
const maxAttempts = latestJob.max_attempts ?? 0;
|
||
if (!attempts || !maxAttempts) return '';
|
||
return `Forsøk ${attempts} av ${maxAttempts}`;
|
||
}, [latestJob]);
|
||
|
||
const latestJobRetryLabel = useMemo(() => {
|
||
if (!latestJob?.next_retry_at || latestJob.status !== 'pending') return '';
|
||
return `Nytt forsøk planlagt ${new Date(latestJob.next_retry_at).toLocaleString('nb-NO')}`;
|
||
}, [latestJob]);
|
||
|
||
const latestJobErrorLabel = useMemo(() => {
|
||
if (!latestJob?.error_code) return '';
|
||
return JOB_ERROR_LABELS[latestJob.error_code] || latestJob.error_code.replaceAll('_', ' ');
|
||
}, [latestJob]);
|
||
|
||
const recentJobs = useMemo(() => {
|
||
if (!latestJob) return scrapeJobs.slice(0, 4);
|
||
return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4);
|
||
}, [latestJob, scrapeJobs]);
|
||
const showJobHistory = recentJobs.length > 0 && !isLatestJobDismissed;
|
||
const statusHistoryFacilityCount = useMemo(
|
||
() => new Set(courseStatusHistory.map(entry => entry.facility_id)).size,
|
||
[courseStatusHistory]
|
||
);
|
||
const statusHistoryHeading = statusHistoryDate
|
||
? `Banestatus endret ${new Date(`${statusHistoryDate}T00:00:00`).toLocaleDateString('nb-NO')}`
|
||
: 'Banestatus endret i dag';
|
||
|
||
const latestJobProgress = useMemo(() => {
|
||
const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0;
|
||
const completed = Math.min(latestJob?.progress_completed ?? 0, total || 0);
|
||
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
|
||
return { total, completed, percent };
|
||
}, [latestJob]);
|
||
|
||
const latestJobEvents = useMemo(() => {
|
||
const events = Array.isArray(latestJob?.recent_events) ? latestJob.recent_events : [];
|
||
return [...events].reverse().slice(0, 6);
|
||
}, [latestJob]);
|
||
|
||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id));
|
||
else setSelectedFacilities([]);
|
||
};
|
||
|
||
const handleSelectOne = (id: number, checked: boolean) => {
|
||
if (checked) setSelectedFacilities([...selectedFacilities, id]);
|
||
else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
||
};
|
||
|
||
const handleQuickEdit = async (id: number, field: string, value: string) => {
|
||
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
|
||
try {
|
||
const res = await adminFetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ field, value })
|
||
});
|
||
if (!res.ok) throw new Error("Feil ved lagring");
|
||
} catch (e) {
|
||
alert("Kunne ikke lagre endringen i databasen.");
|
||
fetchFacilities();
|
||
}
|
||
};
|
||
|
||
const handleRunScrapers = async () => {
|
||
if (selectedFacilities.length === 0) return;
|
||
setIsQueueing(true);
|
||
setQueueFeedback(null);
|
||
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
|
||
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
|
||
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
|
||
activeTab === 'golfpakker' ? '/admin/run-golfpakker-scraper' :
|
||
'/admin/run-vtg-scraper';
|
||
try {
|
||
const response = await adminFetch(`${API_URL}${endpoint}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ facility_ids: selectedFacilities })
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.detail || "Kunne ikke starte skraping");
|
||
if (data.status === 'queued') {
|
||
setQueueFeedback({
|
||
tone: 'success',
|
||
title: 'Jobb lagt i kø',
|
||
message: data.message || 'Skrapejobben ble lagt i kø.'
|
||
});
|
||
setSelectedFacilities([]);
|
||
} else if (data.status === 'already_queued') {
|
||
setQueueFeedback({
|
||
tone: 'info',
|
||
title: 'Jobben finnes allerede',
|
||
message: data.message || 'Det finnes allerede en aktiv jobb for dette utvalget.'
|
||
});
|
||
setSelectedFacilities([]);
|
||
} else if (data.status === 'conflict') {
|
||
const conflictingCount = Array.isArray(data.conflicting_facility_ids) ? data.conflicting_facility_ids.length : 0;
|
||
const idleCount = Array.isArray(data.idle_facility_ids) ? data.idle_facility_ids.length : 0;
|
||
setQueueFeedback({
|
||
tone: 'warning',
|
||
title: 'Noen anlegg er allerede i arbeid',
|
||
message:
|
||
conflictingCount > 0
|
||
? `${data.message || 'Utvalget overlapper med en aktiv jobb.'}${idleCount > 0 ? ` ${idleCount} andre anlegg i valget er fortsatt ledige hvis du vil justere utvalget.` : ''}`
|
||
: (data.message || 'Utvalget overlapper med en aktiv jobb.')
|
||
});
|
||
}
|
||
fetchScrapeJobs(activeTab);
|
||
} catch (error) {
|
||
setQueueFeedback({
|
||
tone: 'error',
|
||
title: 'Kunne ikke starte jobben',
|
||
message: `Det oppstod en feil ved start av ${activeTab}-skraperen.`
|
||
});
|
||
alert(`Feil ved start av ${activeTab}-skraperen.`);
|
||
} finally {
|
||
setIsQueueing(false);
|
||
}
|
||
};
|
||
|
||
const openEditModal = (facility: any) => {
|
||
setEditingFacility(facility);
|
||
setEditForm({
|
||
scrape_status_url: facility.scrape_status_url || '',
|
||
scrape_status_selector: facility.scrape_status_selector || '',
|
||
scrape_method: normalizeScrapeMethod(facility.scrape_method),
|
||
ai_instruction: facility.ai_instruction || '',
|
||
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
|
||
});
|
||
};
|
||
|
||
const handleSaveEdit = async () => {
|
||
setIsSaving(true);
|
||
try {
|
||
const response = await adminFetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(editForm)
|
||
});
|
||
if (!response.ok) throw new Error("Feil ved lagring");
|
||
setEditingFacility(null);
|
||
fetchFacilities();
|
||
} catch (error) {
|
||
alert("Kunne ikke lagre endringene.");
|
||
} finally { setIsSaving(false); }
|
||
};
|
||
|
||
const closeManualOverrideModal = () => {
|
||
setManualEditFacility(null);
|
||
setManualEditTab(null);
|
||
setManualEditForm(EMPTY_MANUAL_OVERRIDE_FORM);
|
||
setIsManualSaving(false);
|
||
};
|
||
|
||
const openManualOverrideModal = (facility: any, tab: ManualOverrideTab) => {
|
||
setManualEditFacility(facility);
|
||
setManualEditTab(tab);
|
||
setManualEditForm({
|
||
medlemskap_url: facility.medlemskap_url || '',
|
||
navn_standard_medlemskap: facility.navn_standard_medlemskap || '',
|
||
standard_medlemskap: toInputString(facility.standard_medlemskap),
|
||
standard_medlemskap_kommentarer: facility.standard_medlemskap_kommentarer || '',
|
||
navn_rimeligste_alternativ: facility.navn_rimeligste_alternativ || '',
|
||
rimeligste_alternativ: toInputString(facility.rimeligste_alternativ),
|
||
greenfee_url: facility.greenfee_url || '',
|
||
guest_requirements: facility.guest_requirements || '',
|
||
greenfee: normalizeGreenfeeRows(facility.greenfee),
|
||
vtg_lenke: facility.vtg_lenke || '',
|
||
vtg_pris: toInputString(facility.vtg_pris),
|
||
vtg_beskrivelse: facility.vtg_beskrivelse || '',
|
||
vtg_datoer: normalizeVtgDates(facility.vtg_datoer),
|
||
});
|
||
};
|
||
|
||
const updateManualOverrideField = (field: keyof ManualOverrideForm, value: string) => {
|
||
setManualEditForm((prev) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
const updateManualGreenfeeRow = (index: number, field: keyof GreenfeeRow, value: string) => {
|
||
setManualEditForm((prev) => {
|
||
const nextRows = [...prev.greenfee];
|
||
nextRows[index] = { ...nextRows[index], [field]: value };
|
||
return { ...prev, greenfee: nextRows };
|
||
});
|
||
};
|
||
|
||
const addManualGreenfeeRow = () => {
|
||
setManualEditForm((prev) => ({ ...prev, greenfee: [...prev.greenfee, { ...EMPTY_GREENFEE_ROW }] }));
|
||
};
|
||
|
||
const removeManualGreenfeeRow = (index: number) => {
|
||
setManualEditForm((prev) => ({ ...prev, greenfee: prev.greenfee.filter((_, rowIndex) => rowIndex !== index) }));
|
||
};
|
||
|
||
const updateManualVtgDateRow = (index: number, field: keyof VtgDateRow, value: string) => {
|
||
setManualEditForm((prev) => {
|
||
const nextRows = [...prev.vtg_datoer];
|
||
nextRows[index] = { ...nextRows[index], [field]: value };
|
||
return { ...prev, vtg_datoer: nextRows };
|
||
});
|
||
};
|
||
|
||
const addManualVtgDateRow = () => {
|
||
setManualEditForm((prev) => ({ ...prev, vtg_datoer: [...prev.vtg_datoer, { ...EMPTY_VTG_DATE_ROW }] }));
|
||
};
|
||
|
||
const removeManualVtgDateRow = (index: number) => {
|
||
setManualEditForm((prev) => ({ ...prev, vtg_datoer: prev.vtg_datoer.filter((_, rowIndex) => rowIndex !== index) }));
|
||
};
|
||
|
||
const handleSaveManualOverride = async () => {
|
||
if (!manualEditFacility || !manualEditTab) return;
|
||
|
||
const now = new Date().toISOString();
|
||
let payload: Record<string, unknown> = {};
|
||
|
||
if (manualEditTab === 'medlemskap') {
|
||
payload = {
|
||
medlemskap_url: manualEditForm.medlemskap_url.trim(),
|
||
navn_standard_medlemskap: manualEditForm.navn_standard_medlemskap.trim(),
|
||
standard_medlemskap: toNullableNumber(manualEditForm.standard_medlemskap),
|
||
standard_medlemskap_kommentarer: manualEditForm.standard_medlemskap_kommentarer.trim(),
|
||
navn_rimeligste_alternativ: manualEditForm.navn_rimeligste_alternativ.trim(),
|
||
rimeligste_alternativ: toNullableNumber(manualEditForm.rimeligste_alternativ),
|
||
membership_updated_at: now,
|
||
membership_draft: null,
|
||
};
|
||
}
|
||
|
||
if (manualEditTab === 'greenfee') {
|
||
payload = {
|
||
greenfee_url: manualEditForm.greenfee_url.trim(),
|
||
guest_requirements: manualEditForm.guest_requirements.trim(),
|
||
greenfee: manualEditForm.greenfee
|
||
.map((row) => ({
|
||
banenavn: row.banenavn.trim(),
|
||
priskategori: row.priskategori.trim(),
|
||
pris_voksne: toNullableNumber(row.pris_voksne),
|
||
pris_junior: toNullableNumber(row.pris_junior),
|
||
}))
|
||
.filter((row) => row.banenavn || row.priskategori || row.pris_voksne !== null || row.pris_junior !== null),
|
||
greenfee_updated_at: now,
|
||
greenfee_draft: null,
|
||
};
|
||
}
|
||
|
||
if (manualEditTab === 'vtg') {
|
||
payload = {
|
||
vtg_lenke: manualEditForm.vtg_lenke.trim(),
|
||
vtg_pris: toNullableNumber(manualEditForm.vtg_pris),
|
||
vtg_beskrivelse: manualEditForm.vtg_beskrivelse.trim(),
|
||
vtg_datoer: manualEditForm.vtg_datoer
|
||
.map((row) => ({
|
||
dato: row.dato.trim(),
|
||
status: row.status.trim() || 'Ledig',
|
||
}))
|
||
.filter((row) => row.dato),
|
||
vtg_updated_at: now,
|
||
vtg_draft: null,
|
||
};
|
||
}
|
||
|
||
setIsManualSaving(true);
|
||
try {
|
||
const response = await adminFetch(`${API_URL}/admin/facilities/${manualEditFacility.id}/full`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!response.ok) throw new Error("Feil ved lagring");
|
||
closeManualOverrideModal();
|
||
fetchFacilities();
|
||
} catch (error) {
|
||
alert("Kunne ikke lagre de manuelle verdiene.");
|
||
} finally {
|
||
setIsManualSaving(false);
|
||
}
|
||
};
|
||
|
||
const openTwoFactorModal = () => {
|
||
setShowTwoFactorModal(true);
|
||
setTwoFactorPassword('');
|
||
setTwoFactorError('');
|
||
setTwoFactorSetup(null);
|
||
setCopiedTwoFactorField(null);
|
||
};
|
||
|
||
const closeTwoFactorModal = () => {
|
||
setShowTwoFactorModal(false);
|
||
setTwoFactorPassword('');
|
||
setTwoFactorError('');
|
||
setTwoFactorSetup(null);
|
||
setCopiedTwoFactorField(null);
|
||
};
|
||
|
||
const handleLoadTwoFactorSetup = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
setTwoFactorError('');
|
||
setIsLoadingTwoFactor(true);
|
||
setCopiedTwoFactorField(null);
|
||
|
||
try {
|
||
const response = await adminFetch(`${API_URL}/admin/2fa/setup`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: twoFactorPassword })
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente 2FA-oppsett');
|
||
setTwoFactorSetup(data);
|
||
setTwoFactorPassword('');
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === 'ADMIN_UNAUTHORIZED') return;
|
||
setTwoFactorSetup(null);
|
||
setTwoFactorError(error instanceof Error ? error.message : 'Kunne ikke hente 2FA-oppsett');
|
||
} finally {
|
||
setIsLoadingTwoFactor(false);
|
||
}
|
||
};
|
||
|
||
const copyTwoFactorValue = async (field: 'secret' | 'uri', value: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
setCopiedTwoFactorField(field);
|
||
window.setTimeout(() => setCopiedTwoFactorField(null), 2000);
|
||
} catch {
|
||
setCopiedTwoFactorField(null);
|
||
}
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
try {
|
||
await fetch(`${API_URL}/auth/logout`, {
|
||
method: 'POST',
|
||
credentials: 'include'
|
||
});
|
||
} finally {
|
||
window.location.href = '/';
|
||
}
|
||
};
|
||
|
||
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER KONTROLLPANEL...</div>;
|
||
|
||
return (
|
||
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-x-hidden">
|
||
|
||
{/* REDIGER-MODAL FOR BANESTATUS */}
|
||
{editingFacility && (
|
||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
||
<div className="bg-[#11280f] text-white p-6 shrink-0">
|
||
<h3 className="text-xl font-black uppercase tracking-widest">Skrape-innstillinger</h3>
|
||
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
|
||
</div>
|
||
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL (Banestatus)</label>
|
||
<input type="text" value={editForm.scrape_status_url} onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="f.eks. https://golfklubb.no/banestatus" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
|
||
<select value={editForm.scrape_method} onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors">
|
||
<option value="css_selector">Standard (CSS)</option>
|
||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
||
<option value="iframe_golfbox">Golfbox iframe</option>
|
||
<option value="click_then_css">Auto-klikk + CSS</option>
|
||
<option value="disabled">Ingen (Avslått)</option>
|
||
<option value="manual">🚨 Manuell (Ikke skrap)</option>
|
||
</select>
|
||
</div>
|
||
{editForm.scrape_method === 'llm_parse' && (
|
||
<div className="animate-fade-in">
|
||
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2">✨ AI-Hviskeren (Instruks til Gemini)</label>
|
||
<textarea value={editForm.ai_instruction || ''} onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})} className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen." rows={3} />
|
||
</div>
|
||
)}
|
||
{editForm.scrape_method === 'manual' && (
|
||
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
|
||
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
|
||
<div className="space-y-4">
|
||
{editForm.courses.map((course: any, idx: number) => (
|
||
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
||
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
|
||
<select value={course.status || 'ukjent'} onChange={(e) => { const newCourses = [...editForm.courses]; newCourses[idx].status = e.target.value; setEditForm({...editForm, courses: newCourses}); }} className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0">
|
||
<option value="aapen">🟢 Åpen</option>
|
||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
||
<option value="stengt">🔴 Stengt</option>
|
||
<option value="stenger_snart">🔴 Stenger Snart</option>
|
||
<option value="under_utvikling">🔨 Under Utvikling</option>
|
||
<option value="nedlagt">⚫ Nedlagt</option>
|
||
<option value="ukjent">⚪ Ukjent</option>
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
|
||
<div>
|
||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
|
||
<input type="text" value={editForm.scrape_status_selector} onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono" placeholder="f.eks. .status-text" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
|
||
<button onClick={() => setEditingFacility(null)} className="btn btn-md btn-secondary">Avbryt</button>
|
||
<button onClick={handleSaveEdit} disabled={isSaving} className="btn btn-md btn-primary disabled:opacity-50">
|
||
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{manualEditFacility && manualEditTab && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||
<div className="flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-3xl bg-white shadow-2xl">
|
||
<div className="shrink-0 bg-[#11280f] p-6 text-white">
|
||
<h3 className="text-xl font-black uppercase tracking-widest">
|
||
Manuell overstyring: {JOB_LABELS[manualEditTab]}
|
||
</h3>
|
||
<p className="text-sm text-[#7ca982]">{manualEditFacility.name}</p>
|
||
</div>
|
||
|
||
<div className="flex-grow space-y-6 overflow-y-auto p-8">
|
||
{manualEditTab === 'medlemskap' && (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Kilde-URL</label>
|
||
<input
|
||
type="text"
|
||
value={manualEditForm.medlemskap_url}
|
||
onChange={(e) => updateManualOverrideField('medlemskap_url', e.target.value)}
|
||
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
|
||
placeholder="https://..."
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
|
||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Standard medlemskap</p>
|
||
<div className="mt-4 space-y-3">
|
||
<input
|
||
type="text"
|
||
value={manualEditForm.navn_standard_medlemskap}
|
||
onChange={(e) => updateManualOverrideField('navn_standard_medlemskap', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
|
||
placeholder="Navn på standard medlemskap"
|
||
/>
|
||
<input
|
||
type="number"
|
||
value={manualEditForm.standard_medlemskap}
|
||
onChange={(e) => updateManualOverrideField('standard_medlemskap', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
|
||
placeholder="Pris i kroner"
|
||
/>
|
||
<textarea
|
||
rows={3}
|
||
value={manualEditForm.standard_medlemskap_kommentarer}
|
||
onChange={(e) => updateManualOverrideField('standard_medlemskap_kommentarer', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm outline-none focus:border-[#8bc34a]"
|
||
placeholder="Kommentar eller forbehold"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
|
||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste alternativ</p>
|
||
<div className="mt-4 space-y-3">
|
||
<input
|
||
type="text"
|
||
value={manualEditForm.navn_rimeligste_alternativ}
|
||
onChange={(e) => updateManualOverrideField('navn_rimeligste_alternativ', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
|
||
placeholder="Navn på billigste alternativ"
|
||
/>
|
||
<input
|
||
type="number"
|
||
value={manualEditForm.rimeligste_alternativ}
|
||
onChange={(e) => updateManualOverrideField('rimeligste_alternativ', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
|
||
placeholder="Pris i kroner"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{manualEditTab === 'greenfee' && (
|
||
<div className="space-y-6">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Kilde-URL</label>
|
||
<input
|
||
type="text"
|
||
value={manualEditForm.greenfee_url}
|
||
onChange={(e) => updateManualOverrideField('greenfee_url', e.target.value)}
|
||
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
|
||
placeholder="https://..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Krav til gjestespill</label>
|
||
<input
|
||
type="text"
|
||
value={manualEditForm.guest_requirements}
|
||
onChange={(e) => updateManualOverrideField('guest_requirements', e.target.value)}
|
||
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
|
||
placeholder="F.eks. klubbhandicap"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Greenfee-priser</p>
|
||
<button type="button" onClick={addManualGreenfeeRow} className="btn btn-sm btn-secondary">
|
||
+ Legg til prisrad
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
{manualEditForm.greenfee.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500">
|
||
Ingen priser registrert ennå.
|
||
</div>
|
||
) : (
|
||
manualEditForm.greenfee.map((row, index) => (
|
||
<div key={index} className="grid gap-3 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_110px_110px_auto] md:items-center">
|
||
<input
|
||
type="text"
|
||
value={row.banenavn}
|
||
onChange={(e) => updateManualGreenfeeRow(index, 'banenavn', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
|
||
placeholder="Bane"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={row.priskategori}
|
||
onChange={(e) => updateManualGreenfeeRow(index, 'priskategori', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm outline-none focus:border-[#8bc34a]"
|
||
placeholder="Kategori"
|
||
/>
|
||
<input
|
||
type="number"
|
||
value={row.pris_voksne}
|
||
onChange={(e) => updateManualGreenfeeRow(index, 'pris_voksne', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-center text-sm outline-none focus:border-[#8bc34a]"
|
||
placeholder="Voksen"
|
||
/>
|
||
<input
|
||
type="number"
|
||
value={row.pris_junior}
|
||
onChange={(e) => updateManualGreenfeeRow(index, 'pris_junior', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-center text-sm outline-none focus:border-[#8bc34a]"
|
||
placeholder="Junior"
|
||
/>
|
||
<button type="button" onClick={() => removeManualGreenfeeRow(index)} className="btn btn-sm btn-danger">
|
||
✕
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{manualEditTab === 'vtg' && (
|
||
<div className="space-y-6">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">VTG-lenke</label>
|
||
<input
|
||
type="text"
|
||
value={manualEditForm.vtg_lenke}
|
||
onChange={(e) => updateManualOverrideField('vtg_lenke', e.target.value)}
|
||
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
|
||
placeholder="https://..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Pris</label>
|
||
<input
|
||
type="number"
|
||
value={manualEditForm.vtg_pris}
|
||
onChange={(e) => updateManualOverrideField('vtg_pris', e.target.value)}
|
||
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm font-bold outline-none transition-colors focus:border-[#8bc34a]"
|
||
placeholder="Pris i kroner"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Beskrivelse</label>
|
||
<textarea
|
||
rows={4}
|
||
value={manualEditForm.vtg_beskrivelse}
|
||
onChange={(e) => updateManualOverrideField('vtg_beskrivelse', e.target.value)}
|
||
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
|
||
placeholder="Beskriv hva som inngår i kurset"
|
||
/>
|
||
</div>
|
||
|
||
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Kursdatoer</p>
|
||
<button type="button" onClick={addManualVtgDateRow} className="btn btn-sm btn-secondary">
|
||
+ Legg til dato
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
{manualEditForm.vtg_datoer.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500">
|
||
Ingen kursdatoer registrert ennå.
|
||
</div>
|
||
) : (
|
||
manualEditForm.vtg_datoer.map((row, index) => (
|
||
<div key={index} className="grid gap-3 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(0,1fr)_180px_auto] md:items-center">
|
||
<input
|
||
type="text"
|
||
value={row.dato}
|
||
onChange={(e) => updateManualVtgDateRow(index, 'dato', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
|
||
placeholder="F.eks. 12.-14. mai"
|
||
/>
|
||
<select
|
||
value={row.status}
|
||
onChange={(e) => updateManualVtgDateRow(index, 'status', e.target.value)}
|
||
className="w-full rounded-xl border border-gray-200 bg-white p-3 text-sm outline-none focus:border-[#8bc34a]"
|
||
>
|
||
<option value="Ledig">Ledig</option>
|
||
<option value="Fulltegnet">Fulltegnet</option>
|
||
<option value="Venteliste">Venteliste</option>
|
||
<option value="Få plasser">Få plasser</option>
|
||
</select>
|
||
<button type="button" onClick={() => removeManualVtgDateRow(index)} className="btn btn-sm btn-danger">
|
||
✕
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 justify-end gap-4 bg-gray-50 p-6">
|
||
<button onClick={closeManualOverrideModal} className="btn btn-md btn-secondary">
|
||
Avbryt
|
||
</button>
|
||
<button onClick={handleSaveManualOverride} disabled={isManualSaving} className="btn btn-md btn-primary disabled:opacity-50">
|
||
{isManualSaving ? 'Lagrer...' : 'Lagre manuelt'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showTwoFactorModal && (
|
||
<div
|
||
className="fixed inset-0 z-50 overflow-y-auto bg-black/60 p-3 md:p-4"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
closeTwoFactorModal();
|
||
}
|
||
}}
|
||
>
|
||
<div className="relative mx-auto my-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl">
|
||
<button
|
||
onClick={closeTwoFactorModal}
|
||
className="btn-icon absolute right-7 top-7 z-30 border-white/20 bg-white/12 text-lg text-white backdrop-blur hover:bg-white/20"
|
||
aria-label="Lukk 2FA-vindu"
|
||
title="Lukk"
|
||
>
|
||
×
|
||
</button>
|
||
|
||
<div className="sticky top-0 z-20 flex items-start justify-between gap-4 bg-[#11280f] p-6 pr-20 text-white">
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">1Password og TOTP</p>
|
||
<h3 className="mt-2 text-2xl font-black tracking-tight">Sett opp 2FA i 1Password</h3>
|
||
<p className="mt-2 max-w-2xl text-sm text-white/80">
|
||
Bekreft passordet ditt på nytt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan brukes i 1Password.
|
||
</p>
|
||
</div>
|
||
<button onClick={closeTwoFactorModal} className="btn btn-sm btn-secondary-dark hidden md:inline-flex">
|
||
Lukk
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid gap-0 lg:grid-cols-[20rem_minmax(0,1fr)]">
|
||
<aside className="border-b border-gray-100 bg-[#f8fbf4] p-6 lg:border-b-0 lg:border-r">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Før du starter</p>
|
||
<div className="mt-5 space-y-4 text-sm leading-relaxed text-gray-600">
|
||
<p>1Password støtter samme TOTP-standard som systemet allerede bruker.</p>
|
||
<p>Dette viser eksisterende 2FA-oppsett. Vi regenererer ikke nøkkelen, så vanlig login fortsetter å virke som før.</p>
|
||
<p>Hvis du vil kan du skanne QR-koden eller kopiere den manuelle nøkkelen direkte inn i 1Password.</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<div className="space-y-6 p-5 md:p-6">
|
||
<section className="space-y-5">
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Sikker bekreftelse</p>
|
||
<h4 className="mt-2 text-xl font-black tracking-tight text-[#11280f]">Vis QR-kode</h4>
|
||
</div>
|
||
|
||
<form onSubmit={handleLoadTwoFactorSetup} className="space-y-4 rounded-[1.75rem] border border-gray-100 bg-gray-50 p-5">
|
||
<label className="block">
|
||
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-gray-500">Bekreft passord</span>
|
||
<input
|
||
type="password"
|
||
value={twoFactorPassword}
|
||
onChange={(e) => setTwoFactorPassword(e.target.value)}
|
||
className="w-full rounded-2xl border-2 border-gray-200 bg-white px-5 py-4 text-base font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
|
||
placeholder="Skriv inn admin-passordet ditt"
|
||
required
|
||
/>
|
||
</label>
|
||
|
||
{twoFactorError && (
|
||
<div className="rounded-2xl border border-red-100 bg-red-50 px-4 py-3 text-sm font-bold text-red-600">
|
||
{twoFactorError}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isLoadingTwoFactor || twoFactorPassword.trim().length === 0}
|
||
className="btn btn-lg btn-primary w-full disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
|
||
>
|
||
{isLoadingTwoFactor ? 'Henter oppsett...' : 'Vis oppsett for 1Password'}
|
||
</button>
|
||
</form>
|
||
</section>
|
||
|
||
<section className="space-y-5">
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">2FA-oppsett</p>
|
||
<h4 className="mt-2 text-xl font-black tracking-tight text-[#11280f]">QR-kode og nøkkel</h4>
|
||
</div>
|
||
|
||
{!twoFactorSetup ? (
|
||
<div className="flex min-h-[360px] items-center justify-center rounded-[1.75rem] border border-dashed border-gray-200 bg-white p-8 text-center">
|
||
<div className="max-w-md">
|
||
<p className="text-lg font-black text-[#11280f]">Ingen QR-kode vist ennå</p>
|
||
<p className="mt-3 text-sm text-gray-500">
|
||
Bekreft passordet ditt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan legges inn i 1Password.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="rounded-[1.75rem] border border-gray-100 bg-[#f8fbf4] p-5 shadow-sm">
|
||
<div className="mx-auto w-full max-w-[18rem] rounded-[1.5rem] bg-white p-4 shadow-inner" dangerouslySetInnerHTML={{ __html: twoFactorSetup.qr_svg }} />
|
||
<p className="mt-4 text-center text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">
|
||
Skann i 1Password
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-4 rounded-[1.75rem] border border-gray-100 bg-gray-50 p-5">
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Konto</p>
|
||
<p className="mt-2 break-words text-lg font-black text-[#11280f]">{twoFactorSetup.account_name}</p>
|
||
<p className="text-sm text-gray-500">Issuer: {twoFactorSetup.issuer}</p>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Manuell oppsettsnøkkel</p>
|
||
<p className="mt-3 break-all font-mono text-sm font-bold text-[#11280f]">{twoFactorSetup.otp_secret}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyTwoFactorValue('secret', twoFactorSetup.otp_secret)}
|
||
className="btn btn-md btn-secondary mt-4 w-full sm:w-auto"
|
||
>
|
||
{copiedTwoFactorField === 'secret' ? 'Kopiert' : 'Kopier nøkkel'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Avansert URI</p>
|
||
<p className="mt-3 break-all font-mono text-xs font-bold text-gray-600">{twoFactorSetup.provisioning_uri}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyTwoFactorValue('uri', twoFactorSetup.provisioning_uri)}
|
||
className="btn btn-md btn-secondary mt-4 w-full sm:w-auto"
|
||
>
|
||
{copiedTwoFactorField === 'uri' ? 'Kopiert' : 'Kopier URI'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showMobileAdminMenu && (
|
||
<div
|
||
className="fixed inset-0 z-50 bg-black/50 md:hidden"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
setShowMobileAdminMenu(false);
|
||
}
|
||
}}
|
||
>
|
||
<div className="h-full w-[88%] max-w-[22rem] bg-[#11280f] p-6 text-white shadow-2xl">
|
||
<div className="flex items-center justify-between border-b border-white/10 pb-5">
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Adminmeny</p>
|
||
<h3 className="mt-2 text-2xl font-black tracking-tight">TeeOff Admin</h3>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowMobileAdminMenu(false)}
|
||
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10 text-xl font-black text-white hover:bg-white/20"
|
||
aria-label="Lukk adminmeny"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<nav className="mt-6 space-y-6 text-[11px] font-black uppercase tracking-[0.2em] text-[#7ca982]">
|
||
<Link href="/admin" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl border border-[#8bc34a]/30 bg-white/5 px-4 py-4 text-white">
|
||
Kontrollpanel
|
||
</Link>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Datavask</div>
|
||
<Link href="/admin/medlemskap" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||
Medlemskap
|
||
</Link>
|
||
<Link href="/admin/greenfee" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||
Greenfee
|
||
</Link>
|
||
<Link href="/admin/golfpakker" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||
Golfpakker
|
||
</Link>
|
||
<Link href="/admin/simulatorer" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||
Simulatorer
|
||
</Link>
|
||
<Link href="/admin/vtg" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||
VTG
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Innhold</div>
|
||
<Link href="/admin/steder" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||
Steder
|
||
</Link>
|
||
<Link href="/admin/artikler" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||
Artikler
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
|
||
<button
|
||
onClick={() => {
|
||
setShowMobileAdminMenu(false);
|
||
openTwoFactorModal();
|
||
}}
|
||
className="block w-full rounded-2xl px-4 py-3 text-left hover:bg-white/5 hover:text-white"
|
||
>
|
||
2FA / 1Password
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<div className="mt-8 border-t border-white/10 pt-6">
|
||
<button
|
||
onClick={handleLogout}
|
||
className="w-full rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-4 text-left text-[11px] font-black uppercase tracking-[0.2em] text-red-300 hover:bg-red-500/20"
|
||
>
|
||
Logg ut
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* SIDEBAR */}
|
||
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
|
||
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
|
||
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">☰</button>
|
||
</div>
|
||
|
||
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
||
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Kontrollpanel">
|
||
{isSidebarCollapsed ? 'KP' : 'Kontrollpanel'}
|
||
</Link>
|
||
<div className="space-y-2 mt-4">
|
||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Datavask</div>
|
||
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
||
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
||
</Link>
|
||
<Link href="/admin/greenfee" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Greenfee">
|
||
{isSidebarCollapsed ? 'G' : 'Greenfee'}
|
||
</Link>
|
||
<Link href="/admin/golfpakker" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Golfpakker">
|
||
{isSidebarCollapsed ? 'GP' : 'Golfpakker'}
|
||
</Link>
|
||
<Link href="/admin/simulatorer" 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="Simulatorer">
|
||
{isSidebarCollapsed ? 'SI' : 'Simulatorer'}
|
||
</Link>
|
||
<Link href="/admin/vtg" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Veien til Golf (VTG)">
|
||
{isSidebarCollapsed ? 'V' : 'VTG'}
|
||
</Link>
|
||
</div>
|
||
<div className="space-y-2 mt-6">
|
||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Innhold</div>
|
||
<Link href="/admin/sider" 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="Sider">
|
||
{isSidebarCollapsed ? 'Si' : 'Sider'}
|
||
</Link>
|
||
<Link href="/admin/steder" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Steder">
|
||
{isSidebarCollapsed ? 'S' : 'Steder'}
|
||
</Link>
|
||
<Link href="/admin/artikler" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Artikler">
|
||
{isSidebarCollapsed ? 'A' : 'Artikler'}
|
||
</Link>
|
||
</div>
|
||
<div className="space-y-2 mt-6">
|
||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Konto</div>
|
||
<button
|
||
onClick={openTwoFactorModal}
|
||
className={`block w-full text-left hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`}
|
||
title="2FA i 1Password"
|
||
>
|
||
{isSidebarCollapsed ? '2F' : '2FA / 1Password'}
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
|
||
<button onClick={handleLogout} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
|
||
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* HOVEDINNHOLD */}
|
||
<main ref={mainContentRef} className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-auto">
|
||
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
|
||
<div className="mb-6 flex md:hidden">
|
||
<button
|
||
onClick={() => setShowMobileAdminMenu(true)}
|
||
className="btn btn-md btn-primary"
|
||
>
|
||
<span className="text-lg leading-none">☰</span>
|
||
Adminmeny
|
||
</button>
|
||
</div>
|
||
|
||
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8">
|
||
<div>
|
||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Kontrollpanel</h2>
|
||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Oversikt over {filteredFacilities.length} anlegg</p>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Link
|
||
href="/admin/nytt-anlegg"
|
||
className="btn btn-md btn-primary"
|
||
>
|
||
Nytt anlegg
|
||
</Link>
|
||
<Link
|
||
href="/admin/sider"
|
||
className="btn btn-md btn-secondary"
|
||
>
|
||
Sider
|
||
</Link>
|
||
<Link
|
||
href="/admin/steder"
|
||
className="btn btn-md btn-secondary"
|
||
>
|
||
Sted-sider
|
||
</Link>
|
||
<Link
|
||
href="/admin/artikler"
|
||
className="btn btn-md btn-secondary"
|
||
>
|
||
Artikler / Banebesøk
|
||
</Link>
|
||
<button
|
||
onClick={openTwoFactorModal}
|
||
className="btn btn-md btn-secondary"
|
||
>
|
||
2FA / 1Password
|
||
</button>
|
||
<button
|
||
onClick={handleRunScrapers}
|
||
disabled={selectedFacilities.length === 0 || isQueueing}
|
||
className={`btn btn-lg whitespace-nowrap disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400 ${
|
||
isQueueing ? 'bg-yellow-500 text-white animate-pulse' : 'btn-primary'
|
||
}`}
|
||
>
|
||
{isQueueing ? 'Legger i kø...' : isScraping ? `Legg ${activeTab}-skraping i kø (${selectedFacilities.length})` : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{queueFeedback && (
|
||
<div className={`mb-6 rounded-[1.5rem] border px-5 py-4 md:px-6 ${
|
||
queueFeedback.tone === 'success'
|
||
? 'border-[#d8e8c8] bg-[#f1f7ed]'
|
||
: queueFeedback.tone === 'warning'
|
||
? 'border-amber-200 bg-amber-50'
|
||
: queueFeedback.tone === 'error'
|
||
? 'border-red-100 bg-red-50'
|
||
: 'border-slate-200 bg-slate-50'
|
||
}`}>
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-1">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">Køstatus</p>
|
||
<p className="text-sm font-black text-[#11280f]">{queueFeedback.title}</p>
|
||
<p className="text-sm leading-relaxed text-gray-600">{queueFeedback.message}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setQueueFeedback(null)}
|
||
className="btn btn-sm btn-secondary self-start"
|
||
>
|
||
Lukk
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{latestJob && latestJob.job_type === activeTab && !isLatestJobDismissed && (
|
||
<div className={`mb-8 rounded-[1.75rem] border p-5 md:p-6 animate-fade-in ${
|
||
latestJob.status === 'failed'
|
||
? 'bg-red-50 border-red-100'
|
||
: latestJob.status === 'completed'
|
||
? 'bg-[#f1f7ed] border-[#d8e8c8]'
|
||
: 'bg-amber-50 border-amber-100'
|
||
}`}>
|
||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||
<div className="space-y-2">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
|
||
{JOB_LABELS[activeTab]} jobb #{latestJob.id}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<span className={`inline-flex px-3 py-1 rounded-xl text-[10px] font-black uppercase tracking-widest ${
|
||
latestJob.status === 'failed'
|
||
? 'bg-red-100 text-red-700'
|
||
: latestJob.status === 'completed'
|
||
? 'bg-[#d8e8c8] text-[#11280f]'
|
||
: 'bg-amber-100 text-amber-700 animate-pulse'
|
||
}`}>
|
||
{JOB_STATUS_LABELS[latestJob.status]}
|
||
</span>
|
||
<span className="text-xs font-bold text-[#11280f]">
|
||
{latestJob.total_facilities} anlegg
|
||
</span>
|
||
{latestJobAttemptLabel && (
|
||
<span className="inline-flex rounded-xl bg-white/80 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-white/70">
|
||
{latestJobAttemptLabel}
|
||
</span>
|
||
)}
|
||
{latestJobErrorLabel && (
|
||
<span className="inline-flex rounded-xl bg-white/80 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-white/70">
|
||
{latestJobErrorLabel}
|
||
</span>
|
||
)}
|
||
{latestJob.created_at && (
|
||
<span className="text-xs text-gray-500">
|
||
Opprettet {new Date(latestJob.created_at).toLocaleString('nb-NO')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{latestJobRetryLabel && (
|
||
<p className="text-xs font-bold text-amber-700 leading-relaxed">
|
||
{latestJobRetryLabel}
|
||
</p>
|
||
)}
|
||
{latestJobProgress.total > 0 && (
|
||
<div className="mt-4 space-y-4">
|
||
<div className="rounded-[1.5rem] border border-white/70 bg-white/70 p-4 shadow-sm">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="space-y-1">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Fremdrift i jobben</p>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<span className="text-sm font-black text-[#11280f]">
|
||
{latestJobProgress.completed} av {latestJobProgress.total} anlegg behandlet
|
||
</span>
|
||
<span className="inline-flex rounded-xl bg-[#f4f7ef] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600">
|
||
{latestJobProgress.percent}%
|
||
</span>
|
||
</div>
|
||
{latestJob.current_facility_name && latestJob.status === 'running' && (
|
||
<p className="text-xs text-gray-600">
|
||
Behandler nå <span className="font-black text-[#11280f]">{latestJob.current_facility_name}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="inline-flex rounded-xl bg-[#edf6e3] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#11280f]">
|
||
OK {latestJob.progress_ok ?? 0}
|
||
</span>
|
||
<span className="inline-flex rounded-xl bg-red-50 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-red-700">
|
||
Feil {latestJob.progress_failed ?? 0}
|
||
</span>
|
||
<span className="inline-flex rounded-xl bg-amber-50 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-amber-800">
|
||
Hoppet over {latestJob.progress_skipped ?? 0}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 h-3 overflow-hidden rounded-full bg-white">
|
||
<div
|
||
className="h-full rounded-full bg-[#8bc34a] transition-all duration-500"
|
||
style={{ width: `${latestJobProgress.percent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{latestJobEvents.length > 0 && (
|
||
<div className="space-y-3">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Siste hendelser</p>
|
||
<div className="grid gap-3 xl:grid-cols-2">
|
||
{latestJobEvents.map((event, index) => {
|
||
const toneClass = JOB_EVENT_TONE_CLASSES[event.outcome || 'info'] || JOB_EVENT_TONE_CLASSES.info;
|
||
const progressLabel =
|
||
event.processed && event.total
|
||
? `${event.processed} av ${event.total}`
|
||
: latestJobProgress.total > 0
|
||
? `${latestJobProgress.completed} av ${latestJobProgress.total}`
|
||
: '';
|
||
|
||
return (
|
||
<article
|
||
key={`${event.timestamp || 'event'}-${index}`}
|
||
className={`rounded-[1.25rem] border p-4 ${toneClass}`}
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{progressLabel && (
|
||
<span className="inline-flex rounded-xl bg-white/80 px-3 py-1 text-[10px] font-black uppercase tracking-widest">
|
||
{progressLabel}
|
||
</span>
|
||
)}
|
||
{event.facility_name && (
|
||
<span className="text-xs font-black">{event.facility_name}</span>
|
||
)}
|
||
{event.timestamp && (
|
||
<span className="text-[10px] text-gray-500">
|
||
{new Date(event.timestamp).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-sm leading-relaxed">{event.message || 'Hendelse registrert.'}</p>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{latestJobSummary && (
|
||
<p className="text-xs text-gray-600 leading-relaxed">{latestJobSummary}</p>
|
||
)}
|
||
{latestJob.error_message && (
|
||
<p className="text-xs text-red-600 leading-relaxed">{latestJob.error_message}</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 self-start">
|
||
<button
|
||
onClick={() => fetchScrapeJobs(activeTab)}
|
||
className="px-4 py-2 rounded-xl bg-white text-[10px] font-black uppercase tracking-widest text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
|
||
>
|
||
Oppdater status
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (!latestJobCardKey) return;
|
||
setDismissedLatestJobKeys((prev) => ({ ...prev, [activeTab]: latestJobCardKey }));
|
||
}}
|
||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-white text-xl leading-none text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
|
||
aria-label="Lukk jobbstatus"
|
||
title="Lukk"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showJobHistory && (
|
||
<section className="mb-8 rounded-[1.75rem] border border-gray-100 bg-[#fbfcf8] p-5 md:p-6">
|
||
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Jobbhistorikk</p>
|
||
<h3 className="text-lg font-black tracking-tight text-[#11280f]">Siste jobber i {JOB_LABELS[activeTab].toLowerCase()}</h3>
|
||
</div>
|
||
<p className="text-xs text-gray-500">Kortene viser status, forsøk og siste kjente resultat uten sideveis scrolling.</p>
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
{recentJobs.map(job => {
|
||
const historyErrorLabel = job.error_code ? (JOB_ERROR_LABELS[job.error_code] || job.error_code.replaceAll('_', ' ')) : '';
|
||
const historyAttemptLabel = job.attempt_count && job.max_attempts ? `Forsøk ${job.attempt_count} av ${job.max_attempts}` : '';
|
||
const historySummary = job.result_summary
|
||
? Object.entries(job.result_summary)
|
||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||
.map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`)
|
||
.join(' • ')
|
||
: '';
|
||
|
||
return (
|
||
<article
|
||
key={job.id}
|
||
className={`rounded-[1.5rem] border p-4 shadow-sm ${
|
||
job.status === 'failed'
|
||
? 'border-red-100 bg-white'
|
||
: job.status === 'completed'
|
||
? 'border-[#d8e8c8] bg-white'
|
||
: 'border-amber-100 bg-white'
|
||
}`}
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${
|
||
job.status === 'failed'
|
||
? 'bg-red-100 text-red-700'
|
||
: job.status === 'completed'
|
||
? 'bg-[#d8e8c8] text-[#11280f]'
|
||
: 'bg-amber-100 text-amber-700'
|
||
}`}>
|
||
{JOB_STATUS_LABELS[job.status]}
|
||
</span>
|
||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Jobb #{job.id}</span>
|
||
<span className="text-xs font-bold text-[#11280f]">{job.total_facilities} anlegg</span>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{historyAttemptLabel && (
|
||
<span className="inline-flex rounded-xl bg-[#f4f7ef] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600">
|
||
{historyAttemptLabel}
|
||
</span>
|
||
)}
|
||
{historyErrorLabel && (
|
||
<span className="inline-flex rounded-xl bg-[#f4f7ef] px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600">
|
||
{historyErrorLabel}
|
||
</span>
|
||
)}
|
||
{job.finished_at && (
|
||
<span className="text-xs text-gray-500">
|
||
Ferdig {new Date(job.finished_at).toLocaleString('nb-NO')}
|
||
</span>
|
||
)}
|
||
{!job.finished_at && job.created_at && (
|
||
<span className="text-xs text-gray-500">
|
||
Opprettet {new Date(job.created_at).toLocaleString('nb-NO')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{job.next_retry_at && job.status === 'pending' && (
|
||
<p className="mt-3 text-xs font-bold text-amber-700">
|
||
Nytt forsøk planlagt {new Date(job.next_retry_at).toLocaleString('nb-NO')}
|
||
</p>
|
||
)}
|
||
{historySummary && (
|
||
<p className="mt-3 text-xs leading-relaxed text-gray-600">{historySummary}</p>
|
||
)}
|
||
{job.error_message && (
|
||
<p className="mt-3 text-xs leading-relaxed text-red-600">{job.error_message}</p>
|
||
)}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'banestatus' && (
|
||
<section className="mb-8 rounded-[1.75rem] border border-[#dbe7cf] bg-[#f8fbf4] p-5 md:p-6">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div className="space-y-2">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Endringshistorikk</p>
|
||
<h3 className="text-lg font-black tracking-tight text-[#11280f]">{statusHistoryHeading}</h3>
|
||
<p className="text-sm leading-relaxed text-gray-600">
|
||
Viser faktiske banestatusendringer fra skraper og manuelle overstyringer.
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||
<label className="block">
|
||
<span className="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
|
||
Dato
|
||
</span>
|
||
<input
|
||
type="date"
|
||
value={statusHistoryDate}
|
||
onChange={(e) => setStatusHistoryDate(e.target.value)}
|
||
className="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]"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setStatusHistoryDate('');
|
||
fetchCourseStatusHistory('');
|
||
}}
|
||
className="btn btn-md btn-secondary whitespace-nowrap"
|
||
>
|
||
Tilbake til i dag
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => fetchCourseStatusHistory()}
|
||
className="btn btn-md btn-secondary whitespace-nowrap"
|
||
>
|
||
Oppdater
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 flex flex-wrap gap-2">
|
||
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-[#dbe7cf]">
|
||
{courseStatusHistory.length} endringer
|
||
</span>
|
||
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-[#dbe7cf]">
|
||
{statusHistoryFacilityCount} anlegg
|
||
</span>
|
||
{isLoadingStatusHistory && (
|
||
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#7ca982] border border-[#dbe7cf]">
|
||
Laster...
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{courseStatusHistory.length === 0 ? (
|
||
<div className="mt-5 rounded-[1.5rem] border border-dashed border-[#dbe7cf] bg-white px-5 py-6 text-sm text-gray-500">
|
||
{isLoadingStatusHistory
|
||
? 'Laster historikk...'
|
||
: 'Ingen registrerte banestatusendringer for valgt dato.'}
|
||
</div>
|
||
) : (
|
||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||
{courseStatusHistory.map((entry) => {
|
||
const oldStatus = entry.old_status || 'ukjent';
|
||
const newStatus = entry.new_status || 'ukjent';
|
||
const oldStatusClass = STATUS_BADGE_CLASSES[oldStatus] || STATUS_BADGE_CLASSES.ukjent;
|
||
const newStatusClass = STATUS_BADGE_CLASSES[newStatus] || STATUS_BADGE_CLASSES.ukjent;
|
||
const sourceLabel = CHANGE_SOURCE_LABELS[entry.change_source] || entry.change_source;
|
||
const sourceTone =
|
||
entry.change_source === 'manual'
|
||
? 'bg-[#eef5e5] text-[#11280f] border-[#d8e8c8]'
|
||
: 'bg-slate-50 text-slate-700 border-slate-200';
|
||
|
||
return (
|
||
<article
|
||
key={entry.id}
|
||
className="rounded-[1.5rem] border border-[#dbe7cf] bg-white p-4 shadow-sm"
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`inline-flex rounded-xl border px-3 py-1 text-[10px] font-black uppercase tracking-widest ${sourceTone}`}>
|
||
{sourceLabel}
|
||
</span>
|
||
{entry.changed_at && (
|
||
<span className="text-xs text-gray-500">
|
||
{new Date(entry.changed_at).toLocaleString('nb-NO')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-3">
|
||
<p className="text-sm font-black text-[#11280f]">{entry.facility_name || 'Ukjent anlegg'}</p>
|
||
<p className="text-xs uppercase tracking-widest text-[#7ca982]">{entry.course_name || 'Ukjent bane'}</p>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs font-bold text-gray-500">
|
||
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${oldStatusClass}`}>
|
||
{STATUS_LABELS[oldStatus] || oldStatus.replaceAll('_', ' ')}
|
||
</span>
|
||
<span>→</span>
|
||
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${newStatusClass}`}>
|
||
{STATUS_LABELS[newStatus] || newStatus.replaceAll('_', ' ')}
|
||
</span>
|
||
</div>
|
||
{entry.change_source === 'manual' && (
|
||
<p className="mt-3 text-xs text-gray-500">
|
||
Endret av {entry.changed_by || 'ukjent admin'}
|
||
</p>
|
||
)}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* VELDIG SYNLIGE FANER */}
|
||
<div className="mb-8 flex flex-wrap gap-2 border-b-2 border-gray-100 pb-3">
|
||
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
|
||
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap</button>
|
||
<button onClick={() => setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee</button>
|
||
<button onClick={() => setActiveTab('golfpakker')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'golfpakker' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Golfpakker</button>
|
||
<button onClick={() => setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs</button>
|
||
</div>
|
||
|
||
{activeTab === 'banestatus' && (
|
||
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8 animate-fade-in">
|
||
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
|
||
<select id="statusFilter" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer">
|
||
<option value="alle">Vis alle anlegg</option>
|
||
<option value="aapne">🟢 Kun åpne baner</option>
|
||
<option value="ikke_aapne">🟠 Alle som ikke er åpne</option>
|
||
<option value="ikke_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
|
||
<option value="stengt">🔴 Kun stengte baner</option>
|
||
<option value="nedlagt">⚫ Kun nedlagte baner</option>
|
||
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-6 flex flex-col gap-4 rounded-[1.75rem] border border-gray-100 bg-[#f8fbf4] p-4 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-2">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Arbeidsvisning</p>
|
||
<p className="text-sm font-bold text-[#11280f]">
|
||
Hvert anlegg vises som et eget arbeidskort, slik at du ser innhold, status og handlinger samlet uten sideveis scrolling.
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||
<label className="block min-w-[18rem]">
|
||
<span className="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
|
||
Hopp til golfanlegg
|
||
</span>
|
||
<select
|
||
value={facilityJumpSlug}
|
||
onChange={(e) => setFacilityJumpSlug(e.target.value)}
|
||
className="w-full rounded-2xl border-2 border-gray-200 bg-white px-4 py-3 text-sm font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
|
||
>
|
||
<option value="">Velg anlegg</option>
|
||
{facilityJumpOptions.map((facility) => (
|
||
<option key={facility.slug} value={facility.slug}>
|
||
{facility.city ? `${facility.label} • ${facility.city}` : facility.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<label className="inline-flex items-center gap-3 rounded-2xl bg-white px-4 py-3 text-xs font-black uppercase tracking-widest text-gray-500 shadow-sm">
|
||
<input
|
||
type="checkbox"
|
||
className="h-5 w-5 cursor-pointer accent-[#8bc34a]"
|
||
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
|
||
onChange={handleSelectAll}
|
||
/>
|
||
Velg alle i visningen
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-6 rounded-[1.75rem] border border-[#dbe7cf] bg-white p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Hvordan Skraping Oppfører Seg</p>
|
||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||
<article className="rounded-2xl bg-[#f8fbf4] p-4">
|
||
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Banestatus</p>
|
||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||
Skraping oppdaterer live banestatus direkte. Hvis skraperen ikke finner en trygg status, beholdes eksisterende status som før.
|
||
</p>
|
||
</article>
|
||
<article className="rounded-2xl bg-[#f8fbf4] p-4">
|
||
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Medlemskap</p>
|
||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||
Skraping lager eller erstatter bare et utkast. Live medlemskapsdata endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
|
||
</p>
|
||
</article>
|
||
<article className="rounded-2xl bg-[#f8fbf4] p-4">
|
||
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Greenfee</p>
|
||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||
Skraping lager eller erstatter bare et utkast. Live greenfee endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
|
||
</p>
|
||
</article>
|
||
<article className="rounded-2xl bg-[#f8fbf4] p-4">
|
||
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Golfpakker</p>
|
||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||
Skraping lager eller erstatter bare et utkast. Live golfpakker endres først når du godkjenner i vaskeriet eller redigerer manuelt.
|
||
</p>
|
||
</article>
|
||
<article className="rounded-2xl bg-[#f8fbf4] p-4">
|
||
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">VTG</p>
|
||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||
Skraping lager eller erstatter bare et utkast. Live VTG-data endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
|
||
</p>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-5 pb-4">
|
||
{filteredFacilities.map((f: any, index: number) => {
|
||
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
|
||
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
|
||
const hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_draft).length > 0;
|
||
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
|
||
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'golfpakker' && hasGpDraft) || (activeTab === 'vtg' && hasVtgDraft);
|
||
const accentStyles = [
|
||
'bg-white border-gray-100',
|
||
'bg-[#fbfdf8] border-[#e3edd7]',
|
||
'bg-[#f8fbff] border-[#dbe7f5]',
|
||
];
|
||
const accentStyle = isHighlighted ? 'bg-[#f3f9ea] border-[#b9d88d]' : accentStyles[index % accentStyles.length];
|
||
|
||
return (
|
||
<article
|
||
key={f.id}
|
||
id={`facility-card-${f.slug}`}
|
||
className={`rounded-[1.9rem] border p-5 shadow-sm transition-all scroll-mt-6 md:p-6 ${accentStyle} ${
|
||
selectedFacilities.includes(f.id)
|
||
? 'ring-2 ring-[#8bc34a]/35 shadow-lg'
|
||
: facilityJumpSlug === f.slug
|
||
? 'ring-2 ring-[#11280f]/15 shadow-lg'
|
||
: ''
|
||
}`}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div className="flex items-start gap-4">
|
||
<input
|
||
type="checkbox"
|
||
className="mt-1 h-5 w-5 cursor-pointer accent-[#8bc34a]"
|
||
checked={selectedFacilities.includes(f.id)}
|
||
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
|
||
/>
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="text-2xl font-black tracking-tight text-[#11280f]">{f.name}</h3>
|
||
<span className="rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-gray-400 shadow-sm">ID {f.id}</span>
|
||
{f.is_published === false && (
|
||
<span className="rounded-xl bg-amber-100 px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-amber-800">
|
||
Skjult
|
||
</span>
|
||
)}
|
||
{isHighlighted && (
|
||
<span className="rounded-xl bg-[#8bc34a] px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-white">
|
||
Trenger oppmerksomhet
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3 text-xs font-bold uppercase tracking-[0.18em] text-[#7ca982]">
|
||
<span>{f.city || 'Ukjent sted'}</span>
|
||
{activeTab === 'banestatus' && (
|
||
<span>{f.status_updated_at ? `Sjekket ${new Date(f.status_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri sjekket'}</span>
|
||
)}
|
||
{activeTab === 'medlemskap' && (
|
||
<span>{f.membership_updated_at ? `Vasket ${new Date(f.membership_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
|
||
)}
|
||
{activeTab === 'greenfee' && (
|
||
<span>{f.greenfee_updated_at ? `Vasket ${new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
|
||
)}
|
||
{activeTab === 'golfpakker' && (
|
||
<span>{f.golfpakker_updated_at ? `Vasket ${new Date(f.golfpakker_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
|
||
)}
|
||
{activeTab === 'vtg' && (
|
||
<span>{f.vtg_updated_at ? `Vasket ${new Date(f.vtg_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:min-w-[180px]">
|
||
{activeTab === 'banestatus' && (
|
||
<button onClick={() => openEditModal(f)} className="btn btn-md btn-secondary">
|
||
Innstillinger
|
||
</button>
|
||
)}
|
||
{activeTab === 'medlemskap' && (
|
||
<button onClick={() => openManualOverrideModal(f, 'medlemskap')} className="btn btn-md btn-secondary">
|
||
Overstyr manuelt
|
||
</button>
|
||
)}
|
||
{activeTab === 'medlemskap' && hasMemDraft && (
|
||
<Link href="/admin/medlemskap" className="btn btn-md btn-danger text-center">
|
||
Til vaskeri
|
||
</Link>
|
||
)}
|
||
{activeTab === 'greenfee' && (
|
||
<button onClick={() => openManualOverrideModal(f, 'greenfee')} className="btn btn-md btn-secondary">
|
||
Overstyr manuelt
|
||
</button>
|
||
)}
|
||
{activeTab === 'greenfee' && hasGfDraft && (
|
||
<Link href="/admin/greenfee" className="btn btn-md btn-danger text-center">
|
||
Til vaskeri
|
||
</Link>
|
||
)}
|
||
{activeTab === 'golfpakker' && hasGpDraft && (
|
||
<Link href="/admin/golfpakker" className="btn btn-md btn-danger text-center">
|
||
Til vaskeri
|
||
</Link>
|
||
)}
|
||
{activeTab === 'vtg' && (
|
||
<button onClick={() => openManualOverrideModal(f, 'vtg')} className="btn btn-md btn-secondary">
|
||
Overstyr manuelt
|
||
</button>
|
||
)}
|
||
{activeTab === 'vtg' && hasVtgDraft && (
|
||
<Link href="/admin/vtg" className="btn btn-md btn-danger text-center">
|
||
Til vaskeri
|
||
</Link>
|
||
)}
|
||
<Link href={`/admin/rediger/${f.slug}`} className="btn btn-md btn-ink text-center">
|
||
Rediger alt
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{activeTab === 'banestatus' && (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde og metode</p>
|
||
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||
<div className="space-y-2">
|
||
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} placeholder="Lim inn URL(er)..." emptyLabel="Mangler URL" title="Klikk for å redigere URL" />
|
||
<p className="break-all text-[10px] font-mono text-gray-400">{f.scrape_status_selector || 'Ingen selector lagret'}</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Metode</p>
|
||
<ScrapeMethodSelect facility={f} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Banestatus</p>
|
||
<div className="mt-4 space-y-2">
|
||
{f.course_statuses && f.course_statuses.length > 0 ? f.course_statuses.map((cs: any, idx: number) => {
|
||
const badgeColor = STATUS_BADGE_CLASSES[cs.status || 'ukjent'] || STATUS_BADGE_CLASSES.ukjent;
|
||
return (
|
||
<div key={idx} className="flex items-center justify-between gap-3 rounded-2xl bg-[#f8fbf4] px-4 py-3">
|
||
<span className="truncate text-xs font-black uppercase tracking-[0.18em] text-gray-500">{cs.name}</span>
|
||
<span className={`rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
|
||
</div>
|
||
);
|
||
}) : (
|
||
<p className="text-sm text-gray-500">Ingen baner registrert.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Viktig beskjed</p>
|
||
<div className="mt-3 rounded-2xl bg-[#f8fbf4] p-3">
|
||
<InlineEdit
|
||
facilityId={f.id}
|
||
field="footnote"
|
||
initialValue={f.footnote || ''}
|
||
onSave={handleQuickEdit}
|
||
placeholder="Skriv viktig beskjed / kursiv intro-tekst..."
|
||
emptyLabel="Ingen viktig beskjed"
|
||
title="Klikk for å redigere viktig beskjed"
|
||
inputRows={4}
|
||
editorWidthClassName="max-w-full"
|
||
displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap"
|
||
renderHtml
|
||
/>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'medlemskap' && (
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_220px]">
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
|
||
<div className="mt-4">
|
||
<InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} />
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Aktuelle priser</p>
|
||
<div className="mt-4 space-y-2 text-sm">
|
||
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
|
||
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Standard</span>
|
||
<span className="mt-1 block font-black text-[#11280f]">{f.standard_medlemskap ? `${f.standard_medlemskap},-` : 'Ikke registrert'}</span>
|
||
</div>
|
||
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
|
||
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Rimeligste</span>
|
||
<span className="mt-1 block font-black text-[#11280f]">{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : 'Ikke registrert'}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
|
||
<div className="mt-4 space-y-3">
|
||
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasMemDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
|
||
{hasMemDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'greenfee' && (
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_220px]">
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
|
||
<div className="mt-4">
|
||
<InlineEdit facilityId={f.id} field="greenfee_url" initialValue={f.greenfee_url} onSave={handleQuickEdit} />
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Aktive priser</p>
|
||
<div className="mt-4 space-y-2">
|
||
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
|
||
<div key={i} className="grid gap-2 rounded-2xl bg-[#f8fbf4] px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||
<div>
|
||
<p className="text-xs font-black text-[#11280f]">{g.banenavn || 'Uten banenavn'}</p>
|
||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-gray-400">{g.priskategori || 'Standard'}</p>
|
||
</div>
|
||
<p className="text-xs font-black text-gray-600">V: {g.pris_voksne || '-'} J: {g.pris_junior || '-'}</p>
|
||
</div>
|
||
)) : (
|
||
<p className="text-sm text-gray-500">Ingen priser registrert.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
|
||
<div className="mt-4 space-y-3">
|
||
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasGfDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
|
||
{hasGfDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'vtg' && (
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_220px]">
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
|
||
<div className="mt-4">
|
||
<InlineEdit facilityId={f.id} field="vtg_lenke" initialValue={f.vtg_lenke} onSave={handleQuickEdit} />
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Registrert informasjon</p>
|
||
<div className="mt-4 space-y-3">
|
||
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
|
||
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Pris</span>
|
||
<span className="mt-1 block font-black text-[#8bc34a]">{f.vtg_pris ? `${f.vtg_pris},-` : 'Ikke registrert'}</span>
|
||
</div>
|
||
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
|
||
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Beskrivelse</span>
|
||
<span className="mt-1 block text-sm text-gray-600">{f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}</span>
|
||
</div>
|
||
<div className="inline-flex rounded-xl bg-white px-3 py-2 text-[10px] font-black uppercase tracking-widest text-[#11280f] shadow-sm">
|
||
{f.vtg_datoer && f.vtg_datoer.length > 0 ? `${f.vtg_datoer.length} kursdatoer` : 'Ingen kursdatoer'}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
|
||
<div className="mt-4 space-y-3">
|
||
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasVtgDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
|
||
{hasVtgDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'golfpakker' && (
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)_220px]">
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
|
||
<div className="mt-4">
|
||
<InlineEdit facilityId={f.id} field="golfpakker_url" initialValue={f.golfpakker_url || ''} onSave={handleQuickEdit} placeholder="Lim inn golfpakke-URL..." emptyLabel={f.website_url ? 'Bruker website_url som fallback' : 'Mangler golfpakke-URL'} title="Klikk for å redigere golfpakke-URL" />
|
||
{f.website_url && (
|
||
<p className="mt-2 text-[10px] font-mono text-gray-400 break-all">
|
||
Fallback: {f.website_url}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Registrerte golfpakker</p>
|
||
<div className="mt-4 space-y-2">
|
||
{f.golfpakker && f.golfpakker.length > 0 ? f.golfpakker.map((pakke: any, i: number) => (
|
||
<div key={i} className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
|
||
<p className="text-xs font-black text-[#11280f]">{pakke.navn || 'Uten navn'}</p>
|
||
<p className="mt-1 text-[10px] text-gray-500">{pakke.beskrivelse || 'Ingen beskrivelse registrert.'}</p>
|
||
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#7ca982]">
|
||
{pakke.pris ? `${pakke.pris},-` : 'Pris ikke registrert'}
|
||
</p>
|
||
</div>
|
||
)) : (
|
||
<p className="text-sm text-gray-500">Ingen golfpakker registrert.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
|
||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
|
||
<div className="mt-4 space-y-3">
|
||
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasGpDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
|
||
{hasGpDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="hidden mb-4 items-center justify-between gap-4 text-[10px] font-black uppercase tracking-widest text-gray-400">
|
||
<span>Scroll sidelengs for flere kolonner</span>
|
||
<span className="text-[#7ca982]">Venstre og høyre kant er låst</span>
|
||
</div>
|
||
|
||
<div className="hidden overflow-x-auto overflow-y-visible pb-4 rounded-[1.5rem] border border-gray-100 bg-white">
|
||
<table className="w-max min-w-full text-left border-collapse min-w-[1100px]">
|
||
<thead>
|
||
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100">
|
||
<th className="pb-4 pl-4 w-10 sticky left-0 z-20 bg-white"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} /></th>
|
||
<th className="pb-4 w-12 text-center sticky left-[56px] z-20 bg-white">ID</th>
|
||
<th className="pb-4 pr-6 sticky left-[104px] z-20 bg-white min-w-[220px]">Anlegg</th>
|
||
|
||
{activeTab === 'banestatus' && (
|
||
<>
|
||
<th className="pb-4">Konfigurasjon (URL & Selektor)</th>
|
||
<th className="pb-4">Metode</th>
|
||
<th className="pb-4">Siste Sjekk</th>
|
||
<th className="pb-4">Banestatus</th>
|
||
</>
|
||
)}
|
||
{activeTab === 'medlemskap' && (
|
||
<>
|
||
<th className="pb-4">Medlemskap-side (Klikk for å redigere)</th>
|
||
<th className="pb-4">Nåværende Priser</th>
|
||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
||
<th className="pb-4">Sist Vasket</th>
|
||
</>
|
||
)}
|
||
{activeTab === 'greenfee' && (
|
||
<>
|
||
<th className="pb-4">Greenfee-side (Klikk for å redigere)</th>
|
||
<th className="pb-4">Aktive priser</th>
|
||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
||
<th className="pb-4">Sist Vasket</th>
|
||
</>
|
||
)}
|
||
{activeTab === 'golfpakker' && (
|
||
<>
|
||
<th className="pb-4">Nettside</th>
|
||
<th className="pb-4">Golfpakker</th>
|
||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
||
<th className="pb-4">Sist Vasket</th>
|
||
</>
|
||
)}
|
||
{activeTab === 'vtg' && (
|
||
<>
|
||
<th className="pb-4">VTG-side (Klikk for å redigere)</th>
|
||
<th className="pb-4 w-64">Registrert Informasjon</th>
|
||
<th className="pb-4 text-center">Nytt Utkast?</th>
|
||
<th className="pb-4">Sist Vasket</th>
|
||
</>
|
||
)}
|
||
<th className="pb-4 text-right pr-4 sticky right-0 z-20 bg-white min-w-[150px]">Handling</th>
|
||
</tr>
|
||
</thead>
|
||
|
||
<tbody className="text-sm font-bold text-[#11280f]">
|
||
{filteredFacilities.map((f: any) => {
|
||
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
|
||
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
|
||
const hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_draft).length > 0;
|
||
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
|
||
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'golfpakker' && hasGpDraft) || (activeTab === 'vtg' && hasVtgDraft);
|
||
|
||
return (
|
||
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50'}`}>
|
||
<td className={`py-6 pl-4 w-10 sticky left-0 z-10 ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.includes(f.id)} onChange={(e) => handleSelectOne(f.id, e.target.checked)} /></td>
|
||
<td className={`py-6 text-center text-xs font-mono text-gray-400 sticky left-[56px] z-10 ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>#{f.id}</td>
|
||
<td className={`py-6 pr-6 sticky left-[104px] z-10 min-w-[220px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
|
||
<div className="flex items-center gap-2">
|
||
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
||
{f.is_published === false && (
|
||
<span className="rounded-lg bg-amber-100 px-2 py-1 text-[10px] font-black uppercase tracking-widest text-amber-800">
|
||
Skjult
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
||
</td>
|
||
|
||
{activeTab === 'banestatus' && (
|
||
<>
|
||
<td className="py-6 pr-4">
|
||
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} placeholder="Lim inn URL(er)..." emptyLabel="Mangler URL" title="Klikk for å redigere URL" />
|
||
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div>
|
||
</td>
|
||
<td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td>
|
||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
||
<td className="py-6 pr-4">
|
||
<div className="flex flex-col gap-1">
|
||
<InlineEdit
|
||
facilityId={f.id}
|
||
field="footnote"
|
||
initialValue={f.footnote || ''}
|
||
onSave={handleQuickEdit}
|
||
placeholder="Skriv viktig beskjed / kursiv intro-tekst..."
|
||
emptyLabel="Ingen viktig beskjed"
|
||
title="Klikk for å redigere viktig beskjed"
|
||
inputRows={4}
|
||
editorWidthClassName="max-w-[320px]"
|
||
displayClassName="mb-2 text-[11px] italic leading-5 text-[#11280f] whitespace-pre-wrap"
|
||
renderHtml
|
||
/>
|
||
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
|
||
const badgeColor = STATUS_BADGE_CLASSES[cs.status || 'ukjent'] || STATUS_BADGE_CLASSES.ukjent;
|
||
return (
|
||
<div key={idx} className="flex items-center gap-2">
|
||
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>{cs.name}</span>
|
||
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</td>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'medlemskap' && (
|
||
<>
|
||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} /></td>
|
||
<td className="py-6 pr-4">
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-xs">Standard: <strong>{f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}</strong></span>
|
||
<span className="text-xs text-gray-500">Rimeligste: <strong>{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}</strong></span>
|
||
</div>
|
||
</td>
|
||
<td className="py-6 pr-4 text-center">{hasMemDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'greenfee' && (
|
||
<>
|
||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="greenfee_url" initialValue={f.greenfee_url} onSave={handleQuickEdit} /></td>
|
||
<td className="py-6 pr-4">
|
||
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2">
|
||
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
|
||
<div key={i} className="flex justify-between border-b border-gray-50 pb-1">
|
||
<span className="truncate max-w-[120px]">{g.banenavn}</span>
|
||
<span className="font-bold text-[#11280f]">V: {g.pris_voksne} J: {g.pris_junior}</span>
|
||
</div>
|
||
)) : 'Ingen priser'}
|
||
</div>
|
||
</td>
|
||
<td className="py-6 pr-4 text-center">{hasGfDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.greenfee_updated_at ? new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'vtg' && (
|
||
<>
|
||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="vtg_lenke" initialValue={f.vtg_lenke} onSave={handleQuickEdit} /></td>
|
||
<td className="py-6 pr-4 max-w-[250px]">
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-xs">Pris: <strong className="text-[#8bc34a]">{f.vtg_pris ? `${f.vtg_pris},-` : '---'}</strong></span>
|
||
<span className="text-[10px] text-gray-500 line-clamp-2" title={f.vtg_beskrivelse}>{f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}</span>
|
||
<span className="text-[10px] font-bold text-[#11280f] mt-1 bg-gray-50 px-2 py-1 rounded-md inline-block w-max">
|
||
{f.vtg_datoer && f.vtg_datoer.length > 0 ? `📅 ${f.vtg_datoer.length} kursdato(er)` : '📅 Ingen datoer registrert'}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="py-6 pr-4 text-center">{hasVtgDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
||
</>
|
||
)}
|
||
|
||
{activeTab === 'golfpakker' && (
|
||
<>
|
||
<td className="py-6 pr-4">
|
||
<InlineEdit facilityId={f.id} field="golfpakker_url" initialValue={f.golfpakker_url || ''} onSave={handleQuickEdit} placeholder="Lim inn golfpakke-URL..." emptyLabel={f.website_url ? 'Bruker website_url som fallback' : 'Mangler golfpakke-URL'} title="Klikk for å redigere golfpakke-URL" />
|
||
{f.website_url && <div className="mt-1 text-[9px] font-mono text-gray-300 truncate max-w-[180px]" title={f.website_url}>{f.website_url}</div>}
|
||
</td>
|
||
<td className="py-6 pr-4">
|
||
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2">
|
||
{f.golfpakker && f.golfpakker.length > 0 ? f.golfpakker.map((pakke: any, i: number) => (
|
||
<div key={i} className="border-b border-gray-50 pb-1">
|
||
<span className="font-bold text-[#11280f]">{pakke.navn || 'Uten navn'}</span>
|
||
<span className="ml-2">{pakke.pris ? `${pakke.pris},-` : 'Uten pris'}</span>
|
||
</div>
|
||
)) : 'Ingen golfpakker'}
|
||
</div>
|
||
</td>
|
||
<td className="py-6 pr-4 text-center">{hasGpDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
|
||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.golfpakker_updated_at ? new Date(f.golfpakker_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
||
</>
|
||
)}
|
||
|
||
<td className={`py-6 text-right pr-4 sticky right-0 z-10 min-w-[150px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
|
||
<div className="flex flex-col gap-2 items-end">
|
||
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="btn btn-sm btn-secondary whitespace-nowrap">Innstillinger</button>}
|
||
{activeTab === 'medlemskap' && <button onClick={() => openManualOverrideModal(f, 'medlemskap')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
|
||
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="btn btn-sm btn-danger whitespace-nowrap">Gå til Vaskeri</Link>}
|
||
{activeTab === 'greenfee' && <button onClick={() => openManualOverrideModal(f, 'greenfee')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
|
||
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="btn btn-sm btn-danger whitespace-nowrap">Gå til Vaskeri</Link>}
|
||
{activeTab === 'golfpakker' && hasGpDraft && <Link href="/admin/golfpakker" className="btn btn-sm btn-danger whitespace-nowrap">Gå til Vaskeri</Link>}
|
||
{activeTab === 'vtg' && <button onClick={() => openManualOverrideModal(f, 'vtg')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
|
||
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="btn btn-sm btn-danger whitespace-nowrap">Gå til Vaskeri</Link>}
|
||
|
||
<Link href={`/admin/rediger/${f.slug}`} className="btn btn-sm btn-ink whitespace-nowrap text-center">Rediger alt</Link>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{showBackToTop && (
|
||
<button
|
||
type="button"
|
||
onClick={() => mainContentRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
||
className="btn btn-ink fixed bottom-4 right-4 z-[95] flex h-12 items-center justify-center gap-2 rounded-full px-4 shadow-2xl md:bottom-8 md:right-8 md:h-14"
|
||
aria-label="Tilbake til toppen"
|
||
title="Tilbake til toppen"
|
||
>
|
||
<span className="text-base leading-none">↑</span>
|
||
<span className="hidden text-[10px] font-black uppercase tracking-[0.18em] sm:inline">Toppen</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|