2026-03-02 09:05:18 +01:00
"use client" ;
/ * *
2026-03-12 13:39:10 +01:00
* TEE OFF ADMIN DASHBOARD v2 . 1 - MISSION CONTROL + INLINE EDIT
2026-03-02 09:05:18 +01:00
* -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -
2026-03-12 13:39:10 +01:00
* FUNKSJON : "Mission Control" med faner for Banestatus og Medlemskap ,
* og klikk - for - å - redigere URL - er direkte i tabellen !
2026-03-02 09:05:18 +01:00
* -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -
* /
2026-03-06 09:39:32 +01:00
import { useState , useEffect , useMemo } 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-03-12 13:39:10 +01:00
// KOMPONENT FOR HURTIGREDIGERING AV URL-ER
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" >
< 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)..."
/ >
< 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 [ ] > ( [ ] ) ;
const [ isScraping , setIsScraping ] = useState ( false ) ;
2026-03-05 09:25:15 +01:00
const [ isSidebarCollapsed , setIsSidebarCollapsed ] = useState ( false ) ;
const [ editingFacility , setEditingFacility ] = useState < any | null > ( null ) ;
2026-03-12 13:39:10 +01:00
const [ activeTab , setActiveTab ] = useState < 'banestatus' | 'medlemskap' > ( '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-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
} ;
useEffect ( ( ) = > {
fetchFacilities ( ) ;
2026-03-02 09:05:18 +01:00
} , [ ] ) ;
2026-03-05 05:18:03 +01:00
useEffect ( ( ) = > {
let interval : NodeJS.Timeout ;
if ( isScraping ) {
2026-03-12 13:39:10 +01:00
interval = setInterval ( ( ) = > fetchFacilities ( ) , 10000 ) ;
2026-03-05 05:18:03 +01:00
}
return ( ) = > clearInterval ( interval ) ;
} , [ isScraping ] ) ;
2026-03-12 13:39:10 +01:00
useEffect ( ( ) = > {
setSelectedFacilities ( [ ] ) ;
} , [ activeTab ] ) ;
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-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
// Funksjon for å lagre inline redigering av URLer
const handleQuickEdit = async ( id : number , field : string , value : string ) = > {
// "Optimistisk oppdatering" - Vi bytter teksten i skjermen med én gang så det føles lynraskt!
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." ) ;
fetchFacilities ( ) ; // Henter gamle data tilbake hvis det kræsjet
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 ( ) = > {
if ( isScraping ) { setIsScraping ( false ) ; return ; }
2026-03-05 05:18:03 +01:00
setIsScraping ( true ) ;
2026-03-12 13:39:10 +01:00
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' : '/admin/run-membership-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 } )
} ) ;
if ( ! response . ok ) throw new Error ( "Kunne ikke starte skraping" ) ;
2026-03-05 09:25:15 +01:00
const timeoutMs = Math . max ( selectedFacilities . length * 40 * 1000 , 60000 ) ;
setSelectedFacilities ( [ ] ) ;
setTimeout ( ( ) = > setIsScraping ( false ) , timeoutMs ) ;
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-03-05 05:18:03 +01:00
setIsScraping ( false ) ;
}
} ;
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." ) ;
} finally {
setIsSaving ( false ) ;
}
} ;
2026-03-02 09:05:18 +01:00
if ( loading ) return < div className = "p-20 text-center font-black animate-pulse" > LASTER DASHBORD . . . < / div > ;
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 13:39:10 +01:00
{ /* REDIGER-MODAL FOR BANESTATUS-SKRAPING */ }
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-03-12 13:39:10 +01: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 = "Mission Control" >
{ isSidebarCollapsed ? 'MC' : 'Mission Control' }
2026-03-08 10:26:56 +01:00
< / Link >
2026-03-12 13:39:10 +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 = "Medlemskaps-vaskeriet" >
{ isSidebarCollapsed ? 'V' : 'Vaskeriet' }
2026-03-08 10:26:56 +01:00
< / Link >
2026-03-05 09:25:15 +01:00
< div className = { ` hover:text-white cursor-pointer py-1 transition-colors ${ isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent' } ` } title = "Bildegalleri" >
{ isSidebarCollapsed ? 'B' : 'Bildegalleri' }
< / 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 = "md:hidden flex justify-between items-center mb-6 bg-[#11280f] text-white p-4 rounded-2xl" >
< h1 className = "text-xl font-black uppercase tracking-tighter" > TeeOff Admin < / h1 >
2026-03-12 13:39:10 +01:00
< span className = "text-xs font-bold text-[#8bc34a]" > CONTROL < / span >
2026-03-05 09:25:15 +01:00
< / div >
< 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-03-12 13:39:10 +01:00
< h2 className = "text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2" > Mission Control < / h2 >
< p className = "text-xs font-bold text-gray-400 uppercase tracking-widest" > Sjekker status på { 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-03-05 09:25:15 +01:00
disabled = { selectedFacilities . length === 0 && ! isScraping }
className = { ` text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
2026-03-05 05:18:03 +01:00
$ { isScraping
2026-03-05 09:25:15 +01:00
? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600'
2026-03-05 05:18:03 +01:00
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
} ` }
>
2026-03-12 13:39:10 +01:00
{ isScraping ? '🤖 Skraper... Klikk for å avslutte' : ` 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-03-12 13:39:10 +01:00
< div className = "flex gap-2 mb-8 border-b-2 border-gray-100 pb-0" >
< button onClick = { ( ) = > setActiveTab ( 'banestatus' ) } className = { ` px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all ${ activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100' } ` } > Banestatus < / button >
< button onClick = { ( ) = > setActiveTab ( 'medlemskap' ) } className = { ` px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all ${ activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100' } ` } > Medlemskap < / 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" >
< table className = "w-full text-left border-collapse min-w-[800px]" >
2026-03-06 09:39:32 +01:00
< thead >
2026-03-02 09:05:18 +01:00
< tr className = "text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50" >
2026-03-05 09:25:15 +01:00
< th className = "pb-4 pl-4 w-10" >
2026-03-12 13:39:10 +01:00
< input type = "checkbox" className = "w-4 h-4 cursor-pointer accent-[#8bc34a]" checked = { selectedFacilities . length === filteredFacilities . length && filteredFacilities . length > 0 } onChange = { handleSelectAll } / >
2026-03-05 05:18:03 +01:00
< / 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
{ 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 >
< / >
) : (
< >
< th className = "pb-4" > Prisside ( Klikk for å redigere URL ) < / th >
< th className = "pb-4" > Nåværende Priser < / th >
< th className = "pb-4 text-center" > Utkast i Vaskeri < / th >
< 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 ) = > {
const hasDraft = f . membership_draft && Object . keys ( f . membership_draft ) . length > 0 ;
return (
< tr key = { f . id } className = { ` border-b border-gray-50 group transition-colors ${ hasDraft && activeTab === 'medlemskap' ? 'bg-[#8bc34a]/5' : 'hover:bg-gray-50/50' } ` } >
< 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 >
< 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 >
{ activeTab === 'banestatus' ? (
< >
< td className = "py-6 pr-4" >
{ /* HER BRUKER VI INLINE EDITOREN */ }
< 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 >
< 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" >
{ 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 >
< / >
) : (
< >
< td className = "py-6 pr-4" >
{ /* HER BRUKER VI INLINE EDITOREN OGSÅ! */ }
< 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" >
{ hasDraft ? < span className = "px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse" > Ja < / 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 >
< / >
) }
< td className = "py-6 text-right pr-4" >
< div className = "flex flex-col gap-2 items-end" >
{ 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" > Ekspert - skraper < / button >
) : (
hasDraft ? (
< 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" > Gå til Vaskeriet < / Link >
) : null
) }
< 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 >
) ;
}