Nye-TeeOff/frontend/src/app/admin/page.tsx

457 lines
22 KiB
TypeScript
Raw Normal View History

"use client";
/**
2026-03-07 09:46:33 +01:00
* TEE OFF ADMIN DASHBOARD v1.9 - RESPONSIVT MED AI-HVISKER, KILL SWITCH, FILTER & FULL EDIT
* ---------------------------------------------------------------------------
* 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-06 09:39:32 +01:00
import { useState, useEffect, useMemo } from 'react';
import { API_URL } from "@/config/constants";
2026-03-05 09:25:15 +01:00
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
2026-03-07 09:46:33 +01:00
import Link from 'next/link';
export default function AdminDashboard() {
2026-03-05 05:18:03 +01:00
const [facilities, setFacilities] = useState<any[]>([]);
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 = () => {
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-05 05:18:03 +01:00
useEffect(() => {
let interval: NodeJS.Timeout;
if (isScraping) {
interval = setInterval(() => {
fetchFacilities();
}, 10000);
}
return () => clearInterval(interval);
}, [isScraping]);
// Filtreringslogikken som kjører automatisk når facilities eller filteret endres
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;
// 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);
}, [facilities, statusFilter]);
// "Velg alle" gjelder 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);
}
};
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 FOR SKRAPING */}
2026-03-05 09:25:15 +01:00
{editingFacility && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
<div className="bg-[#11280f] text-white p-6 shrink-0">
<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>
<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>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
{/* Lenke til Hoveddashbord / Scraping Monitor */}
<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="Scraping Monitor">
2026-03-05 09:25:15 +01:00
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
</Link>
{/* Lenke til Medlemskaps-Vaskeriet */}
<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">
2026-03-05 09:25:15 +01:00
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
</Link>
{/* Bildegalleri (Placeholder inntil vi bygger denne) */}
2026-03-05 09:25:15 +01:00
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
</div>
</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>
</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">
<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 {filteredFacilities.length} anlegg (av {facilities.length})</p>
</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>
</header>
2026-03-06 09:39:32 +01:00
<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 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-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>
<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>
<th className="pb-4 w-12 text-center">ID</th>
2026-03-05 09:25:15 +01:00
<th className="pb-4 pr-6">Anlegg</th>
<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>
</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>
<td className="py-6 text-center text-xs font-mono text-gray-400">
#{f.id}
</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>
<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>
</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">
{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">
2026-03-07 09:46:33 +01:00
<div className="flex flex-col gap-2 items-end">
<button
onClick={() => openEditModal(f)}
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap"
>
Skraper
</button>
<Link
href={`/admin/rediger/${f.slug}`}
className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center"
>
Rediger alt
</Link>
</div>
</td>
</tr>
))}
</tbody>
2026-03-06 09:39:32 +01:00
</table>
</div>
</div>
</main>
</div>
);
}