2026-03-02 09:05:18 +01:00
|
|
|
"use client";
|
|
|
|
|
/**
|
2026-03-06 09:39:32 +01:00
|
|
|
* TEE OFF ADMIN DASHBOARD v1.8 - RESPONSIVT MED AI-HVISKER, KILL SWITCH & FILTER
|
2026-03-02 09:05:18 +01:00
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
* PLASSERING: frontend/src/app/admin/page.tsx
|
2026-03-06 09:39:32 +01:00
|
|
|
* FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering.
|
2026-03-02 09:05:18 +01:00
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
*/
|
|
|
|
|
|
2026-03-06 09:39:32 +01:00
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
2026-03-02 09:05:18 +01:00
|
|
|
import { API_URL } from "@/config/constants";
|
2026-03-05 09:25:15 +01:00
|
|
|
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
2026-03-02 09:05:18 +01:00
|
|
|
|
|
|
|
|
export default function AdminDashboard() {
|
2026-03-05 05:18:03 +01:00
|
|
|
const [facilities, setFacilities] = useState<any[]>([]);
|
2026-03-02 09:05:18 +01:00
|
|
|
const [loading, setLoading] = useState(true);
|
2026-03-05 05:18:03 +01:00
|
|
|
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
|
|
|
|
|
const [isScraping, setIsScraping] = useState(false);
|
2026-03-05 09:25:15 +01:00
|
|
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
|
|
|
|
const [editingFacility, setEditingFacility] = useState<any | null>(null);
|
|
|
|
|
|
2026-03-06 09:39:32 +01:00
|
|
|
// NYTT: State for å holde styr på hvilket filter som er aktivt
|
|
|
|
|
const [statusFilter, setStatusFilter] = useState('alle');
|
|
|
|
|
|
2026-03-05 09:25:15 +01:00
|
|
|
const [editForm, setEditForm] = useState({
|
|
|
|
|
scrape_status_url: '',
|
|
|
|
|
scrape_status_selector: '',
|
|
|
|
|
scrape_method: '',
|
|
|
|
|
ai_instruction: '',
|
|
|
|
|
courses: [] as any[]
|
|
|
|
|
});
|
2026-03-06 09:39:32 +01:00
|
|
|
|
2026-03-05 09:25:15 +01:00
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
|
2026-03-05 05:18:03 +01:00
|
|
|
const fetchFacilities = () => {
|
2026-03-02 09:05:18 +01:00
|
|
|
fetch(`${API_URL}/facilities`)
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
setFacilities(Array.isArray(data) ? data : []);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setLoading(false));
|
2026-03-05 05:18:03 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchFacilities();
|
2026-03-02 09:05:18 +01:00
|
|
|
}, []);
|
|
|
|
|
|
2026-03-05 05:18:03 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
let interval: NodeJS.Timeout;
|
|
|
|
|
if (isScraping) {
|
|
|
|
|
interval = setInterval(() => {
|
|
|
|
|
fetchFacilities();
|
|
|
|
|
}, 10000);
|
|
|
|
|
}
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, [isScraping]);
|
|
|
|
|
|
2026-03-06 09:39:32 +01:00
|
|
|
// NYTT: Filtreringslogikken som kjører automatisk når facilities eller filteret endres
|
|
|
|
|
const filteredFacilities = useMemo(() => {
|
|
|
|
|
if (statusFilter === 'alle') return facilities;
|
|
|
|
|
|
|
|
|
|
return facilities.map(facility => {
|
|
|
|
|
if (!facility.course_statuses) return facility;
|
|
|
|
|
|
|
|
|
|
// Filtrer banene innad i hvert anlegg
|
|
|
|
|
const filteredCourses = facility.course_statuses.filter((cs: any) => {
|
|
|
|
|
const s = cs.status || 'ukjent';
|
|
|
|
|
|
|
|
|
|
if (statusFilter === 'aapne') {
|
|
|
|
|
return s === 'aapen';
|
|
|
|
|
}
|
|
|
|
|
if (statusFilter === 'ikke_stengt') {
|
|
|
|
|
return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
|
|
|
|
|
}
|
|
|
|
|
if (statusFilter === 'stengt') {
|
|
|
|
|
return s === 'stengt' || s === 'nedlagt';
|
|
|
|
|
}
|
|
|
|
|
if (statusFilter === 'ukjent_feil') {
|
|
|
|
|
return s === 'ukjent' || s === 'NOT_FOUND';
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Returner anlegget kun med de banene som matcher
|
|
|
|
|
return { ...facility, course_statuses: filteredCourses };
|
|
|
|
|
|
|
|
|
|
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
|
|
|
|
|
// Skjul anlegget helt hvis det ikke har noen baner som matcher filteret
|
|
|
|
|
|
|
|
|
|
}, [facilities, statusFilter]);
|
|
|
|
|
|
|
|
|
|
// OPPDATERT: "Velg alle" gjelder nå kun de anleggene som er synlige i filteret
|
2026-03-05 05:18:03 +01:00
|
|
|
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
if (e.target.checked) {
|
2026-03-06 09:39:32 +01:00
|
|
|
setSelectedFacilities(filteredFacilities.map(f => f.id));
|
2026-03-05 05:18:03 +01:00
|
|
|
} else {
|
|
|
|
|
setSelectedFacilities([]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSelectOne = (id: number, checked: boolean) => {
|
|
|
|
|
if (checked) {
|
|
|
|
|
setSelectedFacilities([...selectedFacilities, id]);
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 09:39:32 +01:00
|
|
|
const handleRunScrapers = async () => {
|
2026-03-05 09:25:15 +01:00
|
|
|
if (isScraping) {
|
|
|
|
|
setIsScraping(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 05:18:03 +01:00
|
|
|
setIsScraping(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_URL}/admin/run-scraper`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ facility_ids: selectedFacilities })
|
|
|
|
|
});
|
2026-03-06 09:39:32 +01:00
|
|
|
|
2026-03-05 05:18:03 +01:00
|
|
|
if (!response.ok) throw new Error("Kunne ikke starte skraping");
|
|
|
|
|
|
2026-03-05 09:25:15 +01:00
|
|
|
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
|
|
|
|
|
setSelectedFacilities([]);
|
|
|
|
|
setTimeout(() => setIsScraping(false), timeoutMs);
|
2026-03-05 05:18:03 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
alert("Feil ved start av skraperen.");
|
|
|
|
|
setIsScraping(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-05 09:25:15 +01:00
|
|
|
const openEditModal = (facility: any) => {
|
|
|
|
|
setEditingFacility(facility);
|
|
|
|
|
setEditForm({
|
|
|
|
|
scrape_status_url: facility.scrape_status_url || '',
|
|
|
|
|
scrape_status_selector: facility.scrape_status_selector || '',
|
|
|
|
|
scrape_method: facility.scrape_method || 'css_selector',
|
|
|
|
|
ai_instruction: facility.ai_instruction || '',
|
|
|
|
|
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSaveEdit = async () => {
|
|
|
|
|
setIsSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(editForm)
|
|
|
|
|
});
|
2026-03-06 09:39:32 +01:00
|
|
|
|
2026-03-05 09:25:15 +01:00
|
|
|
if (!response.ok) throw new Error("Feil ved lagring");
|
|
|
|
|
|
|
|
|
|
setEditingFacility(null);
|
|
|
|
|
fetchFacilities();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
alert("Kunne ikke lagre endringene.");
|
|
|
|
|
console.error(error);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-02 09:05:18 +01:00
|
|
|
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-05 09:25:15 +01:00
|
|
|
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
|
|
|
|
|
|
|
|
|
|
{/* REDIGER-MODAL */}
|
|
|
|
|
{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-06 09:39:32 +01:00
|
|
|
<h3 className="text-xl font-black uppercase tracking-widest">Rediger Konfigurasjon</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>
|
|
|
|
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL</label>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={editForm.scrape_status_url}
|
|
|
|
|
onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})}
|
|
|
|
|
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
|
|
|
|
placeholder="f.eks. https://golfklubb.no/banestatus"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
|
|
|
|
|
<select
|
|
|
|
|
value={editForm.scrape_method}
|
|
|
|
|
onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})}
|
|
|
|
|
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<option value="css_selector">Standard (CSS)</option>
|
|
|
|
|
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
|
|
|
|
<option value="iframe_golfbox">Golfbox iframe</option>
|
|
|
|
|
<option value="click_then_css">Auto-klikk + CSS</option>
|
|
|
|
|
<option value="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>
|
|
|
|
|
<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}
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-[10px] text-gray-400 mt-1">Hjelper Gemini hvis nettsiden er forvirrende.</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{editForm.scrape_method === 'manual' && (
|
|
|
|
|
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
|
|
|
|
|
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{editForm.courses.map((course: any, idx: number) => (
|
|
|
|
|
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
|
|
|
|
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
|
|
|
|
|
<select
|
|
|
|
|
value={course.status || 'ukjent'}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const newCourses = [...editForm.courses];
|
|
|
|
|
newCourses[idx].status = e.target.value;
|
|
|
|
|
setEditForm({...editForm, courses: newCourses});
|
|
|
|
|
}}
|
|
|
|
|
className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<option value="aapen">🟢 Åpen</option>
|
|
|
|
|
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
|
|
|
|
<option value="aapner_snart">🟡 Åpner Snart</option>
|
|
|
|
|
<option value="stengt">🔴 Stengt</option>
|
|
|
|
|
<option value="stenger_snart">🔴 Stenger Snart</option>
|
|
|
|
|
<option value="under_utvikling">🔨 Under Utvikling</option>
|
|
|
|
|
<option value="nedlagt">⚫ Nedlagt</option>
|
|
|
|
|
<option value="ukjent">⚪ Ukjent</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
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>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={editForm.scrape_status_selector}
|
|
|
|
|
onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})}
|
|
|
|
|
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono"
|
|
|
|
|
placeholder="f.eks. .status-text"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
|
|
|
|
|
<button onClick={() => setEditingFacility(null)} className="px-6 py-3 rounded-xl text-xs font-bold uppercase tracking-widest text-gray-500 hover:bg-gray-200 transition-colors">Avbryt</button>
|
|
|
|
|
<button onClick={handleSaveEdit} disabled={isSaving} className="bg-[#8bc34a] text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
|
|
|
|
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-06 09:39:32 +01:00
|
|
|
{/* SIDEBAR */}
|
2026-03-05 09:25:15 +01:00
|
|
|
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
|
|
|
|
|
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
|
|
|
|
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
|
|
|
|
|
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">
|
|
|
|
|
☰
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-02 09:05:18 +01:00
|
|
|
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
2026-03-05 09:25:15 +01:00
|
|
|
<div className={`text-white border-l-4 border-[#8bc34a] py-1 ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4'}`} title="Scraping Monitor">
|
|
|
|
|
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
|
|
|
|
|
</div>
|
|
|
|
|
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
|
|
|
|
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
|
|
|
|
</div>
|
|
|
|
|
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
|
|
|
|
|
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
|
|
|
|
|
</div>
|
2026-03-02 09:05:18 +01:00
|
|
|
</nav>
|
2026-03-05 09:25:15 +01:00
|
|
|
|
|
|
|
|
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
|
|
|
|
|
<button onClick={() => window.location.href='/'} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
|
|
|
|
|
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
|
|
|
|
|
</button>
|
2026-03-02 09:05:18 +01:00
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
2026-03-06 09:39:32 +01:00
|
|
|
{/* HOVEDINNHOLD */}
|
2026-03-05 09:25:15 +01:00
|
|
|
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
|
|
|
|
|
|
|
|
|
|
<div className="md:hidden flex justify-between items-center mb-6 bg-[#11280f] text-white p-4 rounded-2xl">
|
|
|
|
|
<h1 className="text-xl font-black uppercase tracking-tighter">TeeOff Admin</h1>
|
|
|
|
|
<span className="text-xs font-bold text-[#8bc34a]">MONITOR</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
|
2026-03-06 09:39:32 +01:00
|
|
|
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-6">
|
2026-03-02 09:05:18 +01:00
|
|
|
<div>
|
2026-03-06 09:39:32 +01:00
|
|
|
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
|
|
|
|
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {filteredFacilities.length} anlegg (av {facilities.length})</p>
|
2026-03-02 09:05:18 +01:00
|
|
|
</div>
|
2026-03-05 05:18:03 +01:00
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleRunScrapers}
|
2026-03-05 09:25:15 +01:00
|
|
|
disabled={selectedFacilities.length === 0 && !isScraping}
|
|
|
|
|
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
|
2026-03-05 05:18:03 +01:00
|
|
|
${isScraping
|
2026-03-05 09:25:15 +01:00
|
|
|
? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600'
|
2026-03-05 05:18:03 +01:00
|
|
|
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-03-05 09:25:15 +01:00
|
|
|
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør valgte skrapere (${selectedFacilities.length})`}
|
2026-03-05 05:18:03 +01:00
|
|
|
</button>
|
2026-03-02 09:05:18 +01:00
|
|
|
</header>
|
2026-03-06 09:39:32 +01:00
|
|
|
|
|
|
|
|
{/* NYTT: Filter-meny UI */}
|
|
|
|
|
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8">
|
|
|
|
|
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
|
|
|
|
|
<select
|
|
|
|
|
id="statusFilter"
|
|
|
|
|
value={statusFilter}
|
|
|
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
|
|
|
className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<option value="alle">Vis alle anlegg</option>
|
|
|
|
|
<option value="aapne">🟢 Kun åpne baner</option>
|
|
|
|
|
<option value="ikke_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
|
|
|
|
|
<option value="stengt">🔴 Kun stengte baner</option>
|
|
|
|
|
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil (Krever tilsyn)</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-03-02 09:05:18 +01:00
|
|
|
|
2026-03-05 09:25:15 +01:00
|
|
|
<div className="overflow-x-auto pb-4">
|
|
|
|
|
<table className="w-full text-left border-collapse min-w-[800px]">
|
2026-03-06 09:39:32 +01:00
|
|
|
<thead>
|
2026-03-02 09:05:18 +01:00
|
|
|
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
|
2026-03-05 09:25:15 +01:00
|
|
|
<th className="pb-4 pl-4 w-10">
|
2026-03-05 05:18:03 +01:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
|
2026-03-06 09:39:32 +01:00
|
|
|
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
|
2026-03-05 05:18:03 +01:00
|
|
|
onChange={handleSelectAll}
|
|
|
|
|
/>
|
|
|
|
|
</th>
|
2026-03-05 09:25:15 +01:00
|
|
|
<th className="pb-4 pr-6">Anlegg</th>
|
|
|
|
|
<th className="pb-4">Konfigurasjon</th>
|
|
|
|
|
<th className="pb-4">Metode</th>
|
|
|
|
|
<th className="pb-4">Siste Sjekk</th>
|
|
|
|
|
<th className="pb-4">Banestatus</th>
|
|
|
|
|
<th className="pb-4 text-right pr-4">Handling</th>
|
2026-03-02 09:05:18 +01:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="text-sm font-bold text-[#11280f]">
|
2026-03-06 09:39:32 +01:00
|
|
|
{filteredFacilities.map((f: any) => (
|
|
|
|
|
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
|
2026-03-05 09:25:15 +01:00
|
|
|
<td className="py-6 pl-4 w-10">
|
2026-03-05 05:18:03 +01:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
|
|
|
|
|
checked={selectedFacilities.includes(f.id)}
|
|
|
|
|
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
2026-03-05 09:25:15 +01:00
|
|
|
<td className="py-6 pr-6">
|
|
|
|
|
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
2026-03-02 09:05:18 +01:00
|
|
|
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
|
|
|
|
</td>
|
2026-03-06 09:39:32 +01:00
|
|
|
<td className="py-6 pr-4">
|
2026-03-05 09:25:15 +01:00
|
|
|
<div className="text-[10px] text-blue-600 truncate max-w-[150px] mb-1">
|
|
|
|
|
{f.scrape_status_url ? f.scrape_status_url : <span className="text-red-400 italic">Mangler URL</span>}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px]">{f.scrape_status_selector}</div>
|
2026-03-02 09:05:18 +01:00
|
|
|
</td>
|
2026-03-05 09:25:15 +01:00
|
|
|
<td className="py-6 pr-4">
|
2026-03-06 09:39:32 +01:00
|
|
|
<ScrapeMethodSelect facility={f} />
|
2026-03-05 05:18:03 +01:00
|
|
|
</td>
|
2026-03-05 09:25:15 +01:00
|
|
|
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
|
2026-03-02 09:05:18 +01:00
|
|
|
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
|
|
|
|
|
</td>
|
2026-03-05 09:25:15 +01:00
|
|
|
<td className="py-6 pr-4">
|
|
|
|
|
<div className="flex flex-col gap-1">
|
2026-03-05 05:18:03 +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 (
|
2026-03-06 09:39:32 +01:00
|
|
|
<div key={idx} className="flex items-center gap-2">
|
2026-03-05 09:25:15 +01:00
|
|
|
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>
|
2026-03-05 05:18:03 +01:00
|
|
|
{cs.name}
|
2026-03-06 09:39:32 +01:00
|
|
|
</span>
|
2026-03-05 09:25:15 +01:00
|
|
|
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>
|
2026-03-05 05:18:03 +01:00
|
|
|
{cs.status || 'UKJENT'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
2026-03-06 09:39:32 +01:00
|
|
|
})}
|
2026-03-05 05:18:03 +01:00
|
|
|
</div>
|
|
|
|
|
</td>
|
2026-03-05 09:25:15 +01:00
|
|
|
<td className="py-6 text-right pr-4">
|
|
|
|
|
<button
|
2026-03-06 09:39:32 +01:00
|
|
|
onClick={() => openEditModal(f)}
|
2026-03-05 09:25:15 +01:00
|
|
|
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all whitespace-nowrap"
|
|
|
|
|
>
|
2026-03-06 09:39:32 +01:00
|
|
|
Rediger
|
2026-03-05 09:25:15 +01:00
|
|
|
</button>
|
2026-03-02 09:05:18 +01:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
2026-03-06 09:39:32 +01:00
|
|
|
</table>
|
2026-03-02 09:05:18 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|