2026-03-02 09:05:18 +01:00
"use client" ;
/ * *
2026-04-10 09:52:34 +02:00
* TEE OFF ADMIN DASHBOARD v4 . 0 - KONTROLLPANEL
2026-03-02 09:05:18 +01:00
* /
2026-04-10 18:37:33 +02:00
import { useEffect , useMemo , useRef , useState } from 'react' ;
2026-03-02 09:05:18 +01:00
import { API_URL } from "@/config/constants" ;
2026-03-05 09:25:15 +01:00
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect" ;
2026-03-07 09:46:33 +01:00
import Link from 'next/link' ;
2026-03-02 09:05:18 +01:00
2026-04-10 18:37:33 +02:00
type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'vtg' ;
type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed' ;
type ScrapeJob = {
id : number ;
job_type : AdminTab ;
status : ScrapeJobStatus ;
facility_ids : number [ ] ;
total_facilities : number ;
error_message? : string | null ;
result_summary? : Record < string , number | string | null > ;
created_at? : string | null ;
started_at? : string | null ;
finished_at? : string | null ;
} ;
const JOB_LABELS : Record < AdminTab , string > = {
banestatus : 'Banestatus' ,
medlemskap : 'Medlemskap' ,
greenfee : 'Greenfee' ,
vtg : 'VTG' ,
} ;
const JOB_STATUS_LABELS : Record < ScrapeJobStatus , string > = {
pending : 'I kø' ,
running : 'Kjører' ,
completed : 'Fullført' ,
failed : 'Feilet' ,
} ;
2026-03-12 13:39:10 +01:00
const InlineEdit = ( { facilityId , field , initialValue , onSave } : { facilityId : number , field : string , initialValue : string , onSave : ( id : number , field : string , val : string ) = > void } ) = > {
const [ isEditing , setIsEditing ] = useState ( false ) ;
const [ value , setValue ] = useState ( initialValue || '' ) ;
const handleSave = ( ) = > {
setIsEditing ( false ) ;
if ( value !== initialValue ) {
onSave ( facilityId , field , value ) ;
}
} ;
if ( isEditing ) {
return (
< div className = "flex flex-col gap-1 w-full max-w-[200px] animate-fade-in" >
2026-03-12 14:51:17 +01:00
< textarea autoFocus rows = { 2 } className = "border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white" value = { value } onChange = { e = > setValue ( e . target . value ) } onKeyDown = { e = > { if ( e . key === 'Enter' && ! e . shiftKey ) { e . preventDefault ( ) ; handleSave ( ) ; } } } placeholder = "Lim inn URL(er)..." / >
2026-03-12 13:39:10 +01:00
< div className = "flex gap-1" >
< button onClick = { handleSave } className = "bg-[#8bc34a] text-white px-3 py-1.5 rounded-md text-[10px] font-black uppercase flex-1 shadow-sm hover:bg-[#7ca982]" > Lagre < / button >
< button onClick = { ( ) = > { setIsEditing ( false ) ; setValue ( initialValue || '' ) ; } } className = "bg-gray-200 text-gray-600 px-3 py-1.5 rounded-md text-[10px] font-black uppercase hover:bg-gray-300" > Avbryt < / button >
< / div >
< / div >
) ;
}
return (
< div className = "group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick = { ( ) = > setIsEditing ( true ) } title = "Klikk for å redigere URL" >
< div className = "text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2" >
{ initialValue ? initialValue : < span className = "text-red-400 italic" > Mangler URL < / span > }
< / div >
< span className = "opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity" > ✏ ️ < / span >
< / div >
) ;
} ;
2026-03-02 09:05:18 +01:00
export default function AdminDashboard() {
2026-03-05 05:18:03 +01:00
const [ facilities , setFacilities ] = useState < any [ ] > ( [ ] ) ;
2026-03-02 09:05:18 +01:00
const [ loading , setLoading ] = useState ( true ) ;
2026-03-05 05:18:03 +01:00
const [ selectedFacilities , setSelectedFacilities ] = useState < number [ ] > ( [ ] ) ;
2026-04-10 18:37:33 +02:00
const [ scrapeJobs , setScrapeJobs ] = useState < ScrapeJob [ ] > ( [ ] ) ;
const [ isQueueing , setIsQueueing ] = useState ( false ) ;
2026-03-05 09:25:15 +01:00
const [ isSidebarCollapsed , setIsSidebarCollapsed ] = useState ( false ) ;
const [ editingFacility , setEditingFacility ] = useState < any | null > ( null ) ;
2026-04-10 18:37:33 +02:00
const [ activeTab , setActiveTab ] = useState < AdminTab > ( 'banestatus' ) ;
2026-03-06 09:39:32 +01:00
const [ statusFilter , setStatusFilter ] = useState ( 'alle' ) ;
2026-03-12 13:39:10 +01:00
const [ editForm , setEditForm ] = useState ( { scrape_status_url : '' , scrape_status_selector : '' , scrape_method : '' , ai_instruction : '' , courses : [ ] as any [ ] } ) ;
2026-03-05 09:25:15 +01:00
const [ isSaving , setIsSaving ] = useState ( false ) ;
2026-04-10 18:37:33 +02:00
const latestJobStateRef = useRef < string | null > ( null ) ;
2026-03-05 09:25:15 +01:00
2026-03-05 05:18:03 +01:00
const fetchFacilities = ( ) = > {
2026-03-02 09:05:18 +01:00
fetch ( ` ${ API_URL } /facilities ` )
. then ( res = > res . json ( ) )
. then ( data = > {
setFacilities ( Array . isArray ( data ) ? data : [ ] ) ;
setLoading ( false ) ;
} )
. catch ( ( ) = > setLoading ( false ) ) ;
2026-03-05 05:18:03 +01:00
} ;
2026-04-10 18:37:33 +02:00
const fetchScrapeJobs = ( tab : AdminTab = activeTab ) = > {
fetch ( ` ${ API_URL } /admin/scrape-jobs?job_type= ${ tab } &limit=5 ` )
. then ( res = > res . json ( ) )
. then ( data = > {
setScrapeJobs ( Array . isArray ( data ) ? data : [ ] ) ;
} )
. catch ( ( ) = > setScrapeJobs ( [ ] ) ) ;
} ;
const activeJob = useMemo (
( ) = > scrapeJobs . find ( job = > job . status === 'pending' || job . status === 'running' ) || null ,
[ scrapeJobs ]
) ;
const latestJob = scrapeJobs [ 0 ] || null ;
const isScraping = ! ! activeJob ;
2026-03-02 09:05:18 +01:00
2026-03-05 05:18:03 +01:00
useEffect ( ( ) = > {
2026-04-10 18:37:33 +02:00
fetchFacilities ( ) ;
fetchScrapeJobs ( 'banestatus' ) ;
} , [ ] ) ;
useEffect ( ( ) = > {
const interval = setInterval ( ( ) = > fetchScrapeJobs ( activeTab ) , 5000 ) ;
return ( ) = > clearInterval ( interval ) ;
} , [ activeTab ] ) ;
useEffect ( ( ) = > {
setSelectedFacilities ( [ ] ) ;
fetchScrapeJobs ( activeTab ) ;
} , [ activeTab ] ) ;
useEffect ( ( ) = > {
if ( ! isScraping ) return ;
const interval = setInterval ( ( ) = > fetchFacilities ( ) , 10000 ) ;
2026-03-05 05:18:03 +01:00
return ( ) = > clearInterval ( interval ) ;
} , [ isScraping ] ) ;
2026-04-10 18:37:33 +02:00
useEffect ( ( ) = > {
const currentState = latestJob ? ` ${ latestJob . id } : ${ latestJob . status } ` : null ;
const previousState = latestJobStateRef . current ;
latestJobStateRef . current = currentState ;
if (
previousState &&
previousState !== currentState &&
latestJob &&
( latestJob . status === 'completed' || latestJob . status === 'failed' )
) {
fetchFacilities ( ) ;
fetchScrapeJobs ( activeTab ) ;
}
} , [ activeTab , latestJob ] ) ;
2026-03-12 13:39:10 +01:00
2026-03-06 09:39:32 +01:00
const filteredFacilities = useMemo ( ( ) = > {
if ( statusFilter === 'alle' ) return facilities ;
return facilities . map ( facility = > {
if ( ! facility . course_statuses ) return facility ;
const filteredCourses = facility . course_statuses . filter ( ( cs : any ) = > {
const s = cs . status || 'ukjent' ;
2026-03-12 13:39:10 +01:00
if ( statusFilter === 'aapne' ) return s === 'aapen' ;
if ( statusFilter === 'ikke_stengt' ) return [ 'aapen' , 'aapen_med_vintergreener' , 'aapner_snart' ] . includes ( s ) ;
if ( statusFilter === 'stengt' ) return s === 'stengt' || s === 'nedlagt' ;
if ( statusFilter === 'ukjent_feil' ) return s === 'ukjent' || s === 'NOT_FOUND' ;
2026-03-06 09:39:32 +01:00
return true ;
} ) ;
return { . . . facility , course_statuses : filteredCourses } ;
} ) . filter ( facility = > facility . course_statuses && facility . course_statuses . length > 0 ) ;
} , [ facilities , statusFilter ] ) ;
2026-04-10 18:37:33 +02:00
const latestJobSummary = useMemo ( ( ) = > {
if ( ! latestJob ? . result_summary ) return '' ;
return Object . entries ( latestJob . result_summary )
. filter ( ( [ , value ] ) = > value !== null && value !== undefined && value !== '' )
. map ( ( [ key , value ] ) = > ` ${ key . replaceAll ( '_' , ' ' ) } : ${ value } ` )
. join ( ' • ' ) ;
} , [ latestJob ] ) ;
2026-03-05 05:18:03 +01:00
const handleSelectAll = ( e : React.ChangeEvent < HTMLInputElement > ) = > {
2026-03-12 13:39:10 +01:00
if ( e . target . checked ) setSelectedFacilities ( filteredFacilities . map ( f = > f . id ) ) ;
else setSelectedFacilities ( [ ] ) ;
2026-03-05 05:18:03 +01:00
} ;
const handleSelectOne = ( id : number , checked : boolean ) = > {
2026-03-12 13:39:10 +01:00
if ( checked ) setSelectedFacilities ( [ . . . selectedFacilities , id ] ) ;
else setSelectedFacilities ( selectedFacilities . filter ( facilityId = > facilityId !== id ) ) ;
2026-03-05 05:18:03 +01:00
} ;
2026-03-12 13:39:10 +01:00
const handleQuickEdit = async ( id : number , field : string , value : string ) = > {
setFacilities ( facilities . map ( f = > f . id === id ? { . . . f , [ field ] : value } : f ) ) ;
try {
const res = await fetch ( ` ${ 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." ) ;
2026-03-12 14:51:17 +01:00
fetchFacilities ( ) ;
2026-03-05 09:25:15 +01:00
}
2026-03-12 13:39:10 +01:00
} ;
2026-03-05 09:25:15 +01:00
2026-03-12 13:39:10 +01:00
const handleRunScrapers = async ( ) = > {
2026-04-10 18:37:33 +02:00
if ( selectedFacilities . length === 0 ) return ;
setIsQueueing ( true ) ;
2026-03-12 14:51:17 +01:00
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
'/admin/run-vtg-scraper' ;
2026-03-05 05:18:03 +01:00
try {
2026-03-12 13:39:10 +01:00
const response = await fetch ( ` ${ API_URL } ${ endpoint } ` , {
2026-03-05 05:18:03 +01:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( { facility_ids : selectedFacilities } )
} ) ;
2026-04-10 18:37:33 +02:00
const data = await response . json ( ) ;
if ( ! response . ok ) throw new Error ( data . detail || "Kunne ikke starte skraping" ) ;
setSelectedFacilities ( [ ] ) ;
fetchScrapeJobs ( activeTab ) ;
2026-03-05 05:18:03 +01:00
} catch ( error ) {
2026-03-12 13:39:10 +01:00
alert ( ` Feil ved start av ${ activeTab } -skraperen. ` ) ;
2026-04-10 18:37:33 +02:00
} finally {
setIsQueueing ( false ) ;
2026-03-05 05:18:03 +01:00
}
} ;
2026-03-05 09:25:15 +01:00
const openEditModal = ( facility : any ) = > {
setEditingFacility ( facility ) ;
setEditForm ( {
scrape_status_url : facility.scrape_status_url || '' ,
scrape_status_selector : facility.scrape_status_selector || '' ,
scrape_method : facility.scrape_method || 'css_selector' ,
ai_instruction : facility.ai_instruction || '' ,
courses : facility.course_statuses ? facility . course_statuses . map ( ( c : any ) = > ( { id : c.id , name : c.name , status : c.status } ) ) : [ ]
} ) ;
} ;
const handleSaveEdit = async ( ) = > {
setIsSaving ( true ) ;
try {
const response = await fetch ( ` ${ 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." ) ;
2026-03-12 14:51:17 +01:00
} finally { setIsSaving ( false ) ; }
2026-03-05 09:25:15 +01:00
} ;
2026-04-10 09:52:34 +02:00
if ( loading ) return < div className = "p-20 text-center font-black animate-pulse" > LASTER KONTROLLPANEL . . . < / div > ;
2026-03-02 09:05:18 +01:00
return (
2026-03-05 09:25:15 +01:00
< div className = "flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden" >
2026-03-12 14:51:17 +01:00
{ /* REDIGER-MODAL FOR BANESTATUS */ }
2026-03-05 09:25:15 +01:00
{ editingFacility && (
< div className = "fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" >
< div className = "bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]" >
< div className = "bg-[#11280f] text-white p-6 shrink-0" >
2026-03-08 10:26:56 +01:00
< h3 className = "text-xl font-black uppercase tracking-widest" > Skrape - innstillinger < / h3 >
2026-03-05 09:25:15 +01:00
< p className = "text-sm text-[#7ca982]" > { editingFacility . name } < / p >
< / div >
< div className = "p-8 space-y-6 overflow-y-auto flex-grow" >
< div >
2026-03-12 13:39:10 +01:00
< label className = "block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2" > Scrape URL ( Banestatus ) < / label >
< input type = "text" value = { editForm . scrape_status_url } onChange = { ( e ) = > setEditForm ( { . . . editForm , scrape_status_url : e.target.value } ) } className = "w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder = "f.eks. https://golfklubb.no/banestatus" / >
2026-03-05 09:25:15 +01:00
< / div >
< div >
< label className = "block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2" > Skrapemetode < / label >
2026-03-12 13:39:10 +01:00
< select value = { editForm . scrape_method } onChange = { ( e ) = > setEditForm ( { . . . editForm , scrape_method : e.target.value } ) } className = "w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" >
2026-03-05 09:25:15 +01:00
< option value = "css_selector" > Standard ( CSS ) < / option >
< option value = "llm_parse" > ✨ Gemini AI ( LLM ) < / option >
< option value = "iframe_golfbox" > Golfbox iframe < / option >
< option value = "click_then_css" > Auto - klikk + CSS < / option >
< option value = "manual" > 🚨 Manuell ( Ikke skrap ) < / option >
< / select >
2026-03-06 09:39:32 +01:00
< / div >
2026-03-05 09:25:15 +01:00
{ editForm . scrape_method === 'llm_parse' && (
< div className = "animate-fade-in" >
< label className = "block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2" > ✨ AI - Hviskeren ( Instruks til Gemini ) < / label >
2026-03-12 13:39:10 +01:00
< textarea value = { editForm . ai_instruction || '' } onChange = { ( e ) = > setEditForm ( { . . . editForm , ai_instruction : e.target.value } ) } className = "w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder = "F.eks: Ignorer info om korthullsbanen. Banen er åpen." rows = { 3 } / >
2026-03-05 09:25:15 +01:00
< / div >
) }
{ editForm . scrape_method === 'manual' && (
< div className = "bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in" >
< label className = "block text-xs font-black text-red-500 uppercase tracking-widest mb-4" > 🚨 Sett Status Manuelt < / label >
< div className = "space-y-4" >
{ editForm . courses . map ( ( course : any , idx : number ) = > (
< div key = { course . id } className = "flex justify-between items-center bg-white p-3 rounded-lg shadow-sm" >
< span className = "text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title = { course . name } > { course . name } < / span >
2026-03-12 13:39:10 +01:00
< select value = { course . status || 'ukjent' } onChange = { ( e ) = > { const newCourses = [ . . . editForm . courses ] ; newCourses [ idx ] . status = e . target . value ; setEditForm ( { . . . editForm , courses : newCourses } ) ; } } className = "border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0" >
2026-03-05 09:25:15 +01:00
< option value = "aapen" > 🟢 Å pen < / option >
< option value = "aapen_med_vintergreener" > 🟡 Vintergreener < / option >
< option value = "aapner_snart" > 🟡 Å pner Snart < / option >
< option value = "stengt" > 🔴 Stengt < / option >
< option value = "stenger_snart" > 🔴 Stenger Snart < / option >
< option value = "under_utvikling" > 🔨 Under Utvikling < / option >
< option value = "nedlagt" > ⚫ Nedlagt < / option >
< option value = "ukjent" > ⚪ Ukjent < / option >
< / select >
< / div >
) ) }
< / div >
< / div >
2026-03-06 09:39:32 +01:00
) }
2026-03-05 09:25:15 +01:00
{ ( editForm . scrape_method === 'css_selector' || editForm . scrape_method === 'click_then_css' || editForm . scrape_method === 'iframe_golfbox' ) && (
< div >
< label className = "block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2" > CSS Selector < / label >
2026-03-12 13:39:10 +01:00
< input type = "text" value = { editForm . scrape_status_selector } onChange = { ( e ) = > setEditForm ( { . . . editForm , scrape_status_selector : e.target.value } ) } className = "w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono" placeholder = "f.eks. .status-text" / >
2026-03-05 09:25:15 +01:00
< / div >
) }
< / div >
< div className = "bg-gray-50 p-6 flex justify-end gap-4 shrink-0" >
< button onClick = { ( ) = > setEditingFacility ( null ) } className = "px-6 py-3 rounded-xl text-xs font-bold uppercase tracking-widest text-gray-500 hover:bg-gray-200 transition-colors" > Avbryt < / button >
< button onClick = { handleSaveEdit } disabled = { isSaving } className = "bg-[#8bc34a] text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50" >
{ isSaving ? 'Lagrer...' : 'Lagre endringer' }
< / button >
< / div >
< / div >
< / div >
) }
2026-03-06 09:39:32 +01:00
{ /* SIDEBAR */ }
2026-03-05 09:25:15 +01:00
< aside className = { ` bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${ isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8' } hidden md:flex ` } >
< div className = { ` flex items-center mb-10 ${ isSidebarCollapsed ? 'justify-center' : 'justify-between' } ` } >
{ ! isSidebarCollapsed && < h1 className = "text-2xl font-black uppercase tracking-tighter" > TeeOff < / h1 > }
2026-03-12 13:39:10 +01:00
< button onClick = { ( ) = > setIsSidebarCollapsed ( ! isSidebarCollapsed ) } className = "text-2xl hover:text-[#8bc34a] transition-colors" title = "Skjul/Vis meny" > ☰ < / button >
2026-03-05 09:25:15 +01:00
< / div >
2026-03-02 09:05:18 +01:00
< nav className = "space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow" >
2026-04-10 09:52:34 +02:00
< Link href = "/admin" className = { ` block hover:text-white cursor-pointer py-1 transition-colors ${ isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white' } ` } title = "Kontrollpanel" >
{ isSidebarCollapsed ? 'KP' : 'Kontrollpanel' }
2026-03-08 10:26:56 +01:00
< / Link >
2026-03-12 14:51:17 +01:00
< div className = "space-y-2 mt-4" >
2026-04-10 09:52:34 +02:00
< div className = "text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50" > Datavask < / div >
2026-03-12 14:51:17 +01:00
< Link href = "/admin/medlemskap" className = { ` block hover:text-white cursor-pointer py-1 transition-colors ${ isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent' } ` } title = "Medlemskap" >
{ isSidebarCollapsed ? 'M' : 'Medlemskap' }
< / Link >
< Link href = "/admin/greenfee" className = { ` block hover:text-white cursor-pointer py-1 transition-colors ${ isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent' } ` } title = "Greenfee" >
{ isSidebarCollapsed ? 'G' : 'Greenfee' }
< / Link >
< Link href = "/admin/vtg" className = { ` block hover:text-white cursor-pointer py-1 transition-colors ${ isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent' } ` } title = "Veien til Golf (VTG)" >
{ isSidebarCollapsed ? 'V' : 'VTG' }
< / Link >
< / div >
2026-03-02 09:05:18 +01:00
< / nav >
2026-03-05 09:25:15 +01:00
< div className = { ` mt-auto pt-8 border-t border-white/10 ${ isSidebarCollapsed ? 'text-center' : '' } ` } >
< button onClick = { ( ) = > window . location . href = '/' } 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 >
2026-03-02 09:05:18 +01:00
< / div >
< / aside >
2026-03-06 09:39:32 +01:00
{ /* HOVEDINNHOLD */ }
2026-03-05 09:25:15 +01:00
< main className = "flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto" >
< div className = "bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white" >
2026-03-12 13:39:10 +01:00
< header className = "flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8" >
2026-03-02 09:05:18 +01:00
< div >
2026-04-10 09:52:34 +02:00
< h2 className = "text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2" > Kontrollpanel < / h2 >
< p className = "text-xs font-bold text-gray-400 uppercase tracking-widest" > Oversikt over { filteredFacilities . length } anlegg < / p >
2026-03-02 09:05:18 +01:00
< / div >
2026-03-12 13:39:10 +01:00
2026-03-05 05:18:03 +01:00
< button
onClick = { handleRunScrapers }
2026-04-10 18:37:33 +02:00
disabled = { selectedFacilities . length === 0 || isQueueing }
2026-03-05 09:25:15 +01:00
className = { ` text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
2026-04-10 18:37:33 +02:00
$ { isQueueing ? 'bg-yellow-500 animate-pulse' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed' } ` }
2026-03-05 05:18:03 +01:00
>
2026-04-10 18:37:33 +02:00
{ isQueueing ? 'Legger i kø...' : isScraping ? ` Legg ${ activeTab } -skraping i kø ( ${ selectedFacilities . length } ) ` : ` Kjør ${ activeTab } -skrapere ( ${ selectedFacilities . length } ) ` }
2026-03-05 05:18:03 +01:00
< / button >
2026-03-02 09:05:18 +01:00
< / header >
2026-03-06 09:39:32 +01:00
2026-04-10 18:37:33 +02:00
{ latestJob && latestJob . job_type === activeTab && (
< div className = { ` mb-8 rounded-[1.75rem] border p-5 md:p-6 animate-fade-in ${
latestJob . status === 'failed'
? 'bg-red-50 border-red-100'
: latestJob . status === 'completed'
? 'bg-[#f1f7ed] border-[#d8e8c8]'
: 'bg-amber-50 border-amber-100'
} ` }>
< div className = "flex flex-col md:flex-row md:items-start md:justify-between gap-4" >
< div className = "space-y-2" >
< p className = "text-[10px] font-black uppercase tracking-[0.2em] text-gray-500" >
{ JOB_LABELS [ activeTab ] } jobb # { latestJob . id }
< / p >
< div className = "flex flex-wrap items-center gap-3" >
< span className = { ` inline-flex px-3 py-1 rounded-xl text-[10px] font-black uppercase tracking-widest ${
latestJob . status === 'failed'
? 'bg-red-100 text-red-700'
: latestJob . status === 'completed'
? 'bg-[#d8e8c8] text-[#11280f]'
: 'bg-amber-100 text-amber-700 animate-pulse'
} ` }>
{ JOB_STATUS_LABELS [ latestJob . status ] }
< / span >
< span className = "text-xs font-bold text-[#11280f]" >
{ latestJob . total_facilities } anlegg
< / span >
{ latestJob . created_at && (
< span className = "text-xs text-gray-500" >
Opprettet { new Date ( latestJob . created_at ) . toLocaleString ( 'nb-NO' ) }
< / span >
) }
< / 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 >
< button
onClick = { ( ) = > fetchScrapeJobs ( activeTab ) }
className = "px-4 py-2 rounded-xl bg-white text-[10px] font-black uppercase tracking-widest text-gray-500 border border-gray-200 hover:border-[#8bc34a] hover:text-[#11280f] transition-colors"
>
Oppdater status
< / button >
< / div >
< / div >
) }
2026-04-10 09:52:34 +02:00
{ /* VELDIG SYNLIGE FANER */ }
2026-03-12 14:51:17 +01:00
< div className = "flex gap-2 mb-8 border-b-2 border-gray-100 pb-0 overflow-x-auto hide-scrollbar" >
2026-04-10 09:52:34 +02:00
< button onClick = { ( ) = > setActiveTab ( 'banestatus' ) } className = { ` px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${ activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200' } ` } > Banestatus < / button >
< button onClick = { ( ) = > setActiveTab ( 'medlemskap' ) } className = { ` px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${ activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200' } ` } > Medlemskap < / button >
< button onClick = { ( ) = > setActiveTab ( 'greenfee' ) } className = { ` px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${ activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200' } ` } > Greenfee < / button >
< button onClick = { ( ) = > setActiveTab ( 'vtg' ) } className = { ` px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${ activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200' } ` } > VTG - Kurs < / button >
2026-03-06 09:39:32 +01:00
< / div >
2026-03-12 13:39:10 +01:00
{ activeTab === 'banestatus' && (
< div className = "flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8 animate-fade-in" >
< label htmlFor = "statusFilter" className = "text-xs font-bold text-gray-500 uppercase tracking-widest" > Filtrer 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_stengt" > 🟡 Ikke stengt ( Å pne / Vintergreen / Snart ) < / option >
< option value = "stengt" > 🔴 Kun stengte baner < / option >
< option value = "ukjent_feil" > ⚪ Ukjent / Skrapefeil < / option >
< / select >
< / div >
) }
2026-03-02 09:05:18 +01:00
2026-03-05 09:25:15 +01:00
< div className = "overflow-x-auto pb-4" >
2026-03-12 14:51:17 +01:00
< table className = "w-full text-left border-collapse min-w-[900px]" >
2026-03-06 09:39:32 +01:00
< thead >
2026-04-10 09:52:34 +02:00
< tr className = "text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100" >
2026-03-12 14:51:17 +01:00
< th className = "pb-4 pl-4 w-10" > < input type = "checkbox" className = "w-4 h-4 cursor-pointer accent-[#8bc34a]" checked = { selectedFacilities . length === filteredFacilities . length && filteredFacilities . length > 0 } onChange = { handleSelectAll } / > < / th >
2026-03-08 10:26:56 +01:00
< th className = "pb-4 w-12 text-center" > ID < / th >
2026-03-05 09:25:15 +01:00
< th className = "pb-4 pr-6" > Anlegg < / th >
2026-03-12 13:39:10 +01:00
2026-03-12 14:51:17 +01:00
{ activeTab === 'banestatus' && (
2026-03-12 13:39:10 +01:00
< >
< th className = "pb-4" > Konfigurasjon ( URL & Selektor ) < / th >
< th className = "pb-4" > Metode < / th >
< th className = "pb-4" > Siste Sjekk < / th >
< th className = "pb-4" > Banestatus < / th >
< / >
2026-03-12 14:51:17 +01:00
) }
{ activeTab === 'medlemskap' && (
2026-03-12 13:39:10 +01:00
< >
2026-04-10 09:52:34 +02:00
< th className = "pb-4" > Medlemskap - side ( Klikk for å redigere ) < / th >
2026-03-12 13:39:10 +01:00
< th className = "pb-4" > Nåværende Priser < / th >
2026-04-10 09:52:34 +02:00
< th className = "pb-4 text-center" > Nytt Utkast ? < / th >
2026-03-12 14:51:17 +01:00
< th className = "pb-4" > Sist Vasket < / th >
< / >
) }
{ activeTab === 'greenfee' && (
< >
< th className = "pb-4" > Greenfee - side ( Klikk for å redigere ) < / th >
< th className = "pb-4" > Aktive priser < / th >
2026-04-10 09:52:34 +02:00
< th className = "pb-4 text-center" > Nytt Utkast ? < / th >
2026-03-12 14:51:17 +01:00
< th className = "pb-4" > Sist Vasket < / th >
< / >
) }
{ activeTab === 'vtg' && (
< >
< th className = "pb-4" > VTG - side ( Klikk for å redigere ) < / th >
2026-04-10 09:52:34 +02:00
< th className = "pb-4 w-64" > Registrert Informasjon < / th >
< th className = "pb-4 text-center" > Nytt Utkast ? < / th >
2026-03-12 13:39:10 +01:00
< th className = "pb-4" > Sist Vasket < / th >
< / >
) }
2026-03-05 09:25:15 +01:00
< th className = "pb-4 text-right pr-4" > Handling < / th >
2026-03-02 09:05:18 +01:00
< / tr >
< / thead >
2026-03-12 13:39:10 +01:00
2026-03-02 09:05:18 +01:00
< tbody className = "text-sm font-bold text-[#11280f]" >
2026-03-12 13:39:10 +01:00
{ filteredFacilities . map ( ( f : any ) = > {
2026-03-12 14:51:17 +01:00
const hasMemDraft = f . membership_draft && Object . keys ( f . membership_draft ) . length > 0 ;
const hasGfDraft = f . greenfee_draft && Object . keys ( f . greenfee_draft ) . length > 0 ;
const hasVtgDraft = f . vtg_draft && Object . keys ( f . vtg_draft ) . length > 0 ;
const isHighlighted = ( activeTab === 'medlemskap' && hasMemDraft ) || ( activeTab === 'greenfee' && hasGfDraft ) || ( activeTab === 'vtg' && hasVtgDraft ) ;
2026-03-12 13:39:10 +01:00
return (
2026-04-10 09:52:34 +02:00
< tr key = { f . id } className = { ` border-b border-gray-50 group transition-colors ${ isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50' } ` } >
2026-03-12 14:51:17 +01:00
< td className = "py-6 pl-4 w-10" > < 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 >
2026-03-12 13:39:10 +01:00
< td className = "py-6 text-center text-xs font-mono text-gray-400" > # { f . id } < / td >
< td className = "py-6 pr-6" >
< div className = "font-black text-base md:text-lg whitespace-nowrap" > { f . name } < / div >
< div className = "text-[10px] text-[#7ca982] uppercase tracking-widest" > { f . city } < / div >
< / td >
2026-03-12 14:51:17 +01:00
{ activeTab === 'banestatus' && (
2026-03-12 13:39:10 +01:00
< >
< td className = "py-6 pr-4" >
< InlineEdit facilityId = { f . id } field = "scrape_status_url" initialValue = { f . scrape_status_url } onSave = { handleQuickEdit } / >
< div className = "text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title = { f . scrape_status_selector } > { f . scrape_status_selector } < / div >
< / td >
< td className = "py-6 pr-4" > < ScrapeMethodSelect facility = { f } / > < / td >
2026-03-12 14:51:17 +01:00
< td className = "py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap" > { f . status_updated_at ? new Date ( f . status_updated_at ) . toLocaleDateString ( 'nb-NO' ) : 'Aldri' } < / td >
2026-03-12 13:39:10 +01:00
< td className = "py-6 pr-4" >
< div className = "flex flex-col gap-1" >
{ f . course_statuses && f . course_statuses . map ( ( cs : any , idx : number ) = > {
let badgeColor = "bg-gray-100 text-gray-500" ;
if ( cs . status === "aapen" ) badgeColor = "bg-green-100 text-green-700" ;
if ( cs . status === "stengt" || cs . status === "nedlagt" ) badgeColor = "bg-red-100 text-red-700" ;
if ( cs . status === "aapen_med_vintergreener" || cs . status === "aapner_snart" ) badgeColor = "bg-yellow-100 text-yellow-700" ;
return (
< div key = { idx } className = "flex items-center gap-2" >
< span className = "text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title = { cs . name } > { cs . name } < / span >
< span className = { ` px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${ badgeColor } ` } > { cs . status || 'UKJENT' } < / span >
< / div >
)
} ) }
2026-03-05 05:18:03 +01:00
< / div >
2026-03-12 13:39:10 +01:00
< / td >
< / >
2026-03-12 14:51:17 +01:00
) }
{ activeTab === 'medlemskap' && (
2026-03-12 13:39:10 +01:00
< >
2026-03-12 14:51:17 +01:00
< td className = "py-6 pr-4" > < InlineEdit facilityId = { f . id } field = "medlemskap_url" initialValue = { f . medlemskap_url } onSave = { handleQuickEdit } / > < / td >
2026-03-12 13:39:10 +01:00
< td className = "py-6 pr-4" >
< div className = "flex flex-col gap-1" >
< span className = "text-xs" > Standard : < strong > { f . standard_medlemskap ? ` ${ f . standard_medlemskap } ,- ` : '---' } < / strong > < / span >
< span className = "text-xs text-gray-500" > Rimeligste : < strong > { f . rimeligste_alternativ ? ` ${ f . rimeligste_alternativ } ,- ` : '---' } < / strong > < / span >
< / div >
< / td >
2026-04-10 09:52:34 +02:00
< td className = "py-6 pr-4 text-center" > { hasMemDraft ? < span className = "px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse" > Ja , vask ! < / span > : < span className = "text-gray-300" > - < / span > } < / td >
2026-03-12 14:51:17 +01:00
< td className = "py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap" > { f . membership_updated_at ? new Date ( f . membership_updated_at ) . toLocaleDateString ( 'nb-NO' ) : 'Aldri' } < / td >
< / >
) }
{ activeTab === 'greenfee' && (
< >
< td className = "py-6 pr-4" > < InlineEdit facilityId = { f . id } field = "greenfee_url" initialValue = { f . greenfee_url } onSave = { handleQuickEdit } / > < / td >
< td className = "py-6 pr-4" >
2026-04-10 09:52:34 +02:00
< div className = "flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2" >
2026-03-12 14:51:17 +01:00
{ f . greenfee && f . greenfee . length > 0 ? f . greenfee . map ( ( g : any , i : number ) = > (
2026-04-10 09:52:34 +02:00
< div key = { i } className = "flex justify-between border-b border-gray-50 pb-1" >
< span className = "truncate max-w-[120px]" > { g . banenavn } < / span >
< span className = "font-bold text-[#11280f]" > V : { g . pris_voksne } J : { g . pris_junior } < / span >
< / div >
) ) : 'Ingen priser' }
2026-03-12 14:51:17 +01:00
< / div >
2026-03-12 13:39:10 +01:00
< / td >
2026-04-10 09:52:34 +02:00
< td className = "py-6 pr-4 text-center" > { hasGfDraft ? < span className = "px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse" > Ja , vask ! < / span > : < span className = "text-gray-300" > - < / span > } < / td >
2026-03-12 14:51:17 +01:00
< td className = "py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap" > { f . greenfee_updated_at ? new Date ( f . greenfee_updated_at ) . toLocaleDateString ( 'nb-NO' ) : 'Aldri' } < / td >
< / >
) }
{ activeTab === 'vtg' && (
< >
< td className = "py-6 pr-4" > < InlineEdit facilityId = { f . id } field = "vtg_lenke" initialValue = { f . vtg_lenke } onSave = { handleQuickEdit } / > < / td >
< td className = "py-6 pr-4 max-w-[250px]" >
< div className = "flex flex-col gap-1" >
2026-04-10 09:52:34 +02:00
< span className = "text-xs" > Pris : < strong className = "text-[#8bc34a]" > { f . vtg_pris ? ` ${ f . vtg_pris } ,- ` : '---' } < / strong > < / span >
< span className = "text-[10px] text-gray-500 line-clamp-2" title = { f . vtg_beskrivelse } > { f . vtg_beskrivelse || 'Ingen beskrivelse registrert.' } < / span >
< span className = "text-[10px] font-bold text-[#11280f] mt-1 bg-gray-50 px-2 py-1 rounded-md inline-block w-max" >
{ f . vtg_datoer && f . vtg_datoer . length > 0 ? ` 📅 ${ f . vtg_datoer . length } kursdato(er) ` : '📅 Ingen datoer registrert' }
< / span >
2026-03-12 14:51:17 +01:00
< / div >
2026-03-12 13:39:10 +01:00
< / td >
2026-04-10 09:52:34 +02:00
< td className = "py-6 pr-4 text-center" > { hasVtgDraft ? < span className = "px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse" > Ja , vask ! < / span > : < span className = "text-gray-300" > - < / span > } < / td >
2026-03-12 14:51:17 +01:00
< td className = "py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap" > { f . vtg_updated_at ? new Date ( f . vtg_updated_at ) . toLocaleDateString ( 'nb-NO' ) : 'Aldri' } < / td >
2026-03-12 13:39:10 +01:00
< / >
) }
< td className = "py-6 text-right pr-4" >
< div className = "flex flex-col gap-2 items-end" >
2026-04-10 09:52:34 +02:00
{ activeTab === 'banestatus' && < button onClick = { ( ) = > openEditModal ( f ) } className = "bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap" > Innstillinger < / button > }
{ activeTab === 'medlemskap' && hasMemDraft && < Link href = "/admin/medlemskap" className = "bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200" > Gå til Vaskeri < / Link > }
{ activeTab === 'greenfee' && hasGfDraft && < Link href = "/admin/greenfee" className = "bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200" > Gå til Vaskeri < / Link > }
{ activeTab === 'vtg' && hasVtgDraft && < Link href = "/admin/vtg" className = "bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap shadow-sm border border-yellow-200" > Gå til Vaskeri < / Link > }
2026-03-12 14:51:17 +01:00
2026-03-12 13:39:10 +01:00
< Link href = { ` /admin/rediger/ ${ f . slug } ` } className = "bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center" > Rediger alt < / Link >
< / div >
< / td >
< / tr >
) ;
} ) }
2026-03-02 09:05:18 +01:00
< / tbody >
2026-03-06 09:39:32 +01:00
< / table >
2026-03-02 09:05:18 +01:00
< / div >
< / div >
< / main >
< / div >
) ;
2026-04-10 18:37:33 +02:00
}