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