Før vi henter Greenfee og Vtg fra Backend til Fronten
This commit is contained in:
parent
16d6e207af
commit
15aee27e24
6 changed files with 796 additions and 95 deletions
Binary file not shown.
124
backend/main.py
124
backend/main.py
|
|
@ -66,6 +66,19 @@ class QuickEditRequest(BaseModel):
|
|||
field: str
|
||||
value: str
|
||||
|
||||
class GreenfeeApproval(BaseModel):
|
||||
facility_id: int
|
||||
greenfee: List[dict]
|
||||
|
||||
|
||||
class VtgApproval(BaseModel):
|
||||
facility_id: int
|
||||
vtg_pris: int | None
|
||||
vtg_beskrivelse: str | None
|
||||
vtg_datoer: List[dict] | None
|
||||
|
||||
class BulkVtgRequest(BaseModel):
|
||||
approvals: List[VtgApproval]
|
||||
# --- FUNKSJONER ---
|
||||
def format_row(row):
|
||||
"""
|
||||
|
|
@ -359,7 +372,8 @@ async def update_facility_full(facility_id: int, request: Request):
|
|||
'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url',
|
||||
'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer',
|
||||
'guest_requirements', 'scrape_method', 'scrape_status_url',
|
||||
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at'
|
||||
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
|
||||
'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke'
|
||||
]
|
||||
|
||||
update_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
|
|
@ -512,7 +526,113 @@ async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
|||
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
|
||||
request.value, facility_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
# --- GREENFEE "VASKERI" ENDEPUNKTER ---
|
||||
|
||||
@app.get("/api/admin/greenfee/drafts")
|
||||
async def get_greenfee_drafts():
|
||||
"""Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft
|
||||
FROM facilities
|
||||
WHERE greenfee_draft IS NOT NULL
|
||||
AND greenfee_draft::text != '{}'
|
||||
ORDER BY name ASC
|
||||
""")
|
||||
return [format_row(row) for row in rows]
|
||||
|
||||
class BulkGreenfeeRequest(BaseModel):
|
||||
approvals: List[GreenfeeApproval]
|
||||
|
||||
@app.post("/api/admin/greenfee/approve-bulk")
|
||||
async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
|
||||
"""Godkjenner AI-forslag, setter oppdatert-dato og sletter utkastet."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for approval in request.approvals:
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET greenfee = $1::jsonb,
|
||||
greenfee_updated_at = NOW(),
|
||||
greenfee_draft = NULL
|
||||
WHERE id = $2
|
||||
""", json.dumps(approval.greenfee), approval.facility_id)
|
||||
return {"status": "success"}
|
||||
|
||||
def run_greenfee_worker(facility_ids: List[int]):
|
||||
"""Kjører greenfee-skraperen i bakgrunnen."""
|
||||
print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}")
|
||||
try:
|
||||
import subprocess
|
||||
ids_arg = ",".join(map(str, facility_ids))
|
||||
command = f"python -u scrape_greenfee.py --ids {ids_arg}"
|
||||
subprocess.run(command, shell=True, check=True)
|
||||
print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
||||
except Exception as e:
|
||||
print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}")
|
||||
|
||||
@app.post("/api/admin/run-greenfee-scraper")
|
||||
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
||||
"""Tar imot IDer for greenfeeskraping og legger jobben i kø."""
|
||||
if not request.facility_ids:
|
||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
||||
background_tasks.add_task(run_greenfee_worker, request.facility_ids)
|
||||
return {"status": "queued", "message": "Skraping startet"}
|
||||
|
||||
# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER ---
|
||||
|
||||
@app.get("/api/admin/vtg/drafts")
|
||||
async def get_vtg_drafts():
|
||||
"""Henter alle anlegg som har et ventende VTG-forslag."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft
|
||||
FROM facilities
|
||||
WHERE vtg_draft IS NOT NULL
|
||||
AND vtg_draft::text != '{}'
|
||||
ORDER BY name ASC
|
||||
""")
|
||||
return [format_row(row) for row in rows]
|
||||
|
||||
@app.post("/api/admin/vtg/approve-bulk")
|
||||
async def approve_vtg_bulk(request: BulkVtgRequest):
|
||||
"""Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for approval in request.approvals:
|
||||
datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]'
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET vtg_pris = $1,
|
||||
vtg_beskrivelse = $2,
|
||||
vtg_datoer = $3::jsonb,
|
||||
vtg_updated_at = NOW(),
|
||||
vtg_draft = NULL
|
||||
WHERE id = $4
|
||||
""", approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id)
|
||||
return {"status": "success"}
|
||||
|
||||
def run_vtg_worker(facility_ids: List[int]):
|
||||
"""Kjører VTG-skraperen i bakgrunnen."""
|
||||
print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}")
|
||||
try:
|
||||
import subprocess
|
||||
ids_arg = ",".join(map(str, facility_ids))
|
||||
command = f"python -u scrape_vtg.py --ids {ids_arg}"
|
||||
subprocess.run(command, shell=True, check=True)
|
||||
print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
||||
except Exception as e:
|
||||
print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}")
|
||||
|
||||
@app.post("/api/admin/run-vtg-scraper")
|
||||
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
||||
"""Tar imot IDer for VTG-skraping og legger jobben i kø."""
|
||||
if not request.facility_ids:
|
||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
||||
background_tasks.add_task(run_vtg_worker, request.facility_ids)
|
||||
return {"status": "queued", "message": "Skraping startet"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
161
backend/scrape_vtg.py
Normal file
161
backend/scrape_vtg.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""
|
||||
TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI
|
||||
---------------------------------------------------------------------------
|
||||
Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider.
|
||||
Støtter kommaseparerte URL-er.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from bs4 import BeautifulSoup
|
||||
from playwright.async_api import async_playwright
|
||||
import google.generativeai as genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
||||
|
||||
if not GEMINI_API_KEY:
|
||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
||||
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
||||
|
||||
async def fetch_page_text(url: str, browser) -> str:
|
||||
url = url.strip()
|
||||
if not url.startswith("http"):
|
||||
return ""
|
||||
|
||||
print(f" 🌐 Laster inn: {url}")
|
||||
try:
|
||||
page = await browser.new_page()
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
||||
html_content = await page.content()
|
||||
await page.close()
|
||||
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
||||
script.extract()
|
||||
|
||||
return soup.get_text(separator=' ', strip=True)
|
||||
except Exception as e:
|
||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
||||
return ""
|
||||
|
||||
def analyze_vtg_with_gemini(text: str, club_name: str) -> dict:
|
||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...")
|
||||
|
||||
prompt = f"""
|
||||
Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs.
|
||||
|
||||
OPPGAVER:
|
||||
1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet).
|
||||
2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om:
|
||||
- Er lån av køller/utstyr inkludert i kurset?
|
||||
- Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)?
|
||||
- Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring").
|
||||
Ignorer uvesentlig støy og lange historiske utgreiinger.
|
||||
3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste").
|
||||
|
||||
TEKST FRA NETTSIDEN:
|
||||
{text}
|
||||
|
||||
OPPGAVE:
|
||||
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
|
||||
{{
|
||||
"foreslatt_vtg_pris": 1990,
|
||||
"foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.",
|
||||
"foreslatt_vtg_datoer": [
|
||||
{{"dato": "12.-14. mai", "status": "Fulltegnet"}},
|
||||
{{"dato": "5.-7. juni", "status": "Ledig"}}
|
||||
],
|
||||
"ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr."
|
||||
}}
|
||||
Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom [].
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
raw_response = response.text.strip()
|
||||
|
||||
if raw_response.startswith("```json"):
|
||||
raw_response = raw_response[7:]
|
||||
if raw_response.endswith("```"):
|
||||
raw_response = raw_response[:-3]
|
||||
|
||||
return json.loads(raw_response.strip())
|
||||
except Exception as e:
|
||||
print(f" ❌ AI-analyse feilet: {e}")
|
||||
return None
|
||||
|
||||
async def run_vtg_scraper(facility_ids=None):
|
||||
print("🚀 Starter Veien til Golf (VTG) skraperen...")
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
|
||||
try:
|
||||
query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''"
|
||||
if facility_ids:
|
||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
||||
|
||||
facilities = await conn.fetch(query)
|
||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
|
||||
for facility in facilities:
|
||||
fac_id = facility['id']
|
||||
name = facility['name']
|
||||
urls_raw = facility['vtg_lenke']
|
||||
|
||||
print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})")
|
||||
|
||||
urls = [u.strip() for u in urls_raw.split(',')]
|
||||
combined_text = ""
|
||||
|
||||
for idx, url in enumerate(urls, 1):
|
||||
page_text = await fetch_page_text(url, browser)
|
||||
if page_text:
|
||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
||||
|
||||
if len(combined_text) < 50:
|
||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
||||
continue
|
||||
|
||||
draft_data = analyze_vtg_with_gemini(combined_text[:25000], name)
|
||||
|
||||
if not draft_data:
|
||||
continue
|
||||
|
||||
print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.")
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET vtg_draft = $1::jsonb
|
||||
WHERE id = $2
|
||||
""", json.dumps(draft_data), fac_id)
|
||||
|
||||
print(" 💾 VTG-utkast lagret i databasen!")
|
||||
|
||||
await browser.close()
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
print("\n🏁 Skraping fullført.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Skrap VTG via AI.")
|
||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
||||
args = parser.parse_args()
|
||||
|
||||
ids_to_scrape = None
|
||||
if args.ids:
|
||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
||||
|
||||
asyncio.run(run_vtg_scraper(ids_to_scrape))
|
||||
203
frontend/src/app/admin/greenfee/page.tsx
Normal file
203
frontend/src/app/admin/greenfee/page.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_URL } from "@/config/constants";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function GreenfeeWasher() {
|
||||
const [drafts, setDrafts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchDrafts = () => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/admin/greenfee/drafts`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const editableDrafts = data.map((f: any) => {
|
||||
// JSONB fra Postgres kan noen ganger komme som en streng,
|
||||
// vi må sikre at vi parser det hvis det trengs
|
||||
let parsedDraft = f.greenfee_draft;
|
||||
if (typeof parsedDraft === 'string') {
|
||||
try { parsedDraft = JSON.parse(parsedDraft); }
|
||||
catch (e) { console.error("Kunne ikke parse JSON", e); }
|
||||
}
|
||||
|
||||
// Hent ut selve listen (fallback til tom liste hvis noe er feil)
|
||||
const greenfeeList = parsedDraft?.foreslatt_greenfee || [];
|
||||
|
||||
return {
|
||||
...f,
|
||||
greenfee_draft: parsedDraft, // Lagre den parsede versjonen for visning
|
||||
edit_greenfee: greenfeeList // Dette er arrayet som binder seg til input-feltene
|
||||
};
|
||||
});
|
||||
setDrafts(editableDrafts);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchDrafts(); }, []);
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
||||
else setSelectedIds([]);
|
||||
};
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
||||
else setSelectedIds([...selectedIds, id]);
|
||||
};
|
||||
|
||||
const removeRow = (facilityId: number, rowIndex: number) => {
|
||||
setDrafts(drafts.map(d => {
|
||||
if (d.id === facilityId) {
|
||||
const newRows = [...d.edit_greenfee];
|
||||
newRows.splice(rowIndex, 1);
|
||||
return { ...d, edit_greenfee: newRows };
|
||||
}
|
||||
return d;
|
||||
}));
|
||||
};
|
||||
|
||||
const updateField = (facilityId: number, rowIndex: number, field: string, value: string | number) => {
|
||||
setDrafts(drafts.map(d => {
|
||||
if (d.id === facilityId) {
|
||||
const newRows = [...d.edit_greenfee];
|
||||
newRows[rowIndex] = { ...newRows[rowIndex], [field]: value };
|
||||
return { ...d, edit_greenfee: newRows };
|
||||
}
|
||||
return d;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
||||
facility_id: d.id,
|
||||
greenfee: d.edit_greenfee.map((row: any) => ({
|
||||
banenavn: row.banenavn || "",
|
||||
priskategori: row.priskategori || "",
|
||||
pris_voksne: Number(row.pris_voksne) || null,
|
||||
pris_junior: Number(row.pris_junior) || null
|
||||
}))
|
||||
}));
|
||||
|
||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approvals: toApprove })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert(`${toApprove.length} anlegg oppdatert!`);
|
||||
setSelectedIds([]);
|
||||
fetchDrafts();
|
||||
} else {
|
||||
alert("Noe gikk galt under lagring.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Nettverksfeil");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster utkast...</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
||||
<div>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<h1 className="text-4xl font-black">Greenfee-Vaskeriet</h1>
|
||||
<p className="text-sm text-gray-600 mt-2">Sjekk at prisene gir mening før publisering.</p>
|
||||
</div>
|
||||
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
||||
<span className="text-6xl mb-4 block">🧹</span>
|
||||
<h2 className="text-2xl font-black text-gray-400">Alt er rent og pent!</h2>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
||||
<input type="checkbox" className="w-5 h-5 accent-[#8bc34a] ml-2" checked={selectedIds.length === drafts.length && drafts.length > 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
|
||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
||||
</div>
|
||||
|
||||
{drafts.map(draft => (
|
||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
|
||||
<div className="flex-grow space-y-4">
|
||||
<div className="flex justify-between items-center border-b pb-4">
|
||||
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
|
||||
<a href={draft.greenfee_url?.split(',')[0]} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside ↗</a>
|
||||
</div>
|
||||
|
||||
{draft.greenfee_draft?.ai_begrunnelse && (
|
||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
||||
<strong>🤖 AI Begrunnelse:</strong> {draft.greenfee_draft.ai_begrunnelse}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.greenfee_draft?.foreslatt_avtaleklubber?.length > 0 && (
|
||||
<div className="bg-green-50/50 p-4 rounded-xl text-sm text-green-900 border border-green-100">
|
||||
<strong>🤝 AI fant disse avtaleklubbene i teksten:</strong> {draft.greenfee_draft.foreslatt_avtaleklubber.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400 mb-2">Slik ser det ut i databasen nå:</h4>
|
||||
<div className="bg-gray-50 p-4 rounded-xl text-xs space-y-2 opacity-75">
|
||||
{draft.greenfee && draft.greenfee.length > 0 ? draft.greenfee.map((g: any, i: number) => (
|
||||
<div key={i} className="flex justify-between border-b pb-1">
|
||||
<span>{g.banenavn} - {g.priskategori}</span>
|
||||
<span className="font-bold">V: {g.pris_voksne || '-'} | J: {g.pris_junior || '-'}</span>
|
||||
</div>
|
||||
)) : "Ingen priser registrert."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-2">Nytt forslag å godkjenne:</h4>
|
||||
<div className="space-y-2">
|
||||
{draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => (
|
||||
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
|
||||
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.banenavn || ''} onChange={e => updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" />
|
||||
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none" value={row.priskategori || ''} onChange={e => updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
|
||||
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" type="number" value={row.pris_voksne || ''} onChange={e => updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
|
||||
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" type="number" value={row.pris_junior || ''} onChange={e => updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" />
|
||||
<button onClick={() => removeRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett rad">✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => {
|
||||
const newDrafts = [...drafts];
|
||||
const draftIndex = newDrafts.findIndex(d => d.id === draft.id);
|
||||
newDrafts[draftIndex].edit_greenfee.push({ banenavn: '', priskategori: '', pris_voksne: '', pris_junior: '' });
|
||||
setDrafts(newDrafts);
|
||||
}} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
|
||||
+ Legg til manuell rad
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF ADMIN DASHBOARD v2.1 - MISSION CONTROL + INLINE EDIT
|
||||
* ---------------------------------------------------------------------------
|
||||
* FUNKSJON: "Mission Control" med faner for Banestatus og Medlemskap,
|
||||
* og klikk-for-å-redigere URL-er direkte i tabellen!
|
||||
* ---------------------------------------------------------------------------
|
||||
* TEE OFF ADMIN DASHBOARD v3.0 - THE GRAND SLAM (Alle 4 skrapere)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
|
@ -12,7 +8,6 @@ import { API_URL } from "@/config/constants";
|
|||
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
||||
import Link from 'next/link';
|
||||
|
||||
// KOMPONENT FOR HURTIGREDIGERING AV URL-ER
|
||||
const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(initialValue || '');
|
||||
|
|
@ -27,15 +22,7 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n
|
|||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full max-w-[200px] animate-fade-in">
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={2}
|
||||
className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }}
|
||||
placeholder="Lim inn URL(er)..."
|
||||
/>
|
||||
<textarea autoFocus rows={2} className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white" value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} placeholder="Lim inn URL(er)..." />
|
||||
<div className="flex gap-1">
|
||||
<button onClick={handleSave} className="bg-[#8bc34a] text-white px-3 py-1.5 rounded-md text-[10px] font-black uppercase flex-1 shadow-sm hover:bg-[#7ca982]">Lagre</button>
|
||||
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="bg-gray-200 text-gray-600 px-3 py-1.5 rounded-md text-[10px] font-black uppercase hover:bg-gray-300">Avbryt</button>
|
||||
|
|
@ -54,7 +41,6 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [facilities, setFacilities] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -62,7 +48,7 @@ export default function AdminDashboard() {
|
|||
const [isScraping, setIsScraping] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [editingFacility, setEditingFacility] = useState<any | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'banestatus' | 'medlemskap'>('banestatus');
|
||||
const [activeTab, setActiveTab] = useState<'banestatus' | 'medlemskap' | 'greenfee' | 'vtg'>('banestatus');
|
||||
const [statusFilter, setStatusFilter] = useState('alle');
|
||||
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
|
@ -77,21 +63,15 @@ export default function AdminDashboard() {
|
|||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFacilities();
|
||||
}, []);
|
||||
useEffect(() => { fetchFacilities(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isScraping) {
|
||||
interval = setInterval(() => fetchFacilities(), 10000);
|
||||
}
|
||||
if (isScraping) interval = setInterval(() => fetchFacilities(), 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isScraping]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFacilities([]);
|
||||
}, [activeTab]);
|
||||
useEffect(() => { setSelectedFacilities([]); }, [activeTab]);
|
||||
|
||||
const filteredFacilities = useMemo(() => {
|
||||
if (statusFilter === 'alle') return facilities;
|
||||
|
|
@ -119,11 +99,8 @@ export default function AdminDashboard() {
|
|||
else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
||||
};
|
||||
|
||||
// Funksjon for å lagre inline redigering av URLer
|
||||
const handleQuickEdit = async (id: number, field: string, value: string) => {
|
||||
// "Optimistisk oppdatering" - Vi bytter teksten i skjermen med én gang så det føles lynraskt!
|
||||
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
|
||||
method: 'PATCH',
|
||||
|
|
@ -133,14 +110,17 @@ export default function AdminDashboard() {
|
|||
if (!res.ok) throw new Error("Feil ved lagring");
|
||||
} catch (e) {
|
||||
alert("Kunne ikke lagre endringen i databasen.");
|
||||
fetchFacilities(); // Henter gamle data tilbake hvis det kræsjet
|
||||
fetchFacilities();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScrapers = async () => {
|
||||
if (isScraping) { setIsScraping(false); return; }
|
||||
setIsScraping(true);
|
||||
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' : '/admin/run-membership-scraper';
|
||||
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
|
||||
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
|
||||
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
|
||||
'/admin/run-vtg-scraper';
|
||||
try {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
|
|
@ -181,9 +161,7 @@ export default function AdminDashboard() {
|
|||
fetchFacilities();
|
||||
} catch (error) {
|
||||
alert("Kunne ikke lagre endringene.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
} finally { setIsSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
|
||||
|
|
@ -191,21 +169,20 @@ export default function AdminDashboard() {
|
|||
return (
|
||||
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
|
||||
|
||||
{/* REDIGER-MODAL FOR BANESTATUS-SKRAPING */}
|
||||
{/* REDIGER-MODAL FOR BANESTATUS */}
|
||||
{editingFacility && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
{/* ... (Modal-koden er identisk som før, ingen endringer her) ... */}
|
||||
<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>
|
||||
<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 (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" />
|
||||
</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">
|
||||
|
|
@ -216,14 +193,12 @@ export default function AdminDashboard() {
|
|||
<option value="manual">🚨 Manuell (Ikke skrap)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{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} />
|
||||
</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>
|
||||
|
|
@ -246,7 +221,6 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(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>
|
||||
|
|
@ -254,7 +228,6 @@ export default function AdminDashboard() {
|
|||
</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">
|
||||
|
|
@ -276,10 +249,19 @@ export default function AdminDashboard() {
|
|||
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Mission Control">
|
||||
{isSidebarCollapsed ? 'MC' : 'Mission Control'}
|
||||
</Link>
|
||||
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskaps-vaskeriet">
|
||||
{isSidebarCollapsed ? 'V' : 'Vaskeriet'}
|
||||
</Link>
|
||||
<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">
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Vaskerier</div>
|
||||
<Link href="/admin/medlemskap" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
|
||||
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
||||
</Link>
|
||||
<Link href="/admin/greenfee" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Greenfee">
|
||||
{isSidebarCollapsed ? 'G' : 'Greenfee'}
|
||||
</Link>
|
||||
<Link href="/admin/vtg" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Veien til Golf (VTG)">
|
||||
{isSidebarCollapsed ? 'V' : 'VTG'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`hover:text-white cursor-pointer py-1 transition-colors mt-6 ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
|
||||
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -293,11 +275,6 @@ export default function AdminDashboard() {
|
|||
|
||||
{/* HOVEDINNHOLD */}
|
||||
<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]">CONTROL</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
|
||||
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8">
|
||||
<div>
|
||||
|
|
@ -309,18 +286,17 @@ export default function AdminDashboard() {
|
|||
onClick={handleRunScrapers}
|
||||
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
|
||||
${isScraping
|
||||
? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600'
|
||||
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
${isScraping ? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'}`}
|
||||
>
|
||||
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-2 mb-8 border-b-2 border-gray-100 pb-0">
|
||||
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>Banestatus</button>
|
||||
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>Medlemskap</button>
|
||||
<div className="flex gap-2 mb-8 border-b-2 border-gray-100 pb-0 overflow-x-auto hide-scrollbar">
|
||||
<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' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>Banestatus</button>
|
||||
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>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' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>Greenfee</button>
|
||||
<button onClick={() => setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>VTG-Kurs</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'banestatus' && (
|
||||
|
|
@ -337,27 +313,42 @@ export default function AdminDashboard() {
|
|||
)}
|
||||
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<table className="w-full text-left border-collapse min-w-[800px]">
|
||||
<table className="w-full text-left border-collapse min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
|
||||
<th className="pb-4 pl-4 w-10">
|
||||
<input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} />
|
||||
</th>
|
||||
<th className="pb-4 pl-4 w-10"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} /></th>
|
||||
<th className="pb-4 w-12 text-center">ID</th>
|
||||
<th className="pb-4 pr-6">Anlegg</th>
|
||||
|
||||
{activeTab === 'banestatus' ? (
|
||||
{activeTab === 'banestatus' && (
|
||||
<>
|
||||
<th className="pb-4">Konfigurasjon (URL & Selektor)</th>
|
||||
<th className="pb-4">Metode</th>
|
||||
<th className="pb-4">Siste Sjekk</th>
|
||||
<th className="pb-4">Banestatus</th>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{activeTab === 'medlemskap' && (
|
||||
<>
|
||||
<th className="pb-4">Prisside (Klikk for å redigere URL)</th>
|
||||
<th className="pb-4">Prisside (Klikk for å redigere)</th>
|
||||
<th className="pb-4">Nåværende Priser</th>
|
||||
<th className="pb-4 text-center">Utkast i Vaskeri</th>
|
||||
<th className="pb-4 text-center">Utkast</th>
|
||||
<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>
|
||||
<th className="pb-4 text-center">Utkast</th>
|
||||
<th className="pb-4">Sist Vasket</th>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'vtg' && (
|
||||
<>
|
||||
<th className="pb-4">VTG-side (Klikk for å redigere)</th>
|
||||
<th className="pb-4 w-64">Aktiv Info (Pris & Beskrivelse)</th>
|
||||
<th className="pb-4 text-center">Utkast</th>
|
||||
<th className="pb-4">Sist Vasket</th>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -367,32 +358,28 @@ export default function AdminDashboard() {
|
|||
|
||||
<tbody className="text-sm font-bold text-[#11280f]">
|
||||
{filteredFacilities.map((f: any) => {
|
||||
const hasDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
|
||||
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
|
||||
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
|
||||
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
|
||||
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
|
||||
|
||||
return (
|
||||
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${hasDraft && activeTab === 'medlemskap' ? 'bg-[#8bc34a]/5' : 'hover:bg-gray-50/50'}`}>
|
||||
<td className="py-6 pl-4 w-10">
|
||||
<input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.includes(f.id)} onChange={(e) => handleSelectOne(f.id, e.target.checked)} />
|
||||
</td>
|
||||
|
||||
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/5' : 'hover:bg-gray-50/50'}`}>
|
||||
<td className="py-6 pl-4 w-10"><input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.includes(f.id)} onChange={(e) => handleSelectOne(f.id, e.target.checked)} /></td>
|
||||
<td className="py-6 text-center text-xs font-mono text-gray-400">#{f.id}</td>
|
||||
|
||||
<td className="py-6 pr-6">
|
||||
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
||||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
||||
</td>
|
||||
|
||||
{activeTab === 'banestatus' ? (
|
||||
{activeTab === 'banestatus' && (
|
||||
<>
|
||||
<td className="py-6 pr-4">
|
||||
{/* HER BRUKER VI INLINE EDITOREN */}
|
||||
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
|
||||
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div>
|
||||
</td>
|
||||
<td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td>
|
||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
|
||||
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
|
||||
</td>
|
||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
||||
<td className="py-6 pr-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
|
||||
|
|
@ -410,36 +397,58 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{activeTab === 'medlemskap' && (
|
||||
<>
|
||||
<td className="py-6 pr-4">
|
||||
{/* HER BRUKER VI INLINE EDITOREN OGSÅ! */}
|
||||
<InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} />
|
||||
</td>
|
||||
<td className="py-6 pr-4"><InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} /></td>
|
||||
<td className="py-6 pr-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Standard: <strong>{f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}</strong></span>
|
||||
<span className="text-xs text-gray-500">Rimeligste: <strong>{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}</strong></span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-6 pr-4 text-center">
|
||||
{hasDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja</span> : <span className="text-gray-300">-</span>}
|
||||
<td 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</span> : <span className="text-gray-300">-</span>}</td>
|
||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto">
|
||||
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
|
||||
<div key={i}>{g.banenavn}: V: {g.pris_voksne} J: {g.pris_junior}</div>
|
||||
)) : '---'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
|
||||
{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
|
||||
<td 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</span> : <span className="text-gray-300">-</span>}</td>
|
||||
<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">
|
||||
<span className="text-xs">Pris: <strong>{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'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<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</span> : <span className="text-gray-300">-</span>}</td>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<td className="py-6 text-right pr-4">
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
{activeTab === 'banestatus' ? (
|
||||
<button onClick={() => openEditModal(f)} className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap">Ekspert-skraper</button>
|
||||
) : (
|
||||
hasDraft ? (
|
||||
<Link href="/admin/medlemskap" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap">Gå til Vaskeriet</Link>
|
||||
) : null
|
||||
)}
|
||||
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap">Ekspert-skraper</button>}
|
||||
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap">Vaskeriet</Link>}
|
||||
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap">Vaskeriet</Link>}
|
||||
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap">Vaskeriet</Link>}
|
||||
|
||||
<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>
|
||||
|
|
|
|||
208
frontend/src/app/admin/vtg/page.tsx
Normal file
208
frontend/src/app/admin/vtg/page.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"use client";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_URL } from "@/config/constants";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function VtgWasher() {
|
||||
const [drafts, setDrafts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchDrafts = () => {
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/admin/vtg/drafts`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const editableDrafts = data.map((f: any) => {
|
||||
let parsedDraft = f.vtg_draft;
|
||||
if (typeof parsedDraft === 'string') {
|
||||
try { parsedDraft = JSON.parse(parsedDraft); }
|
||||
catch (e) { console.error("Kunne ikke parse JSON", e); }
|
||||
}
|
||||
|
||||
return {
|
||||
...f,
|
||||
vtg_draft: parsedDraft,
|
||||
edit_pris: parsedDraft?.foreslatt_vtg_pris || f.vtg_pris || '',
|
||||
edit_beskrivelse: parsedDraft?.foreslatt_vtg_beskrivelse || f.vtg_beskrivelse || '',
|
||||
edit_datoer: parsedDraft?.foreslatt_vtg_datoer || []
|
||||
};
|
||||
});
|
||||
setDrafts(editableDrafts);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchDrafts(); }, []);
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
if (checked) setSelectedIds(drafts.map(d => d.id));
|
||||
else setSelectedIds([]);
|
||||
};
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id));
|
||||
else setSelectedIds([...selectedIds, id]);
|
||||
};
|
||||
|
||||
const updateField = (facilityId: number, field: string, value: any) => {
|
||||
setDrafts(drafts.map(d => d.id === facilityId ? { ...d, [field]: value } : d));
|
||||
};
|
||||
|
||||
const updateDateRow = (facilityId: number, rowIndex: number, field: string, value: string) => {
|
||||
setDrafts(drafts.map(d => {
|
||||
if (d.id === facilityId) {
|
||||
const newDates = [...d.edit_datoer];
|
||||
newDates[rowIndex] = { ...newDates[rowIndex], [field]: value };
|
||||
return { ...d, edit_datoer: newDates };
|
||||
}
|
||||
return d;
|
||||
}));
|
||||
};
|
||||
|
||||
const addDateRow = (facilityId: number) => {
|
||||
setDrafts(drafts.map(d => {
|
||||
if (d.id === facilityId) {
|
||||
return { ...d, edit_datoer: [...d.edit_datoer, { dato: '', status: 'Ledig' }] };
|
||||
}
|
||||
return d;
|
||||
}));
|
||||
};
|
||||
|
||||
const removeDateRow = (facilityId: number, rowIndex: number) => {
|
||||
setDrafts(drafts.map(d => {
|
||||
if (d.id === facilityId) {
|
||||
const newDates = [...d.edit_datoer];
|
||||
newDates.splice(rowIndex, 1);
|
||||
return { ...d, edit_datoer: newDates };
|
||||
}
|
||||
return d;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({
|
||||
facility_id: d.id,
|
||||
vtg_pris: Number(d.edit_pris) || null,
|
||||
vtg_beskrivelse: d.edit_beskrivelse,
|
||||
vtg_datoer: d.edit_datoer
|
||||
}));
|
||||
|
||||
if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne.");
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/vtg/approve-bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approvals: toApprove })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert(`${toApprove.length} anlegg oppdatert!`);
|
||||
setSelectedIds([]);
|
||||
fetchDrafts();
|
||||
} else {
|
||||
alert("Noe gikk galt under lagring.");
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Nettverksfeil");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster VTG-utkast...</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
|
||||
<div>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<h1 className="text-4xl font-black">VTG-Vaskeriet</h1>
|
||||
<p className="text-sm text-gray-600 mt-2">Gå gjennom og godkjenn kursinformasjon for Veien til Golf.</p>
|
||||
</div>
|
||||
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
|
||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="bg-white p-20 rounded-[2rem] text-center shadow-sm">
|
||||
<span className="text-6xl mb-4 block">🧹</span>
|
||||
<h2 className="text-2xl font-black text-gray-400">Ingen ventende VTG-utkast!</h2>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-4 rounded-2xl shadow-sm flex items-center gap-4">
|
||||
<input type="checkbox" className="w-5 h-5 accent-[#8bc34a] ml-2" checked={selectedIds.length === drafts.length && drafts.length > 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
|
||||
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
|
||||
</div>
|
||||
|
||||
{drafts.map(draft => (
|
||||
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
|
||||
<div className="flex-grow space-y-4">
|
||||
<div className="flex justify-between items-center border-b pb-4">
|
||||
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
|
||||
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside ↗</a>
|
||||
</div>
|
||||
|
||||
{draft.vtg_draft?.ai_begrunnelse && (
|
||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
||||
<strong>🤖 AI Begrunnelse:</strong> {draft.vtg_draft.ai_begrunnelse}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Pris & Beskrivelse */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Standardpris for Voksen (kr)</label>
|
||||
<input className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_pris} onChange={e => updateField(draft.id, 'edit_pris', e.target.value)} placeholder="Eks: 1990" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase">Selgende tekst / Inkludert i kurset</label>
|
||||
<textarea className="w-full mt-1 p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none resize-y" rows={5} value={draft.edit_beskrivelse} onChange={e => updateField(draft.id, 'edit_beskrivelse', e.target.value)} placeholder="Beskriv kurset..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kursdatoer */}
|
||||
<div>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4>
|
||||
<div className="space-y-2">
|
||||
{draft.edit_datoer.length === 0 ? (
|
||||
<div className="p-4 bg-gray-50 rounded-xl text-sm text-gray-500 italic">Fant ingen spesifikke kursdatoer.</div>
|
||||
) : (
|
||||
draft.edit_datoer.map((row: any, idx: number) => (
|
||||
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
|
||||
<input className="flex-grow p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
|
||||
<select className="w-32 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
|
||||
<option value="Ledig">Ledig</option>
|
||||
<option value="Fulltegnet">Fulltegnet</option>
|
||||
<option value="Venteliste">Venteliste</option>
|
||||
<option value="Få plasser">Få plasser</option>
|
||||
</select>
|
||||
<button onClick={() => removeDateRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett dato">✕</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<button onClick={() => addDateRow(draft.id)} className="text-xs font-bold text-[#8bc34a] hover:underline mt-2 inline-block">
|
||||
+ Legg til ny dato
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue