diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc index 8feb8a2..020672b 100644 Binary files a/backend/__pycache__/main.cpython-311.pyc and b/backend/__pycache__/main.cpython-311.pyc differ diff --git a/backend/main.py b/backend/main.py index ef2ee0d..502f1ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ """ -TEE OFF BACKEND API v3.6.9 - KOBLET PÅ ADMIN KJØR-KNAPP +TEE OFF BACKEND API v3.7.0 - KOBLET PÅ FULL ADMIN REDIGERING --------------------------------------------------------------------------- REGEL 1: Bruk str (ikke string) for type-hinting. REGEL 2: Inkluder alle subqueries for banestatus og hull-data. @@ -23,7 +23,7 @@ from dotenv import load_dotenv # NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER from pydantic import BaseModel -from typing import Optional, List +from typing import Optional, List, Any import subprocess load_dotenv() @@ -51,6 +51,17 @@ class ScrapeSettingsUpdate(BaseModel): class ScrapeRunRequest(BaseModel): facility_ids: List[int] +class MembershipDraftApproval(BaseModel): + facility_id: int + navn_standard_medlemskap: Optional[str] = None + standard_medlemskap: Optional[int] = None + standard_medlemskap_kommentarer: Optional[str] = None + navn_rimeligste_alternativ: Optional[str] = None + rimeligste_alternativ: Optional[int] = None + +class BulkApprovalRequest(BaseModel): + approvals: List[MembershipDraftApproval] + # --- FUNKSJONER --- def format_row(row): """ @@ -64,16 +75,16 @@ def format_row(row): d = dict(row) - for key in ['status_updated_at', 'created_at']: + for key in ['status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at']: if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat() - + json_list_fields = [ 'course_statuses', 'courses', 'gallery', 'greenfee', - 'faqs', 'shotzoom', 'social_links', 'holes' + 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs' ] json_dict_fields = [ - 'amenities', 'vtg', 'nsg_data', 'golfamore_data' + 'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'membership_draft' ] for field in json_list_fields: @@ -146,7 +157,7 @@ async def lifespan(app: FastAPI): # Lukk pool ved avslutning await app.state.pool.close() -app = FastAPI(title="TeeOff API v3.6.9", lifespan=lifespan) +app = FastAPI(title="TeeOff API v3.7.0", lifespan=lifespan) # CORS - Tillater både lokal utvikling og produksjonsdomene app.add_middleware( @@ -315,6 +326,84 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat raise e raise HTTPException(status_code=500, detail=str(e)) +# --- NYTT ADMIN ENDPOINT FOR FULL OPPDATERING (JSON-EDITOR) --- +@app.put("/api/admin/facilities/{facility_id}/full") +async def update_facility_full(facility_id: int, request: Request): + """Dynamisk endpoint som oppdaterer anlegg, baner og hull (den fulle editoren).""" + data = await request.json() + + # Felter som er trygge å oppdatere manuelt på anlegget + allowed_fields = [ + 'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters', + 'address', 'zipcode', 'city', 'county', 'lat', 'lng', + 'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url', + 'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url', + 'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee', + 'nsg_url', 'nsg_data', 'golfamore', 'golfamore_data', + 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', + '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' + ] + + update_data = {k: v for k, v in data.items() if k in allowed_fields} + + async with app.state.pool.acquire() as conn: + async with conn.transaction(): # Sikrer at alt lagres samlet + + # 1. OPPDATER ANLEGG (FACILITIES) + if update_data: + set_clauses = [] + values = [] + for i, (k, v) in enumerate(update_data.items(), 1): + if isinstance(v, (dict, list)): + set_clauses.append(f"{k} = ${i}::jsonb") + values.append(json.dumps(v)) + else: + set_clauses.append(f"{k} = ${i}") + values.append(v) + + values.append(facility_id) + query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}" + await conn.execute(query, *values) + + # 2. OPPDATER BANER (COURSES) OG HULL (HOLES) + courses = data.get('courses', []) + for course in courses: + course_id = course.get('id') + if course_id: + # Rens datoformat for PostgreSQL (håndterer Next.js date input) + valid_until = course.get('slope_valid_until') + if valid_until == "" or valid_until is None: + valid_until = None + + await conn.execute(""" + UPDATE courses + SET name=$1, par=$2, length_meters=$3, architect=$4, + status=$5, is_main_course=$6, tee_boxes=$7::jsonb, + slope_valid_until=$8 + WHERE id=$9 AND facility_id=$10 + """, + course.get('name'), course.get('par'), course.get('length_meters'), + course.get('architect'), course.get('status'), course.get('is_main_course'), + json.dumps(course.get('tee_boxes', {})), valid_until, course_id, facility_id) + + # 3. OPPDATER HULL PÅ BANEN (HOLES) + holes = course.get('holes', []) + for hole in holes: + hole_id = hole.get('id') + if hole_id: + await conn.execute(""" + UPDATE holes + SET par=$1, hcp_index=$2, lengths=$3::jsonb + WHERE id=$4 AND course_id=$5 + """, + hole.get('par'), hole.get('hcp_index'), + json.dumps(hole.get('lengths', {})), hole_id, course_id) + + return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."} + # --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER --- @app.post("/api/admin/run-scraper") async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): @@ -327,12 +416,10 @@ async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: Back print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}") - # Her starter vi selve magien: Vi legger jobben i FastAPIs BackgroundTasks background_tasks.add_task(run_scrape_worker, request.facility_ids) return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."} - @app.get("/api/health") async def health_check(): """Enkel sjekk for å se at API og DB lever.""" @@ -343,6 +430,49 @@ async def health_check(): except Exception as e: return {"status": "unhealthy", "error": str(e)} +# --- MEDLEMSKAP "VASKERI" ENDEPUNKTER --- + +@app.get("/api/admin/membership/drafts") +async def get_membership_drafts(): + """Henter alle anlegg som har et ventende forslag fra AI-skraperen.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, slug, medlemskap_url, + navn_standard_medlemskap, standard_medlemskap, + navn_rimeligste_alternativ, rimeligste_alternativ, + membership_draft + FROM facilities + WHERE membership_draft IS NOT NULL + AND membership_draft::text != '{}' + ORDER BY name ASC + """) + return [format_row(row) for row in rows] + +@app.post("/api/admin/membership/approve-bulk") +async def approve_membership_bulk(request: BulkApprovalRequest): + """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 navn_standard_medlemskap = $1, + standard_medlemskap = $2, + standard_medlemskap_kommentarer = $3, + navn_rimeligste_alternativ = $4, + rimeligste_alternativ = $5, + membership_updated_at = NOW(), + membership_draft = NULL + WHERE id = $6 + """, + approval.navn_standard_medlemskap, + approval.standard_medlemskap, + approval.standard_medlemskap_kommentarer, + approval.navn_rimeligste_alternativ, + approval.rimeligste_alternativ, + approval.facility_id) + return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"} + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/backend/scrape_membership.py b/backend/scrape_membership.py new file mode 100644 index 0000000..f177dcd --- /dev/null +++ b/backend/scrape_membership.py @@ -0,0 +1,163 @@ +""" +TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI +--------------------------------------------------------------------------- +Går til oppgitte medlemskaps-URLer, henter ut tekst, og bruker Gemini til å +finne 'Standard' og 'Rimeligste' medlemskap basert på TeeOffs definisjoner. +Lagrer resultatet som et utkast i databasen (membership_draft). +--------------------------------------------------------------------------- +""" + +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 + +# Last inn miljøvariabler +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!") + +# Konfigurer Gemini +genai.configure(api_key=GEMINI_API_KEY) +model = genai.GenerativeModel('gemini-2.5-flash') # Eller gemini-1.5-pro avhengig av hva du har tilgang til + +async def fetch_page_text(url: str) -> str: + """Bruker Playwright for å hente all synlig tekst fra nettsiden.""" + print(f" 🌐 Laster inn: {url}") + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + # Setter timeout til 15 sekunder + await page.goto(url, wait_until="domcontentloaded", timeout=15000) + + # Hent hele HTML-innholdet + html_content = await page.content() + await browser.close() + + # Bruk BeautifulSoup til å renske ut bare den synlige teksten + soup = BeautifulSoup(html_content, 'html.parser') + # Fjern script og style tags + for script in soup(["script", "style", "nav", "footer", "header"]): + script.extract() + + text = soup.get_text(separator=' ', strip=True) + + # Begrens teksten slik at vi ikke sprenger token-grensen til AI (f.eks max 15000 tegn) + return text[:15000] + except Exception as e: + print(f" ❌ Feil ved lasting av side: {e}") + return "" + +def analyze_with_gemini(text: str, club_name: str) -> dict: + """Sender teksten til Gemini for å trekke ut priser.""" + print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...") + + prompt = f""" +Du er en ekspert på norske golfklubber og medlemskap. +Din oppgave er å lese teksten hentet fra nettsiden til "{club_name}" og trekke ut to spesifikke medlemskapspriser. + +DEFINISJONER DU MÅ FØLGE STRENGT: +1. "Standard medlemskap": Hva vil det koste for en gjennomsnittsgolfer (voksen over 25/30 år, ikke student/senior) å spille SÅ RYE VEDKOMMENDE ØNSKER (Fritt spill) på denne banen i år? +2. "Rimeligste alternativ": Det absolutt billigste medlemskapet som gir medlemskap i klubben (golfkortet), forutsatt at man aksepterer å måtte betale greenfee for hver runde man spiller. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem el.l.) + +TEKST FRA NETTSIDEN: +{text} + +OPPGAVE: +Returner KUN et gyldig JSON-objekt med følgende struktur (og ingenting annet, ingen markdown): +{{ + "foreslatt_standard_navn": "Navnet på medlemskapet (eks: Hovedmedlem Voksen)", + "foreslatt_standard_pris": 1234, + "foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)", + "foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)", + "foreslatt_rimeligste_pris": 500, + "ai_begrunnelse": "Kort forklaring på hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'." +}} + +Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Prisen SKAL være et tall (integer), ikke en tekststreng (bruk 6500, ikke "6 500"). +""" + + try: + response = model.generate_content(prompt) + raw_response = response.text.strip() + + # Rensker vekk eventuell markdown-formatering som ```json + 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_scraper(facility_ids=None): + """Hovedfunksjon som henter fra DB, skraper, og lagrer utkast.""" + print("🚀 Starter Medlemskaps-skraperen...") + + conn = await asyncpg.connect(DB_URL) + + try: + # Hent anlegg som har en url for medlemskap + query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''" + 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.") + + for facility in facilities: + fac_id = facility['id'] + name = facility['name'] + url = facility['medlemskap_url'] + + print(f"\n▶️ Behandler: {name} (ID: {fac_id})") + + # 1. Hent tekst + page_text = await fetch_page_text(url) + if not page_text or len(page_text) < 50: + print(" ⚠️ Fant for lite tekst på siden, hopper over.") + continue + + # 2. Analyser med Gemini + draft_data = analyze_with_gemini(page_text, name) + + if not draft_data: + continue + + # 3. Lagre i databasen som utkast + print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}") + + await conn.execute(""" + UPDATE facilities + SET membership_draft = $1::jsonb + WHERE id = $2 + """, json.dumps(draft_data), fac_id) + + print(" 💾 Utkast lagret i databasen!") + + finally: + await conn.close() + print("\n🏁 Skraping fullført.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Skrap medlemskapspriser 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_scraper(ids_to_scrape)) \ No newline at end of file diff --git a/fil-tre.txt b/fil-tre.txt index c1e507e..835b76a 100644 --- a/fil-tre.txt +++ b/fil-tre.txt @@ -1,11 +1,16 @@ 📁 teeoff/ + 📄 nsg.txt 📄 test_tjome.py + 📄 fil-tre.txt + 📄 losby_dump.txt 📄 seed.sql + 📄 struktur3_dump.txt 📄 eksport_script.py 📄 update_golfbox.sql 📄 docker-compose.yml 📄 schema.sql 📄 init.sql + 📄 rene_urler.txt 📁 frontend/ 📄 eslint.config.mjs 📄 next-env.d.ts @@ -678,32 +683,56 @@ 📄 FacilityDetailView.tsx 📁 admin/ 📄 page.tsx + 📁 rediger/ + 📁 [slug]/ + 📄 EditFacilityClient.tsx + 📄 page.tsx 📁 login/ 📄 page.tsx + 📁 medlemskap/ + 📄 page.tsx 📁 kode_eksport_1/ + 📄 backend_scrape_membership_py.txt 📄 frontend_src_components_Header_tsx.txt + 📄 backend_scrape_nsg_3_py.txt + 📄 frontend_src_app_admin_medlemskap_page_tsx.txt 📄 frontend_next-env_d_ts.txt 📄 frontend_src_app_layout_tsx.txt 📄 frontend_src_app_page_tsx.txt 📄 eksport_script_py.txt 📄 frontend_src_components_ScrapeMethodSelect_tsx.txt 📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt + 📄 backend_import_wp_py.txt 📄 frontend_src_middleware_ts.txt 📄 test_tjome_py.txt + 📄 backend_test_gemini_py.txt 📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt 📄 frontend_next_config_ts.txt + 📄 backend_update_admin_py.txt + 📄 backend_import_nye_felter_py.txt 📄 frontend_src_app_admin_login_page_tsx.txt 📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt + 📄 backend_main_py.txt + 📄 frontend_src_app_admin_rediger_[slug]_page_tsx.txt 📄 frontend_src_app_admin_page_tsx.txt + 📄 frontend_src_app_admin_rediger_[slug]_EditFacilityClient_tsx.txt 📄 frontend_src_app_HeroSlider_tsx.txt + 📄 backend_test_login_py.txt + 📄 backend_create_admin_py.txt + 📄 backend_sync_greenfee_py.txt + 📄 backend_scrape_status_py.txt + 📄 backend_scrape_golfamore1_3_py.txt 📄 frontend_src_app_FacilitySearch_tsx.txt 📄 frontend_src_config_constants_ts.txt + 📄 backend_import_gallery_py.txt 📁 backend/ 📄 scrape_nsg_3.py 📄 update_admin.py 📄 test_gemini.py 📄 import_gallery.py + 📄 import_nye_felter.py 📄 .env + 📄 scrape_membership.py 📄 test_login.py 📄 sync_greenfee.py 📄 scrape_status.py diff --git a/frontend/src/app/admin/medlemskap/page.tsx b/frontend/src/app/admin/medlemskap/page.tsx new file mode 100644 index 0000000..7c16052 --- /dev/null +++ b/frontend/src/app/admin/medlemskap/page.tsx @@ -0,0 +1,179 @@ +"use client"; +import { useState, useEffect } from 'react'; +import { API_URL } from "@/config/constants"; +import Link from 'next/link'; + +export default function MembershipWasher() { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState([]); + const [saving, setSaving] = useState(false); + + const fetchDrafts = () => { + setLoading(true); + fetch(`${API_URL}/admin/membership/drafts`) + .then(res => res.json()) + .then(data => { + // Konverter innkommende drafts til editerbare felter lokalt + const editableDrafts = data.map((f: any) => ({ + ...f, + edit_standard_navn: f.membership_draft?.foreslatt_standard_navn || f.navn_standard_medlemskap || "", + edit_standard_pris: f.membership_draft?.foreslatt_standard_pris || f.standard_medlemskap || "", + edit_standard_kommentar: f.membership_draft?.foreslatt_standard_kommentar || "", + edit_rimeligste_navn: f.membership_draft?.foreslatt_rimeligste_navn || f.navn_rimeligste_alternativ || "", + edit_rimeligste_pris: f.membership_draft?.foreslatt_rimeligste_pris || f.rimeligste_alternativ || "", + })); + 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 updateDraftField = (id: number, field: string, value: any) => { + setDrafts(drafts.map(d => d.id === id ? { ...d, [field]: value } : d)); + }; + + const handleApprove = async () => { + const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({ + facility_id: d.id, + navn_standard_medlemskap: d.edit_standard_navn, + standard_medlemskap: Number(d.edit_standard_pris) || null, + standard_medlemskap_kommentarer: d.edit_standard_kommentar, + navn_rimeligste_alternativ: d.edit_rimeligste_navn, + rimeligste_alternativ: Number(d.edit_rimeligste_pris) || null, + })); + + if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne."); + + setSaving(true); + try { + const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approvals: toApprove }) + }); + if (res.ok) { + alert(`${toApprove.length} anlegg ble oppdatert og lagret til live!`); + setSelectedIds([]); + fetchDrafts(); // Oppdaterer listen (fjerner de godkjente) + } else { + alert("Noe gikk galt under lagring."); + } + } catch (e) { + alert("Nettverksfeil"); + } + setSaving(false); + }; + + if (loading) return
Laster utkast...
; + + return ( +
+
+
+
+ ← Tilbake til oversikten +

Medlemskaps-Vaskeriet

+

Gå gjennom AI-ens forslag, juster hvis nødvendig, og godkjenn for å publisere. Oppdatert-dato settes automatisk i dag.

+
+ +
+ + {drafts.length === 0 ? ( +
+ 🧹 +

Alt er rent og pent!

+

Ingen ventende forslag fra AI-skraperen akkurat nå.

+
+ ) : ( +
+
+ toggleSelectAll(e.target.checked)} + /> + Velg Alle +
+ + {drafts.map(draft => ( +
+
+
+ toggleOne(draft.id)} + /> +
+
+ + {/* OPPDATERT: Navn + ID Badge */} +
+

+ {draft.name} + ID: {draft.id} +

+ Sjekk Klubbens Nettside ↗ +
+ + {draft.membership_draft?.ai_begrunnelse && ( +
+ 🤖 AI Begrunnelse: {draft.membership_draft.ai_begrunnelse} +
+ )} + +
+ {/* Standard */} +
+

Standard Medlemskap (Ubegrenset)

+
+ updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" /> + updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" /> +
+ updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" /> +

Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}

+
+ + {/* Rimeligste */} +
+

Rimeligste (Betaler Greenfee)

+
+ updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" /> + updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" /> +
+

Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}

+
+
+
+
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index c131716..cd39d83 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -57,7 +57,7 @@ export default function AdminDashboard() { return () => clearInterval(interval); }, [isScraping]); - // NYTT: Filtreringslogikken som kjører automatisk når facilities eller filteret endres + // Filtreringslogikken som kjører automatisk når facilities eller filteret endres const filteredFacilities = useMemo(() => { if (statusFilter === 'alle') return facilities; @@ -87,11 +87,10 @@ export default function AdminDashboard() { 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 + // "Velg alle" gjelder kun de anleggene som er synlige i filteret const handleSelectAll = (e: React.ChangeEvent) => { if (e.target.checked) { setSelectedFacilities(filteredFacilities.map(f => f.id)); @@ -171,12 +170,12 @@ export default function AdminDashboard() { return (
- {/* REDIGER-MODAL */} + {/* REDIGER-MODAL FOR SKRAPING */} {editingFacility && (
-

Rediger Konfigurasjon

+

Skrape-innstillinger

{editingFacility.name}

@@ -287,12 +286,17 @@ export default function AdminDashboard() {
+ ); +}; + +// KOMPONENT 2: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => { const entries = Object.entries(value || {}); @@ -35,39 +56,38 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, }; return ( -
- -
+
+ +
{entries.map(([k, v]) => ( -
+
updateKey(k, e.target.value, v)} /> updateVal(k, e.target.value)} /> - +
))}
- +
); }; -// KOMPONENT 2: Viser Arrays med objekter (som Greenfee-lister) som små pene kort +// KOMPONENT 3: Viser Arrays med objekter (som Greenfee-lister) som små pene kort const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => { const items = Array.isArray(value) ? value : []; const updateField = (index: number, key: string, val: string | number) => { const newItems = [...items]; - // Prøv å konvertere til tall hvis det gir mening (for priser) const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val; newItems[index] = { ...newItems[index], [key]: parsedVal }; onChange(newItems); @@ -85,18 +105,18 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str }; return ( -
- -
+
+ +
{items.map((item, idx) => ( -
- -
+
+ +
{templateKeys.map(key => ( -
- +
+ updateField(idx, key, e.target.value)} /> @@ -106,18 +126,199 @@ const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: str
))}
- + +
+ ); +}; + +// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN +const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => { + const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']; + + const [holes, setHoles] = useState(() => { + const h = course.holes || []; + if (h.length === 0) { + return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} })); + } + return h.sort((a: any, b: any) => a.hole_number - b.hole_number); + }); + + const [activeKeys, setActiveKeys] = useState(() => { + const keys = new Set(); + holes.forEach(h => { + if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k)); + }); + return ALL_KEYS.filter(k => keys.has(k)); + }); + + const [tees, setTees] = useState(() => { + const herrer = course.tee_boxes?.herrer || []; + const damer = course.tee_boxes?.damer || []; + const initialTees = { herrer: {} as any, damer: {} as any }; + activeKeys.forEach((key, idx) => { + initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' }; + initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' }; + }); + return initialTees; + }); + + const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => { + const updatedTeeBoxes = { + herrer: newKeys.map(k => newTees.herrer[k] || {}), + damer: newKeys.map(k => newTees.damer[k] || {}) + }; + onChange({ + ...course, + holes: newHoles, + tee_boxes: updatedTeeBoxes + }); + }; + + const toggleKey = (key: string) => { + const newKeys = activeKeys.includes(key) + ? activeKeys.filter(k => k !== key) + : ALL_KEYS.filter(k => activeKeys.includes(k) || k === key); + setActiveKeys(newKeys); + + const newTees = { ...tees }; + if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' }; + if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' }; + setTees(newTees); + syncToParent(holes, newKeys, newTees); + }; + + const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => { + const newTees = { ...tees }; + newTees[gender][key] = { ...newTees[gender][key], [field]: value }; + setTees(newTees); + syncToParent(holes, activeKeys, newTees); + }; + + const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => { + const newHoles = [...holes]; + if (lengthKey) { + newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) }; + } else { + newHoles[index][field] = value === '' ? '' : Number(value); + } + setHoles(newHoles); + syncToParent(newHoles, activeKeys, tees); + }; + + const addHole = () => { + const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }]; + setHoles(newHoles); + syncToParent(newHoles, activeKeys, tees); + }; + + const removeLastHole = () => { + const newHoles = holes.slice(0, -1); + setHoles(newHoles); + syncToParent(newHoles, activeKeys, tees); + }; + + return ( +
+
+ Aktive Utslagskolonner: + {ALL_KEYS.map(k => ( + + ))} +
+ +
+ + + + + + + {activeKeys.map(k => )} + + {/* Herrer */} + + + {activeKeys.map(k => ( + + ))} + + {/* Damer */} + + + {activeKeys.map(k => ( + + ))} + + + + {holes.map((h, idx) => ( + + + + + {activeKeys.map(k => ( + + ))} + + ))} + +
HullParHCP{k}
+ Herrer (Navn / CR / Slope) + +
+ updateTee('herrer', k, 'navn_utslag', e.target.value)} /> +
+ updateTee('herrer', k, 'baneverdi', e.target.value)} /> + updateTee('herrer', k, 'slopeverdi', e.target.value)} /> +
+
+
+ Damer (Navn / CR / Slope) + +
+ updateTee('damer', k, 'navn_utslag_damer', e.target.value)} /> +
+ updateTee('damer', k, 'baneverdi_damer', e.target.value)} /> + updateTee('damer', k, 'slopeverdi_damer', e.target.value)} /> +
+
+
{h.hole_number} updateHole(idx, 'par', e.target.value)} /> updateHole(idx, 'hcp_index', e.target.value)} /> + updateHole(idx, 'lengths', e.target.value, k)} /> +
+
+ +
+ + +
); }; -export default function EditFacilityClient({ initialData }: { initialData: any }) { +export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) { const router = useRouter(); const [formData, setFormData] = useState(initialData); const [activeTab, setActiveTab] = useState('generelt'); const [saving, setSaving] = useState(false); + // Trekk ut unike arkitekter fra alle anlegg + const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort(); + + // Sørg for at cooperating_clubs er et array + const [coopClubs, setCoopClubs] = useState( + Array.isArray(initialData.cooperating_clubs) ? initialData.cooperating_clubs : + (typeof initialData.cooperating_clubs === 'string' ? JSON.parse(initialData.cooperating_clubs) : []) + ); + const handleChange = (field: string, value: any) => { setFormData((prev: any) => ({ ...prev, [field]: value })); }; @@ -132,7 +333,7 @@ export default function EditFacilityClient({ initialData }: { initialData: any } }); if (res.ok) { - alert("Anlegget ble oppdatert suksessfullt!"); + alert("Lagret suksessfullt!"); router.refresh(); } else { alert("Noe gikk galt under lagring."); @@ -148,64 +349,116 @@ export default function EditFacilityClient({ initialData }: { initialData: any } { id: 'lokasjon', label: 'Lokasjon & Kontakt' }, { id: 'linker', label: 'Lenker & Media' }, { id: 'okonomi', label: 'Økonomi & Medlemskap' }, - { id: 'avansert', label: 'Spesial & Strukturer (JSON)' } + { id: 'baner', label: 'Baner & Scorekort' } ]; - const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => ( -
- - {type === 'textarea' ? ( -