From 64d15ebd4e291e63b5b74baf1ec1ae8f15df8f20 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 26 Apr 2026 21:49:30 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20korrigering=20av=20golfpakker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 525 ++++++++++ frontend/src/app/admin/page.tsx | 6 + .../simulatorer/SimulatorAdminClient.tsx | 931 ++++++++++++++++++ frontend/src/app/admin/simulatorer/page.tsx | 5 + frontend/src/components/AdminMobileMenu.tsx | 1 + 5 files changed, 1468 insertions(+) create mode 100644 frontend/src/app/admin/simulatorer/SimulatorAdminClient.tsx create mode 100644 frontend/src/app/admin/simulatorer/page.tsx diff --git a/backend/main.py b/backend/main.py index 9c53983..c1eac2d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -656,6 +656,48 @@ class FacilityVisibilityRequest(BaseModel): class PlacePageUpsertRequest(BaseModel): factbox_intro_html: Optional[str] = "" + +class SimulatorOperatorUpsertRequest(BaseModel): + name: str + slug: Optional[str] = None + website_url: Optional[str] = None + logo_url: Optional[str] = None + description: Optional[str] = None + is_published: bool = False + + +class SimulatorVenueUpsertRequest(BaseModel): + operator_id: Optional[int] = None + facility_id: Optional[int] = None + name: str + slug: Optional[str] = None + venue_type: str + description: Optional[str] = None + city: Optional[str] = None + county: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + lat: Optional[float] = None + lng: Optional[float] = None + website_url: Optional[str] = None + booking_url: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + image_url: Optional[str] = None + simulator_systems: Optional[List[str]] = [] + bay_count: Optional[int] = Field(default=None, ge=0) + lessons_available: Optional[bool] = None + club_fitting: Optional[bool] = None + food_and_drink: Optional[bool] = None + serves_alcohol: Optional[bool] = None + drop_in: Optional[bool] = None + membership_required: Optional[bool] = None + opening_hours: Optional[str] = None + price_from: Optional[float] = Field(default=None, ge=0) + season: Optional[str] = None + is_published: bool = False + + class GreenfeeApproval(BaseModel): facility_id: int greenfee: List[dict] @@ -832,6 +874,14 @@ FACILITY_VIEW_VTG_FIELDS = { FACILITY_VIEW_CLUBNUMBERS_FIELDS = {'id', 'slug', 'name', 'city', 'county', 'ngf_number'} FACILITY_VIEW_SITEMAP_FIELDS = {'slug', 'status_updated_at', 'vtg_updated_at'} FACILITY_VIEW_ALIASES_FIELDS = {'slug', 'name'} +SIMULATOR_VENUE_TYPES = { + "golfanlegg", + "simulatorsenter", + "pub", + "butikk", + "hotell", + "annet", +} # --- FUNKSJONER --- def format_row(row): """ @@ -994,6 +1044,280 @@ def format_place_page_row(row): return d +def format_simulator_operator_row(row): + if row is None: + return None + + data = dict(row) + for key in ["created_at", "updated_at"]: + if isinstance(data.get(key), (date, datetime)): + data[key] = data[key].isoformat() + + return data + + +def format_simulator_venue_row(row): + if row is None: + return None + + data = dict(row) + for key in ["created_at", "updated_at"]: + if isinstance(data.get(key), (date, datetime)): + data[key] = data[key].isoformat() + + simulator_systems = data.get("simulator_systems") + if simulator_systems is None: + data["simulator_systems"] = [] + elif isinstance(simulator_systems, str): + try: + parsed = json.loads(simulator_systems) + data["simulator_systems"] = parsed if isinstance(parsed, list) else [] + except Exception: + data["simulator_systems"] = [] + elif not isinstance(simulator_systems, list): + data["simulator_systems"] = [] + + return data + + +def normalize_optional_text(value: Any) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def normalize_simulator_systems(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + + normalized: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value or "").strip() + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + normalized.append(text) + + return normalized + + +def normalize_simulator_venue_type(value: str | None) -> str: + normalized = str(value or "").strip().lower() + if normalized not in SIMULATOR_VENUE_TYPES: + raise HTTPException( + status_code=400, + detail="Ugyldig simulatorsted-type.", + ) + return normalized + + +async def save_simulator_operator(conn, request: SimulatorOperatorUpsertRequest, operator_id: int | None = None): + name = str(request.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Operatørnavn mangler.") + + slug = normalize_facility_slug(request.slug or name) + if not slug: + raise HTTPException(status_code=400, detail="Slug mangler eller er ugyldig.") + + existing = await conn.fetchval( + """ + SELECT id + FROM simulator_operators + WHERE slug = $1 + AND ($2::int IS NULL OR id <> $2) + """, + slug, + operator_id, + ) + if existing: + raise HTTPException(status_code=409, detail="Slug er allerede i bruk.") + + if operator_id is None: + return await conn.fetchrow( + """ + INSERT INTO simulator_operators ( + name, slug, website_url, logo_url, description, is_published + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + """, + name, + slug, + normalize_optional_text(request.website_url), + normalize_optional_text(request.logo_url), + normalize_optional_text(request.description), + bool(request.is_published), + ) + + row = await conn.fetchrow( + """ + UPDATE simulator_operators + SET name = $1, + slug = $2, + website_url = $3, + logo_url = $4, + description = $5, + is_published = $6, + updated_at = NOW() + WHERE id = $7 + RETURNING * + """, + name, + slug, + normalize_optional_text(request.website_url), + normalize_optional_text(request.logo_url), + normalize_optional_text(request.description), + bool(request.is_published), + operator_id, + ) + if not row: + raise HTTPException(status_code=404, detail="Simulatoroperatøren ble ikke funnet.") + return row + + +async def save_simulator_venue(conn, request: SimulatorVenueUpsertRequest, venue_id: int | None = None): + name = str(request.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Navn på simulatorsted mangler.") + + slug = normalize_facility_slug(request.slug or name) + if not slug: + raise HTTPException(status_code=400, detail="Slug mangler eller er ugyldig.") + + venue_type = normalize_simulator_venue_type(request.venue_type) + operator_id = int(request.operator_id) if request.operator_id else None + facility_id = int(request.facility_id) if request.facility_id else None + + existing = await conn.fetchval( + """ + SELECT id + FROM simulator_venues + WHERE slug = $1 + AND ($2::int IS NULL OR id <> $2) + """, + slug, + venue_id, + ) + if existing: + raise HTTPException(status_code=409, detail="Slug er allerede i bruk.") + + if operator_id is not None: + operator_exists = await conn.fetchval( + "SELECT id FROM simulator_operators WHERE id = $1", + operator_id, + ) + if not operator_exists: + raise HTTPException(status_code=404, detail="Simulatoroperatøren ble ikke funnet.") + + if facility_id is not None: + facility_exists = await conn.fetchval( + "SELECT id FROM facilities WHERE id = $1", + facility_id, + ) + if not facility_exists: + raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet.") + + query = """ + {statement} + RETURNING * + """ + + values = [ + operator_id, + facility_id, + name, + slug, + venue_type, + normalize_optional_text(request.description), + normalize_optional_text(request.city), + normalize_optional_text(request.county), + normalize_optional_text(request.address), + normalize_optional_text(request.postal_code), + request.lat, + request.lng, + normalize_optional_text(request.website_url), + normalize_optional_text(request.booking_url), + normalize_optional_text(request.phone), + normalize_optional_text(request.email), + normalize_optional_text(request.image_url), + json.dumps(normalize_simulator_systems(request.simulator_systems)), + request.bay_count, + request.lessons_available, + request.club_fitting, + request.food_and_drink, + request.serves_alcohol, + request.drop_in, + request.membership_required, + normalize_optional_text(request.opening_hours), + request.price_from, + normalize_optional_text(request.season), + bool(request.is_published), + ] + + if venue_id is None: + statement = """ + INSERT INTO simulator_venues ( + operator_id, facility_id, name, slug, venue_type, description, + city, county, address, postal_code, lat, lng, + website_url, booking_url, phone, email, image_url, simulator_systems, + bay_count, lessons_available, club_fitting, food_and_drink, + serves_alcohol, drop_in, membership_required, opening_hours, + price_from, season, is_published + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, $18::jsonb, + $19, $20, $21, $22, + $23, $24, $25, $26, + $27, $28, $29 + ) + """ + else: + statement = """ + UPDATE simulator_venues + SET operator_id = $1, + facility_id = $2, + name = $3, + slug = $4, + venue_type = $5, + description = $6, + city = $7, + county = $8, + address = $9, + postal_code = $10, + lat = $11, + lng = $12, + website_url = $13, + booking_url = $14, + phone = $15, + email = $16, + image_url = $17, + simulator_systems = $18::jsonb, + bay_count = $19, + lessons_available = $20, + club_fitting = $21, + food_and_drink = $22, + serves_alcohol = $23, + drop_in = $24, + membership_required = $25, + opening_hours = $26, + price_from = $27, + season = $28, + is_published = $29, + updated_at = NOW() + WHERE id = $30 + """ + values.append(venue_id) + + row = await conn.fetchrow(query.format(statement=statement), *values) + if venue_id is not None and not row: + raise HTTPException(status_code=404, detail="Simulatorstedet ble ikke funnet.") + return row + + def normalize_club_lookup_value(value: str | None) -> str: text = str(value or "").strip().lower() if not text: @@ -2222,6 +2546,81 @@ async def ensure_public_user_tables(conn): """) +async def ensure_simulator_operator_tables(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS simulator_operators ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + website_url TEXT, + logo_url TEXT, + description TEXT, + is_published BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS simulator_operators_name_idx + ON simulator_operators (name) + """) + + +async def ensure_simulator_venue_tables(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS simulator_venues ( + id SERIAL PRIMARY KEY, + operator_id INTEGER REFERENCES simulator_operators(id) ON DELETE SET NULL, + facility_id INTEGER REFERENCES facilities(id) ON DELETE SET NULL, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + venue_type VARCHAR(64) NOT NULL, + description TEXT, + city VARCHAR(255), + county VARCHAR(255), + address TEXT, + postal_code VARCHAR(32), + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + website_url TEXT, + booking_url TEXT, + phone VARCHAR(64), + email VARCHAR(255), + image_url TEXT, + simulator_systems JSONB NOT NULL DEFAULT '[]'::jsonb, + bay_count INTEGER, + lessons_available BOOLEAN, + club_fitting BOOLEAN, + food_and_drink BOOLEAN, + serves_alcohol BOOLEAN, + drop_in BOOLEAN, + membership_required BOOLEAN, + opening_hours TEXT, + price_from DOUBLE PRECISION, + season VARCHAR(255), + is_published BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS simulator_venues_name_idx + ON simulator_venues (name) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS simulator_venues_operator_id_idx + ON simulator_venues (operator_id) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS simulator_venues_facility_id_idx + ON simulator_venues (facility_id) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS simulator_venues_public_location_idx + ON simulator_venues (is_published, county, city, name) + """) + + @asynccontextmanager async def lifespan(app: FastAPI): # Opprett database-pool ved start @@ -2239,6 +2638,8 @@ async def lifespan(app: FastAPI): await ensure_place_pages_table(conn) await ensure_articles_table(conn) await ensure_public_user_tables(conn) + await ensure_simulator_operator_tables(conn) + await ensure_simulator_venue_tables(conn) await ensure_scrape_jobs_table(conn) await ensure_course_status_history_table(conn) await ensure_weather_forecast_table(conn) @@ -3807,6 +4208,130 @@ async def create_article_comment( # --- ADMIN ENDPOINTS --- +@app.get("/api/admin/simulator/operators") +async def get_admin_simulator_operators(): + async with app.state.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT * + FROM simulator_operators + ORDER BY name ASC, id ASC + """ + ) + return [format_simulator_operator_row(row) for row in rows] + + +@app.get("/api/admin/simulator/operator-options") +async def get_admin_simulator_operator_options(): + async with app.state.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, name, slug + FROM simulator_operators + ORDER BY name ASC, id ASC + """ + ) + return [format_simulator_operator_row(row) for row in rows] + + +@app.post("/api/admin/simulator/operators") +async def create_admin_simulator_operator(request: SimulatorOperatorUpsertRequest): + async with app.state.pool.acquire() as conn: + row = await save_simulator_operator(conn, request) + return format_simulator_operator_row(row) + + +@app.put("/api/admin/simulator/operators/{operator_id}") +async def update_admin_simulator_operator(operator_id: int, request: SimulatorOperatorUpsertRequest): + async with app.state.pool.acquire() as conn: + row = await save_simulator_operator(conn, request, operator_id=operator_id) + return format_simulator_operator_row(row) + + +@app.delete("/api/admin/simulator/operators/{operator_id}") +async def delete_admin_simulator_operator(operator_id: int): + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + """ + DELETE FROM simulator_operators + WHERE id = $1 + RETURNING * + """, + operator_id, + ) + if not row: + raise HTTPException(status_code=404, detail="Simulatoroperatøren ble ikke funnet.") + return { + "status": "success", + "operator": format_simulator_operator_row(row), + } + + +@app.get("/api/admin/simulator/facility-options") +async def get_admin_simulator_facility_options(): + async with app.state.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, name, slug, city, county + FROM facilities + ORDER BY name ASC, id ASC + """ + ) + return [format_row(row) for row in rows] + + +@app.get("/api/admin/simulator/venues") +async def get_admin_simulator_venues(): + async with app.state.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT + v.*, + o.name AS operator_name, + f.name AS facility_name, + f.slug AS facility_slug + FROM simulator_venues v + LEFT JOIN simulator_operators o ON o.id = v.operator_id + LEFT JOIN facilities f ON f.id = v.facility_id + ORDER BY v.updated_at DESC, v.name ASC, v.id ASC + """ + ) + return [format_simulator_venue_row(row) for row in rows] + + +@app.post("/api/admin/simulator/venues") +async def create_admin_simulator_venue(request: SimulatorVenueUpsertRequest): + async with app.state.pool.acquire() as conn: + row = await save_simulator_venue(conn, request) + return format_simulator_venue_row(row) + + +@app.put("/api/admin/simulator/venues/{venue_id}") +async def update_admin_simulator_venue(venue_id: int, request: SimulatorVenueUpsertRequest): + async with app.state.pool.acquire() as conn: + row = await save_simulator_venue(conn, request, venue_id=venue_id) + return format_simulator_venue_row(row) + + +@app.delete("/api/admin/simulator/venues/{venue_id}") +async def delete_admin_simulator_venue(venue_id: int): + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow( + """ + DELETE FROM simulator_venues + WHERE id = $1 + RETURNING * + """, + venue_id, + ) + if not row: + raise HTTPException(status_code=404, detail="Simulatorstedet ble ikke funnet.") + return { + "status": "success", + "venue": format_simulator_venue_row(row), + } + + @app.get("/api/admin/articles") async def get_admin_articles(status: Optional[str] = Query(default="all")): """Henter artikler for admin med valgfritt statusfilter.""" diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index ab5f585..896832c 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1347,6 +1347,9 @@ export default function AdminDashboard() { setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> Golfpakker + setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> + Simulatorer + setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> VTG @@ -1410,6 +1413,9 @@ export default function AdminDashboard() { {isSidebarCollapsed ? 'GP' : 'Golfpakker'} + + {isSidebarCollapsed ? 'SI' : 'Simulatorer'} + {isSidebarCollapsed ? 'V' : 'VTG'} diff --git a/frontend/src/app/admin/simulatorer/SimulatorAdminClient.tsx b/frontend/src/app/admin/simulatorer/SimulatorAdminClient.tsx new file mode 100644 index 0000000..34419bf --- /dev/null +++ b/frontend/src/app/admin/simulatorer/SimulatorAdminClient.tsx @@ -0,0 +1,931 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import AdminMobileMenu from "@/components/AdminMobileMenu"; +import { API_URL } from "@/config/constants"; +import { adminFetch } from "@/config/adminFetch"; + +type SimulatorOperator = { + id: number; + name: string; + slug: string; + website_url: string | null; + logo_url: string | null; + description: string | null; + is_published: boolean; + created_at: string | null; + updated_at: string | null; +}; + +type FacilityOption = { + id: number; + name: string; + slug: string; + city: string | null; + county: string | null; +}; + +type SimulatorVenue = { + id: number; + operator_id: number | null; + facility_id: number | null; + name: string; + slug: string; + venue_type: string; + description: string | null; + city: string | null; + county: string | null; + address: string | null; + postal_code: string | null; + lat: number | null; + lng: number | null; + website_url: string | null; + booking_url: string | null; + phone: string | null; + email: string | null; + image_url: string | null; + simulator_systems: string[]; + bay_count: number | null; + lessons_available: boolean | null; + club_fitting: boolean | null; + food_and_drink: boolean | null; + serves_alcohol: boolean | null; + drop_in: boolean | null; + membership_required: boolean | null; + opening_hours: string | null; + price_from: number | null; + season: string | null; + is_published: boolean; + operator_name?: string | null; + facility_name?: string | null; + facility_slug?: string | null; + created_at: string | null; + updated_at: string | null; +}; + +type OperatorFormState = { + id: number | null; + name: string; + slug: string; + website_url: string; + logo_url: string; + description: string; + is_published: boolean; +}; + +type VenueFormState = { + id: number | null; + operator_id: string; + facility_id: string; + name: string; + slug: string; + venue_type: string; + description: string; + city: string; + county: string; + address: string; + postal_code: string; + lat: string; + lng: string; + website_url: string; + booking_url: string; + phone: string; + email: string; + image_url: string; + simulator_systems: string; + bay_count: string; + lessons_available: boolean; + club_fitting: boolean; + food_and_drink: boolean; + serves_alcohol: boolean; + drop_in: boolean; + membership_required: boolean; + opening_hours: string; + price_from: string; + season: string; + is_published: boolean; +}; + +const VENUE_TYPE_OPTIONS = [ + { value: "simulatorsenter", label: "Simulatorsenter" }, + { value: "golfanlegg", label: "Golfanlegg" }, + { value: "pub", label: "Pub" }, + { value: "butikk", label: "Butikk" }, + { value: "hotell", label: "Hotell" }, + { value: "annet", label: "Annet" }, +]; + +const DEFAULT_OPERATOR_FORM: OperatorFormState = { + id: null, + name: "", + slug: "", + website_url: "", + logo_url: "", + description: "", + is_published: false, +}; + +const DEFAULT_VENUE_FORM: VenueFormState = { + id: null, + operator_id: "", + facility_id: "", + name: "", + slug: "", + venue_type: "simulatorsenter", + description: "", + city: "", + county: "", + address: "", + postal_code: "", + lat: "", + lng: "", + website_url: "", + booking_url: "", + phone: "", + email: "", + image_url: "", + simulator_systems: "", + bay_count: "", + lessons_available: false, + club_fitting: false, + food_and_drink: false, + serves_alcohol: false, + drop_in: false, + membership_required: false, + opening_hours: "", + price_from: "", + season: "", + is_published: false, +}; + +function operatorToFormState(operator: SimulatorOperator): OperatorFormState { + return { + id: operator.id, + name: operator.name || "", + slug: operator.slug || "", + website_url: operator.website_url || "", + logo_url: operator.logo_url || "", + description: operator.description || "", + is_published: Boolean(operator.is_published), + }; +} + +function venueToFormState(venue: SimulatorVenue): VenueFormState { + return { + id: venue.id, + operator_id: venue.operator_id ? String(venue.operator_id) : "", + facility_id: venue.facility_id ? String(venue.facility_id) : "", + name: venue.name || "", + slug: venue.slug || "", + venue_type: venue.venue_type || "simulatorsenter", + description: venue.description || "", + city: venue.city || "", + county: venue.county || "", + address: venue.address || "", + postal_code: venue.postal_code || "", + lat: venue.lat == null ? "" : String(venue.lat), + lng: venue.lng == null ? "" : String(venue.lng), + website_url: venue.website_url || "", + booking_url: venue.booking_url || "", + phone: venue.phone || "", + email: venue.email || "", + image_url: venue.image_url || "", + simulator_systems: Array.isArray(venue.simulator_systems) ? venue.simulator_systems.join(", ") : "", + bay_count: venue.bay_count == null ? "" : String(venue.bay_count), + lessons_available: Boolean(venue.lessons_available), + club_fitting: Boolean(venue.club_fitting), + food_and_drink: Boolean(venue.food_and_drink), + serves_alcohol: Boolean(venue.serves_alcohol), + drop_in: Boolean(venue.drop_in), + membership_required: Boolean(venue.membership_required), + opening_hours: venue.opening_hours || "", + price_from: venue.price_from == null ? "" : String(venue.price_from), + season: venue.season || "", + is_published: Boolean(venue.is_published), + }; +} + +function parseOptionalNumber(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed.replace(",", ".")); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseOptionalInteger(value: string): number | null { + const parsed = parseOptionalNumber(value); + return parsed == null ? null : Math.round(parsed); +} + +function splitSystems(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +export default function SimulatorAdminClient() { + const [operators, setOperators] = useState([]); + const [venues, setVenues] = useState([]); + const [facilityOptions, setFacilityOptions] = useState([]); + const [loading, setLoading] = useState(true); + const [savingOperator, setSavingOperator] = useState(false); + const [savingVenue, setSavingVenue] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [operatorForm, setOperatorForm] = useState(DEFAULT_OPERATOR_FORM); + const [venueForm, setVenueForm] = useState(DEFAULT_VENUE_FORM); + + const refreshData = async () => { + setLoading(true); + setError(null); + + try { + const [operatorsResponse, venuesResponse, facilitiesResponse] = await Promise.all([ + adminFetch(`${API_URL}/admin/simulator/operators`), + adminFetch(`${API_URL}/admin/simulator/venues`), + adminFetch(`${API_URL}/admin/simulator/facility-options`), + ]); + + if (!operatorsResponse.ok || !venuesResponse.ok || !facilitiesResponse.ok) { + throw new Error("Kunne ikke hente simulatorinnholdet."); + } + + const [operatorsData, venuesData, facilitiesData] = await Promise.all([ + operatorsResponse.json(), + venuesResponse.json(), + facilitiesResponse.json(), + ]); + + setOperators(Array.isArray(operatorsData) ? operatorsData : []); + setVenues(Array.isArray(venuesData) ? venuesData : []); + setFacilityOptions(Array.isArray(facilitiesData) ? facilitiesData : []); + } catch (fetchError) { + console.error(fetchError); + setError("Kunne ikke laste simulatoradmin akkurat nå."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refreshData(); + }, []); + + const handleOperatorFieldChange = (field: keyof OperatorFormState, value: string | boolean | number | null) => { + setOperatorForm((current) => ({ ...current, [field]: value })); + }; + + const handleVenueFieldChange = (field: keyof VenueFormState, value: string | boolean | number | null) => { + setVenueForm((current) => ({ ...current, [field]: value })); + }; + + const resetOperatorForm = () => setOperatorForm(DEFAULT_OPERATOR_FORM); + const resetVenueForm = () => setVenueForm(DEFAULT_VENUE_FORM); + + const submitOperator = async (event: React.FormEvent) => { + event.preventDefault(); + setSavingOperator(true); + setError(null); + setMessage(null); + + try { + const payload = { + name: operatorForm.name, + slug: operatorForm.slug, + website_url: operatorForm.website_url, + logo_url: operatorForm.logo_url, + description: operatorForm.description, + is_published: operatorForm.is_published, + }; + + const endpoint = operatorForm.id + ? `${API_URL}/admin/simulator/operators/${operatorForm.id}` + : `${API_URL}/admin/simulator/operators`; + const method = operatorForm.id ? "PUT" : "POST"; + + const response = await adminFetch(endpoint, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.detail || "Kunne ikke lagre simulatoroperatøren."); + } + + setMessage(operatorForm.id ? "Simulatoroperatøren ble oppdatert." : "Simulatoroperatøren ble opprettet."); + resetOperatorForm(); + await refreshData(); + } catch (submitError) { + console.error(submitError); + setError(submitError instanceof Error ? submitError.message : "Kunne ikke lagre simulatoroperatøren."); + } finally { + setSavingOperator(false); + } + }; + + const submitVenue = async (event: React.FormEvent) => { + event.preventDefault(); + setSavingVenue(true); + setError(null); + setMessage(null); + + try { + const payload = { + operator_id: venueForm.operator_id ? Number(venueForm.operator_id) : null, + facility_id: venueForm.facility_id ? Number(venueForm.facility_id) : null, + name: venueForm.name, + slug: venueForm.slug, + venue_type: venueForm.venue_type, + description: venueForm.description, + city: venueForm.city, + county: venueForm.county, + address: venueForm.address, + postal_code: venueForm.postal_code, + lat: parseOptionalNumber(venueForm.lat), + lng: parseOptionalNumber(venueForm.lng), + website_url: venueForm.website_url, + booking_url: venueForm.booking_url, + phone: venueForm.phone, + email: venueForm.email, + image_url: venueForm.image_url, + simulator_systems: splitSystems(venueForm.simulator_systems), + bay_count: parseOptionalInteger(venueForm.bay_count), + lessons_available: venueForm.lessons_available, + club_fitting: venueForm.club_fitting, + food_and_drink: venueForm.food_and_drink, + serves_alcohol: venueForm.serves_alcohol, + drop_in: venueForm.drop_in, + membership_required: venueForm.membership_required, + opening_hours: venueForm.opening_hours, + price_from: parseOptionalNumber(venueForm.price_from), + season: venueForm.season, + is_published: venueForm.is_published, + }; + + const endpoint = venueForm.id + ? `${API_URL}/admin/simulator/venues/${venueForm.id}` + : `${API_URL}/admin/simulator/venues`; + const method = venueForm.id ? "PUT" : "POST"; + + const response = await adminFetch(endpoint, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.detail || "Kunne ikke lagre simulatorstedet."); + } + + setMessage(venueForm.id ? "Simulatorstedet ble oppdatert." : "Simulatorstedet ble opprettet."); + resetVenueForm(); + await refreshData(); + } catch (submitError) { + console.error(submitError); + setError(submitError instanceof Error ? submitError.message : "Kunne ikke lagre simulatorstedet."); + } finally { + setSavingVenue(false); + } + }; + + const deleteOperator = async (operatorId: number) => { + if (!window.confirm("Slette denne simulatoroperatøren? Tilknyttede steder mister bare koblingen.")) return; + + setError(null); + setMessage(null); + try { + const response = await adminFetch(`${API_URL}/admin/simulator/operators/${operatorId}`, { + method: "DELETE", + }); + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.detail || "Kunne ikke slette simulatoroperatøren."); + } + + if (operatorForm.id === operatorId) { + resetOperatorForm(); + } + setMessage("Simulatoroperatøren ble slettet."); + await refreshData(); + } catch (deleteError) { + console.error(deleteError); + setError(deleteError instanceof Error ? deleteError.message : "Kunne ikke slette simulatoroperatøren."); + } + }; + + const deleteVenue = async (venueId: number) => { + if (!window.confirm("Slette dette simulatorstedet?")) return; + + setError(null); + setMessage(null); + try { + const response = await adminFetch(`${API_URL}/admin/simulator/venues/${venueId}`, { + method: "DELETE", + }); + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.detail || "Kunne ikke slette simulatorstedet."); + } + + if (venueForm.id === venueId) { + resetVenueForm(); + } + setMessage("Simulatorstedet ble slettet."); + await refreshData(); + } catch (deleteError) { + console.error(deleteError); + setError(deleteError instanceof Error ? deleteError.message : "Kunne ikke slette simulatorstedet."); + } + }; + + if (loading) { + return
Laster simulatoradmin...
; + } + + return ( +
+
+ + +
+
+ + ← Tilbake til oversikten + +

Simulatorer

+

+ Skjult fundament for simulatoroperatører og simulatorsteder. Ingenting her er offentlig koblet inn ennå, og nye rader starter som upubliserte. +

+
+ +
+
+
Operatører
+
{operators.length}
+
+
+
Steder
+
{venues.length}
+
+
+
Publiserte
+
+ {venues.filter((venue) => venue.is_published).length} +
+
+
+
+ + {message && ( +
+ {message} +
+ )} + {error && ( +
+ {error} +
+ )} + +
+
+
+
+

Operatører

+

+ {operatorForm.id ? "Rediger operatør" : "Ny operatør"} +

+
+ {operatorForm.id && ( + + )} +
+ +
+
+ + handleOperatorFieldChange("name", event.target.value)} + placeholder="Indoor Golf Norge" + required + /> +
+ +
+ + handleOperatorFieldChange("slug", event.target.value)} + placeholder="indoor-golf-norge" + /> +
+ +
+ + handleOperatorFieldChange("website_url", event.target.value)} + placeholder="https://..." + /> +
+ +
+ + handleOperatorFieldChange("logo_url", event.target.value)} + placeholder="https://..." + /> +
+ +
+ +