Før korrigering av golfpakker
This commit is contained in:
parent
9af374adda
commit
64d15ebd4e
5 changed files with 1468 additions and 0 deletions
525
backend/main.py
525
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."""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
Golfpakker
|
||||
</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">
|
||||
VTG
|
||||
</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">
|
||||
{isSidebarCollapsed ? 'GP' : 'Golfpakker'}
|
||||
</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)">
|
||||
{isSidebarCollapsed ? 'V' : 'VTG'}
|
||||
</Link>
|
||||
|
|
|
|||
931
frontend/src/app/admin/simulatorer/SimulatorAdminClient.tsx
Normal file
931
frontend/src/app/admin/simulatorer/SimulatorAdminClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/admin/simulatorer/page.tsx
Normal file
5
frontend/src/app/admin/simulatorer/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import SimulatorAdminClient from "./SimulatorAdminClient";
|
||||
|
||||
export default function AdminSimulatorPage() {
|
||||
return <SimulatorAdminClient />;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ const NAV_ITEMS = [
|
|||
{ 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/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/steder', label: 'Steder', match: (pathname: string) => pathname.startsWith('/admin/steder') },
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in a new issue