Før korrigering av golfpakker

This commit is contained in:
Erol Haagenrud 2026-04-26 21:49:30 +02:00
parent 9af374adda
commit 64d15ebd4e
5 changed files with 1468 additions and 0 deletions

View file

@ -656,6 +656,48 @@ class FacilityVisibilityRequest(BaseModel):
class PlacePageUpsertRequest(BaseModel): class PlacePageUpsertRequest(BaseModel):
factbox_intro_html: Optional[str] = "" 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): class GreenfeeApproval(BaseModel):
facility_id: int facility_id: int
greenfee: List[dict] greenfee: List[dict]
@ -832,6 +874,14 @@ FACILITY_VIEW_VTG_FIELDS = {
FACILITY_VIEW_CLUBNUMBERS_FIELDS = {'id', 'slug', 'name', 'city', 'county', 'ngf_number'} FACILITY_VIEW_CLUBNUMBERS_FIELDS = {'id', 'slug', 'name', 'city', 'county', 'ngf_number'}
FACILITY_VIEW_SITEMAP_FIELDS = {'slug', 'status_updated_at', 'vtg_updated_at'} FACILITY_VIEW_SITEMAP_FIELDS = {'slug', 'status_updated_at', 'vtg_updated_at'}
FACILITY_VIEW_ALIASES_FIELDS = {'slug', 'name'} FACILITY_VIEW_ALIASES_FIELDS = {'slug', 'name'}
SIMULATOR_VENUE_TYPES = {
"golfanlegg",
"simulatorsenter",
"pub",
"butikk",
"hotell",
"annet",
}
# --- FUNKSJONER --- # --- FUNKSJONER ---
def format_row(row): def format_row(row):
""" """
@ -994,6 +1044,280 @@ def format_place_page_row(row):
return d 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: def normalize_club_lookup_value(value: str | None) -> str:
text = str(value or "").strip().lower() text = str(value or "").strip().lower()
if not text: 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Opprett database-pool ved start # Opprett database-pool ved start
@ -2239,6 +2638,8 @@ async def lifespan(app: FastAPI):
await ensure_place_pages_table(conn) await ensure_place_pages_table(conn)
await ensure_articles_table(conn) await ensure_articles_table(conn)
await ensure_public_user_tables(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_scrape_jobs_table(conn)
await ensure_course_status_history_table(conn) await ensure_course_status_history_table(conn)
await ensure_weather_forecast_table(conn) await ensure_weather_forecast_table(conn)
@ -3807,6 +4208,130 @@ async def create_article_comment(
# --- ADMIN ENDPOINTS --- # --- 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") @app.get("/api/admin/articles")
async def get_admin_articles(status: Optional[str] = Query(default="all")): async def get_admin_articles(status: Optional[str] = Query(default="all")):
"""Henter artikler for admin med valgfritt statusfilter.""" """Henter artikler for admin med valgfritt statusfilter."""

View file

@ -1347,6 +1347,9 @@ export default function AdminDashboard() {
<Link href="/admin/golfpakker" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> <Link href="/admin/golfpakker" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Golfpakker Golfpakker
</Link> </Link>
<Link href="/admin/simulatorer" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Simulatorer
</Link>
<Link href="/admin/vtg" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> <Link href="/admin/vtg" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
VTG VTG
</Link> </Link>
@ -1410,6 +1413,9 @@ export default function AdminDashboard() {
<Link href="/admin/golfpakker" 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="Golfpakker"> <Link href="/admin/golfpakker" 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="Golfpakker">
{isSidebarCollapsed ? 'GP' : 'Golfpakker'} {isSidebarCollapsed ? 'GP' : 'Golfpakker'}
</Link> </Link>
<Link href="/admin/simulatorer" 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="Simulatorer">
{isSidebarCollapsed ? 'SI' : 'Simulatorer'}
</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)"> <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'} {isSidebarCollapsed ? 'V' : 'VTG'}
</Link> </Link>

View file

@ -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<SimulatorOperator[]>([]);
const [venues, setVenues] = useState<SimulatorVenue[]>([]);
const [facilityOptions, setFacilityOptions] = useState<FacilityOption[]>([]);
const [loading, setLoading] = useState(true);
const [savingOperator, setSavingOperator] = useState(false);
const [savingVenue, setSavingVenue] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [operatorForm, setOperatorForm] = useState<OperatorFormState>(DEFAULT_OPERATOR_FORM);
const [venueForm, setVenueForm] = useState<VenueFormState>(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<HTMLFormElement>) => {
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<HTMLFormElement>) => {
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 <div className="p-20 text-center font-black animate-pulse">Laster simulatoradmin...</div>;
}
return (
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
<div className="mx-auto max-w-[1500px]">
<AdminMobileMenu />
<div className="mb-10 flex flex-col gap-5 border-b border-gray-200 pb-6 xl:flex-row xl:items-end xl:justify-between">
<div>
<Link href="/admin" className="mb-2 block text-sm font-bold text-gray-500 hover:text-[#8bc34a]">
Tilbake til oversikten
</Link>
<h1 className="text-4xl font-black">Simulatorer</h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600">
Skjult fundament for simulatoroperatører og simulatorsteder. Ingenting her er offentlig koblet inn ennå, og nye rader starter som upubliserte.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl bg-white px-5 py-4 shadow-sm">
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-gray-400">Operatører</div>
<div className="mt-2 text-3xl font-black">{operators.length}</div>
</div>
<div className="rounded-2xl bg-white px-5 py-4 shadow-sm">
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-gray-400">Steder</div>
<div className="mt-2 text-3xl font-black">{venues.length}</div>
</div>
<div className="rounded-2xl bg-white px-5 py-4 shadow-sm">
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-gray-400">Publiserte</div>
<div className="mt-2 text-3xl font-black">
{venues.filter((venue) => venue.is_published).length}
</div>
</div>
</div>
</div>
{message && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-5 py-4 text-sm font-semibold text-green-900">
{message}
</div>
)}
{error && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm font-semibold text-red-900">
{error}
</div>
)}
<div className="grid gap-8 xl:grid-cols-[440px_minmax(0,1fr)]">
<section className="rounded-[2rem] bg-white p-6 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-gray-400">Operatører</p>
<h2 className="mt-2 text-2xl font-black">
{operatorForm.id ? "Rediger operatør" : "Ny operatør"}
</h2>
</div>
{operatorForm.id && (
<button onClick={resetOperatorForm} className="btn btn-sm btn-secondary">
Ny
</button>
)}
</div>
<form onSubmit={submitOperator} className="space-y-4">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Navn</label>
<input
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={operatorForm.name}
onChange={(event) => handleOperatorFieldChange("name", event.target.value)}
placeholder="Indoor Golf Norge"
required
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Slug</label>
<input
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={operatorForm.slug}
onChange={(event) => handleOperatorFieldChange("slug", event.target.value)}
placeholder="indoor-golf-norge"
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Nettside</label>
<input
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={operatorForm.website_url}
onChange={(event) => handleOperatorFieldChange("website_url", event.target.value)}
placeholder="https://..."
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Logo-URL</label>
<input
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={operatorForm.logo_url}
onChange={(event) => handleOperatorFieldChange("logo_url", event.target.value)}
placeholder="https://..."
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Beskrivelse</label>
<textarea
className="min-h-[120px] w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={operatorForm.description}
onChange={(event) => handleOperatorFieldChange("description", event.target.value)}
placeholder="Kort intern beskrivelse av kjeden eller aktøren."
/>
</div>
<label className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3">
<input
type="checkbox"
checked={operatorForm.is_published}
onChange={(event) => handleOperatorFieldChange("is_published", event.target.checked)}
className="h-5 w-5 accent-[#8bc34a]"
/>
<span className="text-sm font-semibold">Publisert</span>
</label>
<button type="submit" disabled={savingOperator} className="btn btn-lg btn-primary w-full disabled:opacity-50">
{savingOperator ? "Lagrer..." : operatorForm.id ? "Oppdater operatør" : "Opprett operatør"}
</button>
</form>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-gray-400">Operatøroversikt</p>
<h2 className="mt-2 text-2xl font-black">Eksisterende operatører</h2>
</div>
</div>
{operators.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 p-8 text-sm text-gray-500">
Ingen simulatoroperatører registrert ennå.
</div>
) : (
<div className="space-y-3">
{operators.map((operator) => (
<div key={operator.id} className="rounded-2xl border border-gray-200 bg-[#f8fbf6] p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-lg font-black">{operator.name}</h3>
<span className={`rounded-full px-3 py-1 text-[11px] font-black uppercase tracking-widest ${operator.is_published ? "bg-green-100 text-green-800" : "bg-gray-200 text-gray-700"}`}>
{operator.is_published ? "Publisert" : "Skjult"}
</span>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{operator.slug}</p>
{operator.website_url && (
<a href={operator.website_url} target="_blank" rel="noreferrer" className="mt-2 block text-sm font-semibold text-[#2d6a4f] hover:underline">
{operator.website_url}
</a>
)}
{operator.description && (
<p className="mt-3 text-sm text-gray-600">{operator.description}</p>
)}
</div>
<div className="flex gap-2">
<button onClick={() => setOperatorForm(operatorToFormState(operator))} className="btn btn-sm btn-secondary">
Rediger
</button>
<button onClick={() => deleteOperator(operator.id)} className="btn btn-sm btn-danger">
Slett
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
</div>
<div className="mt-8 grid gap-8 xl:grid-cols-[520px_minmax(0,1fr)]">
<section className="rounded-[2rem] bg-white p-6 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-gray-400">Simulatorsteder</p>
<h2 className="mt-2 text-2xl font-black">
{venueForm.id ? "Rediger sted" : "Nytt sted"}
</h2>
</div>
{venueForm.id && (
<button onClick={resetVenueForm} className="btn btn-sm btn-secondary">
Nytt sted
</button>
)}
</div>
<form onSubmit={submitVenue} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Navn</label>
<input
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={venueForm.name}
onChange={(event) => handleVenueFieldChange("name", event.target.value)}
placeholder="X Golf Oslo"
required
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Slug</label>
<input
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={venueForm.slug}
onChange={(event) => handleVenueFieldChange("slug", event.target.value)}
placeholder="x-golf-oslo"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Stedtype</label>
<select
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={venueForm.venue_type}
onChange={(event) => handleVenueFieldChange("venue_type", event.target.value)}
>
{VENUE_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Operatør</label>
<select
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={venueForm.operator_id}
onChange={(event) => handleVenueFieldChange("operator_id", event.target.value)}
>
<option value="">Ingen operatør</option>
{operators.map((operator) => (
<option key={operator.id} value={operator.id}>
{operator.name}
</option>
))}
</select>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Tilknyttet golfanlegg</label>
<select
className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={venueForm.facility_id}
onChange={(event) => handleVenueFieldChange("facility_id", event.target.value)}
>
<option value="">Ikke koblet til golfanlegg</option>
{facilityOptions.map((facility) => (
<option key={facility.id} value={facility.id}>
{facility.name}{facility.city ? `, ${facility.city}` : ""}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Beskrivelse</label>
<textarea
className="min-h-[110px] w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={venueForm.description}
onChange={(event) => handleVenueFieldChange("description", event.target.value)}
placeholder="Kort intern eller fremtidig offentlig beskrivelse."
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">By</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.city} onChange={(event) => handleVenueFieldChange("city", event.target.value)} />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Fylke</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.county} onChange={(event) => handleVenueFieldChange("county", event.target.value)} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Adresse</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.address} onChange={(event) => handleVenueFieldChange("address", event.target.value)} />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Postnr.</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.postal_code} onChange={(event) => handleVenueFieldChange("postal_code", event.target.value)} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Breddegrad</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.lat} onChange={(event) => handleVenueFieldChange("lat", event.target.value)} placeholder="59.9139" />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Lengdegrad</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.lng} onChange={(event) => handleVenueFieldChange("lng", event.target.value)} placeholder="10.7522" />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Nettside</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.website_url} onChange={(event) => handleVenueFieldChange("website_url", event.target.value)} placeholder="https://..." />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Bookinglenke</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.booking_url} onChange={(event) => handleVenueFieldChange("booking_url", event.target.value)} placeholder="https://..." />
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Telefon</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.phone} onChange={(event) => handleVenueFieldChange("phone", event.target.value)} />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">E-post</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.email} onChange={(event) => handleVenueFieldChange("email", event.target.value)} />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Bilde-URL</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.image_url} onChange={(event) => handleVenueFieldChange("image_url", event.target.value)} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Simulatorsystemer</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.simulator_systems} onChange={(event) => handleVenueFieldChange("simulator_systems", event.target.value)} placeholder="TrackMan, Foresight" />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Antall båser</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.bay_count} onChange={(event) => handleVenueFieldChange("bay_count", event.target.value)} />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Pris fra</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.price_from} onChange={(event) => handleVenueFieldChange("price_from", event.target.value)} placeholder="350" />
</div>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Sesong / merknad om tilgjengelighet</label>
<input className="w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]" value={venueForm.season} onChange={(event) => handleVenueFieldChange("season", event.target.value)} placeholder="Helårsåpent" />
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Åpningstider</label>
<textarea
className="min-h-[100px] w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none focus:border-[#8bc34a]"
value={venueForm.opening_hours}
onChange={(event) => handleVenueFieldChange("opening_hours", event.target.value)}
placeholder="Man-fre 10-22, lør-søn 10-20"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
["lessons_available", "Tilbyr undervisning"],
["club_fitting", "Tilbyr custom fitting"],
["food_and_drink", "Mat og drikke"],
["serves_alcohol", "Serverer alkohol"],
["drop_in", "Drop-in mulig"],
["membership_required", "Krever medlemskap"],
].map(([field, label]) => (
<label key={field} className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3">
<input
type="checkbox"
checked={Boolean(venueForm[field as keyof VenueFormState])}
onChange={(event) => handleVenueFieldChange(field as keyof VenueFormState, event.target.checked)}
className="h-5 w-5 accent-[#8bc34a]"
/>
<span className="text-sm font-semibold">{label}</span>
</label>
))}
</div>
<label className="flex items-center gap-3 rounded-2xl border border-gray-200 px-4 py-3">
<input
type="checkbox"
checked={venueForm.is_published}
onChange={(event) => handleVenueFieldChange("is_published", event.target.checked)}
className="h-5 w-5 accent-[#8bc34a]"
/>
<span className="text-sm font-semibold">Publisert</span>
</label>
<button type="submit" disabled={savingVenue} className="btn btn-lg btn-primary w-full disabled:opacity-50">
{savingVenue ? "Lagrer..." : venueForm.id ? "Oppdater sted" : "Opprett sted"}
</button>
</form>
</section>
<section className="rounded-[2rem] bg-white p-6 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.18em] text-gray-400">Stedsoversikt</p>
<h2 className="mt-2 text-2xl font-black">Eksisterende simulatorsteder</h2>
</div>
</div>
{venues.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 p-8 text-sm text-gray-500">
Ingen simulatorsteder registrert ennå.
</div>
) : (
<div className="space-y-4">
{venues.map((venue) => (
<div key={venue.id} className="rounded-2xl border border-gray-200 bg-[#f8fbf6] p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-lg font-black">{venue.name}</h3>
<span className="rounded-full bg-[#11280f] px-3 py-1 text-[11px] font-black uppercase tracking-widest text-white">
{venue.venue_type}
</span>
<span className={`rounded-full px-3 py-1 text-[11px] font-black uppercase tracking-widest ${venue.is_published ? "bg-green-100 text-green-800" : "bg-gray-200 text-gray-700"}`}>
{venue.is_published ? "Publisert" : "Skjult"}
</span>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{venue.slug}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-gray-600">
{venue.operator_name && <span className="rounded-full bg-white px-3 py-1">Operatør: {venue.operator_name}</span>}
{venue.facility_name && <span className="rounded-full bg-white px-3 py-1">Golfanlegg: {venue.facility_name}</span>}
{venue.city && <span className="rounded-full bg-white px-3 py-1">{venue.city}</span>}
{venue.county && <span className="rounded-full bg-white px-3 py-1">{venue.county}</span>}
{venue.bay_count != null && <span className="rounded-full bg-white px-3 py-1">{venue.bay_count} båser</span>}
{venue.price_from != null && <span className="rounded-full bg-white px-3 py-1">Fra {venue.price_from} kr</span>}
</div>
{venue.simulator_systems.length > 0 && (
<p className="mt-3 text-sm text-gray-600">
<strong>Systemer:</strong> {venue.simulator_systems.join(", ")}
</p>
)}
{venue.description && (
<p className="mt-3 text-sm text-gray-600">{venue.description}</p>
)}
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-gray-600">
{venue.lessons_available && <span className="rounded-full bg-white px-3 py-1">Undervisning</span>}
{venue.club_fitting && <span className="rounded-full bg-white px-3 py-1">Custom fitting</span>}
{venue.food_and_drink && <span className="rounded-full bg-white px-3 py-1">Mat og drikke</span>}
{venue.serves_alcohol && <span className="rounded-full bg-white px-3 py-1">Alkoholservering</span>}
{venue.drop_in && <span className="rounded-full bg-white px-3 py-1">Drop-in</span>}
{venue.membership_required && <span className="rounded-full bg-white px-3 py-1">Krever medlemskap</span>}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => setVenueForm(venueToFormState(venue))} className="btn btn-sm btn-secondary">
Rediger
</button>
<button onClick={() => deleteVenue(venue.id)} className="btn btn-sm btn-danger">
Slett
</button>
</div>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,5 @@
import SimulatorAdminClient from "./SimulatorAdminClient";
export default function AdminSimulatorPage() {
return <SimulatorAdminClient />;
}

View file

@ -14,6 +14,7 @@ const NAV_ITEMS = [
{ href: '/admin/medlemskap', label: 'Medlemskap', match: (pathname: string) => pathname.startsWith('/admin/medlemskap') }, { href: '/admin/medlemskap', label: 'Medlemskap', match: (pathname: string) => pathname.startsWith('/admin/medlemskap') },
{ href: '/admin/greenfee', label: 'Greenfee', match: (pathname: string) => pathname.startsWith('/admin/greenfee') }, { href: '/admin/greenfee', label: 'Greenfee', match: (pathname: string) => pathname.startsWith('/admin/greenfee') },
{ href: '/admin/golfpakker', label: 'Golfpakker', match: (pathname: string) => pathname.startsWith('/admin/golfpakker') }, { href: '/admin/golfpakker', label: 'Golfpakker', match: (pathname: string) => pathname.startsWith('/admin/golfpakker') },
{ href: '/admin/simulatorer', label: 'Simulatorer', match: (pathname: string) => pathname.startsWith('/admin/simulatorer') },
{ href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') }, { href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') },
{ href: '/admin/steder', label: 'Steder', match: (pathname: string) => pathname.startsWith('/admin/steder') }, { href: '/admin/steder', label: 'Steder', match: (pathname: string) => pathname.startsWith('/admin/steder') },
]; ];