diff --git a/2026-04-20 17.08.14 teeoff.no e7e909d79195.jpg b/2026-04-20 17.08.14 teeoff.no e7e909d79195.jpg deleted file mode 100644 index 5503803..0000000 Binary files a/2026-04-20 17.08.14 teeoff.no e7e909d79195.jpg and /dev/null differ diff --git a/2026-04-20_214654.png b/2026-04-20_214654.png new file mode 100644 index 0000000..02f69c7 Binary files /dev/null and b/2026-04-20_214654.png differ diff --git a/2026-04-20_214712.png b/2026-04-20_214712.png new file mode 100644 index 0000000..016b97c Binary files /dev/null and b/2026-04-20_214712.png differ diff --git a/backend/main.py b/backend/main.py index 63513fc..fa5e6a1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -569,6 +569,10 @@ class QuickEditRequest(BaseModel): field: str value: str + +class FacilityVisibilityRequest(BaseModel): + is_published: bool + class GreenfeeApproval(BaseModel): facility_id: int greenfee: List[dict] @@ -893,6 +897,24 @@ def parse_optional_datetime(value: str | None) -> datetime | None: raise HTTPException(status_code=400, detail=f"Ugyldig datoformat: {value}") from exc +def parse_optional_int(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + + trimmed = str(value).strip() + if not trimmed: + return None + + try: + return int(float(trimmed.replace(",", "."))) + except ValueError: + return None + + def sanitize_hero_images(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] @@ -1318,6 +1340,7 @@ async def ensure_facility_columns(conn): """Legger til nye facility-kolonner ved behov.""" await conn.execute(""" ALTER TABLE facilities + ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT TRUE, ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS golfamore_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_url TEXT, @@ -2113,7 +2136,8 @@ async def get_facilities(): ORDER BY day_offset ASC ) w_data ) as weather_forecast - FROM facilities f + FROM facilities f + WHERE COALESCE(f.is_published, TRUE) = TRUE ORDER BY f.name ASC """) return [format_row(row) for row in rows] @@ -2154,7 +2178,9 @@ async def get_facility(slug: str): ORDER BY day_offset ASC ) w_data ) as weather_forecast - FROM facilities f WHERE f.slug = $1 + FROM facilities f + WHERE f.slug = $1 + AND COALESCE(f.is_published, TRUE) = TRUE """, slug) if not row: @@ -2163,6 +2189,88 @@ async def get_facility(slug: str): return format_row(row) +@app.get("/api/admin/facilities") +async def get_admin_facilities(): + """Henter alle golfanlegg for admin, også upubliserte.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT f.*, ( + SELECT jsonb_agg(cs) FROM ( + SELECT id, name, status FROM courses + WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' + ORDER BY is_main_course DESC, id ASC + ) cs + ) as course_statuses, ( + SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( + SELECT + forecast_date, + day_offset, + dry_all_day, + dry_daylight, + precip_mm, + precip_probability_max, + daylight_precip_mm, + daylight_precip_probability_max, + confidence, + source_updated_at, + source_expires_at, + calculated_at + FROM facility_weather_forecast + WHERE facility_id = f.id + ORDER BY day_offset ASC + ) w_data + ) as weather_forecast + FROM facilities f + ORDER BY f.name ASC + """) + return [format_row(row) for row in rows] + + +@app.get("/api/admin/facilities/{slug}") +async def get_admin_facility(slug: str): + """Henter full anleggsdetalj for admin, også når anlegget er upublisert.""" + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT f.*, ( + SELECT jsonb_agg(c_data) FROM ( + SELECT c.*, ( + SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC) + FROM (SELECT * FROM holes WHERE course_id = c.id) h_data + ) as holes + FROM courses c + WHERE c.facility_id = f.id + ORDER BY c.is_main_course DESC, c.id ASC + ) c_data + ) as courses, ( + SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( + SELECT + forecast_date, + day_offset, + dry_all_day, + dry_daylight, + precip_mm, + precip_probability_max, + daylight_precip_mm, + daylight_precip_probability_max, + confidence, + source_updated_at, + source_expires_at, + calculated_at + FROM facility_weather_forecast + WHERE facility_id = f.id + ORDER BY day_offset ASC + ) w_data + ) as weather_forecast + FROM facilities f + WHERE f.slug = $1 + """, slug) + + if not row: + raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") + + return format_row(row) + + @app.get("/api/course-visits") async def get_course_visits(): """Henter publiserte Banebesøk-artikler.""" @@ -2608,7 +2716,7 @@ async def update_facility_full(facility_id: int, request: Request): 'guest_requirements', 'scrape_method', 'scrape_status_url', 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at', 'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', - 'vtg_updated_at', 'vtg_draft', 'footnote_updated_at', + 'vtg_updated_at', 'vtg_draft', 'footnote_updated_at', 'is_published', 'golfpakker_draft', 'golfpakker_updated_at' ] @@ -2682,49 +2790,116 @@ async def update_facility_full(facility_id: int, request: Request): await conn.execute(query, *values) # 2. OPPDATER BANER (COURSES) OG HULL (HOLES) - courses = data.get('courses') or [] - for course in courses: - if not course: - continue - course_id = course.get('id') - if course_id: - # Rens datoformat for PostgreSQL (håndterer Next.js date input) + if 'courses' in data: + submitted_courses = [course for course in (data.get('courses') or []) if course] + normalized_courses: list[dict[str, Any]] = [] + + for index, course in enumerate(submitted_courses): + normalized_course = dict(course) + normalized_course['is_main_course'] = bool(course.get('is_main_course')) + normalized_courses.append(normalized_course) + + if normalized_courses: + if not any(course['is_main_course'] for course in normalized_courses): + normalized_courses[0]['is_main_course'] = True + else: + main_assigned = False + for course in normalized_courses: + if course['is_main_course'] and not main_assigned: + main_assigned = True + else: + course['is_main_course'] = False + + retained_course_ids: list[int] = [] + + for course in normalized_courses: + course_id = course.get('id') + holes = [hole for hole in (course.get('holes') or []) if hole] + hole_count = len(holes) or None + course_par = parse_optional_int(course.get('par')) + course_length_meters = parse_optional_int(course.get('length_meters')) + valid_until_str = course.get('slope_valid_until') - if valid_until_str == "" or valid_until_str is None: + if valid_until_str == "" or valid_until_str is None: valid_until = None else: - # Gjør om strengen til et ekte date-objekt for asyncpg try: - date_part = valid_until_str.split('T')[0] + date_part = str(valid_until_str).split('T')[0] valid_until = datetime.strptime(date_part, "%Y-%m-%d").date() except ValueError: valid_until = None - - await conn.execute(""" - UPDATE courses - SET name=$1, par=$2, length_meters=$3, architect=$4, - status=$5, is_main_course=$6, tee_boxes=$7::jsonb, - slope_valid_until=$8 - WHERE id=$9 AND facility_id=$10 - """, - course.get('name'), course.get('par'), course.get('length_meters'), - course.get('architect'), course.get('status'), course.get('is_main_course'), - json.dumps(course.get('tee_boxes') or {}), valid_until, course_id, facility_id) - - # 3. OPPDATER HULL PÅ BANEN (HOLES) - holes = course.get('holes') or [] + + tee_boxes_json = json.dumps(course.get('tee_boxes') or {}) + + if course_id: + await conn.execute(""" + UPDATE courses + SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5, + status=$6, is_main_course=$7, tee_boxes=$8::jsonb, + slope_valid_until=$9 + WHERE id=$10 AND facility_id=$11 + """, + course.get('name'), hole_count, course_par, course_length_meters, + course.get('architect'), course.get('status'), course.get('is_main_course'), + tee_boxes_json, valid_until, course_id, facility_id) + else: + course_id = await conn.fetchval(""" + INSERT INTO courses ( + facility_id, name, holes, par, length_meters, architect, + status, is_main_course, tee_boxes, slope_valid_until + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10) + RETURNING id + """, + facility_id, course.get('name'), hole_count, course_par, course_length_meters, + course.get('architect'), course.get('status'), course.get('is_main_course'), + tee_boxes_json, valid_until) + + retained_course_ids.append(int(course_id)) + + retained_hole_ids: list[int] = [] for hole in holes: - if not hole: - continue hole_id = hole.get('id') + hole_number = parse_optional_int(hole.get('hole_number')) + hole_par = parse_optional_int(hole.get('par')) + hole_hcp_index = parse_optional_int(hole.get('hcp_index')) + lengths_json = json.dumps(hole.get('lengths') or {}) if hole_id: await conn.execute(""" UPDATE holes - SET par=$1, hcp_index=$2, lengths=$3::jsonb - WHERE id=$4 AND course_id=$5 - """, - hole.get('par'), hole.get('hcp_index'), - json.dumps(hole.get('lengths') or {}), hole_id, course_id) + SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb + WHERE id=$5 AND course_id=$6 + """, + hole_number, hole_par, hole_hcp_index, + lengths_json, hole_id, course_id) + else: + hole_id = await conn.fetchval(""" + INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) + VALUES ($1, $2, $3, $4, $5::jsonb) + RETURNING id + """, + course_id, hole_number, hole_par, hole_hcp_index, + lengths_json) + + retained_hole_ids.append(int(hole_id)) + + if retained_hole_ids: + await conn.execute( + "DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))", + course_id, + retained_hole_ids, + ) + else: + await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id) + + if retained_course_ids: + await conn.execute( + "DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))", + facility_id, + retained_course_ids, + ) + else: + await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id) extra_paths = ["/golfbaner"] if changed_field_names & membership_fields: @@ -2737,6 +2912,30 @@ async def update_facility_full(facility_id: int, request: Request): ) return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."} + +@app.delete("/api/admin/facilities/{facility_id}") +async def delete_facility(facility_id: int): + """Sletter et anlegg permanent med tilhørende baner og hull.""" + async with app.state.pool.acquire() as conn: + deleted = await conn.fetchrow( + "DELETE FROM facilities WHERE id = $1 RETURNING slug, name", + facility_id, + ) + + if not deleted: + raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") + + deleted_slug = str(deleted["slug"] or "").strip() + schedule_indexnow_submission( + collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]), + reason="facility delete", + ) + return { + "status": "success", + "message": f"{deleted['name']} ble slettet.", + "slug": deleted_slug, + } + # --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER --- @app.get("/api/admin/scrape-jobs") async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit: int = Query(default=10, ge=1, le=50)): diff --git a/frontend/src/app/admin/artikler/page.tsx b/frontend/src/app/admin/artikler/page.tsx index c089cd8..3aaa6b6 100644 --- a/frontend/src/app/admin/artikler/page.tsx +++ b/frontend/src/app/admin/artikler/page.tsx @@ -191,7 +191,7 @@ export default function AdminArticlesPage() { }; const loadFacilities = async () => { - const response = await fetch(`${API_URL}/facilities`, { credentials: "include" }); + const response = await adminFetch(`${API_URL}/admin/facilities`, { credentials: "include" }); const data = await response.json(); const mapped = Array.isArray(data) ? data diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index b245c76..ffa80f7 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -336,7 +336,7 @@ export default function AdminDashboard() { const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState>>({}); const fetchFacilities = () => { - fetch(`${API_URL}/facilities`) + adminFetch(`${API_URL}/admin/facilities`) .then(res => res.json()) .then(data => { setFacilities(Array.isArray(data) ? data : []); @@ -1813,6 +1813,11 @@ export default function AdminDashboard() {

{f.name}

ID {f.id} + {f.is_published === false && ( + + Skjult + + )} {isHighlighted && ( Trenger oppmerksomhet @@ -2163,7 +2168,14 @@ export default function AdminDashboard() { handleSelectOne(f.id, e.target.checked)} /> #{f.id} -
{f.name}
+
+
{f.name}
+ {f.is_published === false && ( + + Skjult + + )} +
{f.city}
diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx index 549c3ae..268bf08 100644 --- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx +++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx @@ -157,17 +157,21 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any const herrer = course.tee_boxes?.herrer || []; const damer = course.tee_boxes?.damer || []; const initialTees = { herrer: {} as any, damer: {} as any }; - activeKeys.forEach((key, idx) => { - initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' }; - initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' }; + const herrerAreCompact = herrer.length > 0 && herrer.length < ALL_KEYS.length; + const damerAreCompact = damer.length > 0 && damer.length < ALL_KEYS.length; + + ALL_KEYS.forEach((key, idx) => { + const activeIdx = activeKeys.indexOf(key); + initialTees.herrer[key] = (herrerAreCompact && activeIdx >= 0 ? herrer[activeIdx] : herrer[idx]) || { navn_utslag: '', baneverdi: '', slopeverdi: '' }; + initialTees.damer[key] = (damerAreCompact && activeIdx >= 0 ? damer[activeIdx] : damer[idx]) || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' }; }); return initialTees; }); const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => { const updatedTeeBoxes = { - herrer: newKeys.map(k => newTees.herrer[k] || {}), - damer: newKeys.map(k => newTees.damer[k] || {}) + herrer: ALL_KEYS.map(k => newTees.herrer[k] || {}), + damer: ALL_KEYS.map(k => newTees.damer[k] || {}) }; onChange({ ...course, @@ -401,9 +405,14 @@ const getMediaFieldLabel = (field: string) => { export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) { const router = useRouter(); - const [formData, setFormData] = useState(initialData); + const [formData, setFormData] = useState({ + ...initialData, + is_published: initialData?.is_published !== false, + courses: Array.isArray(initialData?.courses) ? initialData.courses : [], + }); const [activeTab, setActiveTab] = useState('generelt'); const [saving, setSaving] = useState(false); + const [deletingFacility, setDeletingFacility] = useState(false); const [mediaFeedback, setMediaFeedback] = useState(""); const [uploadingTarget, setUploadingTarget] = useState(null); const mainImageInputRef = useRef(null); @@ -423,6 +432,60 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini setFormData((prev: any) => ({ ...prev, [field]: value })); }; + const updateCourses = (updater: (courses: any[]) => any[]) => { + const nextCourses = updater(Array.isArray(formData.courses) ? formData.courses : []); + handleChange('courses', nextCourses); + }; + + const createEmptyCourse = () => { + const existingCourses = Array.isArray(formData.courses) ? formData.courses : []; + return { + _clientId: `course-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: `Ny bane ${existingCourses.length + 1}`, + status: 'ukjent', + par: '', + length_meters: '', + architect: '', + is_main_course: existingCourses.length === 0, + slope_valid_until: '', + tee_boxes: { herrer: [], damer: [] }, + holes: Array.from({ length: 18 }, (_, index) => ({ + hole_number: index + 1, + par: '', + hcp_index: '', + lengths: {}, + })), + }; + }; + + const handleAddCourse = () => { + updateCourses((courses) => [...courses, createEmptyCourse()]); + }; + + const handleRemoveCourse = (index: number) => { + const courses = Array.isArray(formData.courses) ? formData.courses : []; + const course = courses[index]; + const confirmed = window.confirm(`Slette banen "${course?.name || 'uten navn'}"?`); + if (!confirmed) return; + + updateCourses((currentCourses) => { + const nextCourses = currentCourses.filter((_, courseIndex) => courseIndex !== index); + if (nextCourses.length > 0 && !nextCourses.some((entry) => entry?.is_main_course)) { + nextCourses[0] = { ...nextCourses[0], is_main_course: true }; + } + return nextCourses; + }); + }; + + const handleSetMainCourse = (index: number) => { + updateCourses((courses) => + courses.map((course, courseIndex) => ({ + ...course, + is_main_course: courseIndex === index, + })) + ); + }; + const galleryImages = normalizeStringList(formData.gallery); const setGalleryImages = (images: string[]) => { @@ -536,6 +599,30 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini setSaving(false); }; + const handleDeleteFacility = async () => { + const confirmed = window.confirm(`Slette anlegget "${initialData.name}" permanent? Dette fjerner også baner og hull.`); + if (!confirmed) return; + + setDeletingFacility(true); + try { + const response = await adminFetch(`/api/admin/facilities/${initialData.id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + alert("Noe gikk galt under sletting."); + return; + } + + router.push('/admin'); + router.refresh(); + } catch { + alert("Nettverksfeil under sletting."); + } finally { + setDeletingFacility(false); + } + }; + const tabs = [ { id: 'generelt', label: 'Generelt' }, { id: 'lokasjon', label: 'Lokasjon & Kontakt' }, @@ -561,24 +648,43 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini ← Tilbake til oversikten

Rediger:{" "} - - {initialData.name} - + {formData.is_published ? ( + + {initialData.name} + + ) : ( + {initialData.name} + )}

+

+ + {formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'} + + Slug: {initialData.slug} +

+
+
+ +
-
@@ -599,6 +705,24 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{activeTab === 'generelt' && (
+
+

Publisering

+
+
+

{formData.is_published ? 'Anlegget er publisert' : 'Anlegget er skjult'}

+

Skjulte anlegg forsvinner fra offentlige lister og anleggssiden, men forblir tilgjengelige i admin.

+
+ +
+
handleChange('name', e.target.value)} /> @@ -1008,35 +1132,60 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini {activeTab === 'baner' && (
-
-

Baner og Scorekort

-

Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.

+
+
+

Baner og Scorekort

+

Bruk det interaktive skjemaet under for å redigere lengder, par og utslag. Nye baner lagres sammen med anlegget og blir behandlet som egne baner i detaljvisningen.

+
+
+ {formData.courses?.length || 0} baner + +
{formData.courses?.map((course: any, cIdx: number) => ( -
+
-

{course.name}

- - {course.is_main_course ? 'Hovedbane' : 'Sekundærbane'} - +
+

{course.name}

+
+ + + {course.is_main_course ? 'Hovedbane' : 'Sekundærbane'} + +
+
+
{ - const newCourses = [...formData.courses]; - newCourses[cIdx] = {...course, name: e.target.value}; - handleChange('courses', newCourses); + updateCourses((courses) => { + const nextCourses = [...courses]; + nextCourses[cIdx] = {...course, name: e.target.value}; + return nextCourses; + }); }} />
{ - const newCourses = [...formData.courses]; - newCourses[cIdx] = {...course, par: Number(e.target.value)}; - handleChange('courses', newCourses); + updateCourses((courses) => { + const nextCourses = [...courses]; + nextCourses[cIdx] = {...course, par: Number(e.target.value)}; + return nextCourses; + }); }} />
{ - const newCourses = [...formData.courses]; - newCourses[cIdx] = {...course, slope_valid_until: e.target.value}; - handleChange('courses', newCourses); + updateCourses((courses) => { + const nextCourses = [...courses]; + nextCourses[cIdx] = {...course, slope_valid_until: e.target.value}; + return nextCourses; + }); }} />
@@ -1068,9 +1221,11 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini { - const newCourses = [...formData.courses]; - newCourses[cIdx] = updatedCourse; - handleChange('courses', newCourses); + updateCourses((courses) => { + const nextCourses = [...courses]; + nextCourses[cIdx] = updatedCourse; + return nextCourses; + }); }} />
diff --git a/frontend/src/app/admin/rediger/[slug]/page.tsx b/frontend/src/app/admin/rediger/[slug]/page.tsx index dafe776..bd4c2e8 100644 --- a/frontend/src/app/admin/rediger/[slug]/page.tsx +++ b/frontend/src/app/admin/rediger/[slug]/page.tsx @@ -1,15 +1,23 @@ +import { cookies } from "next/headers"; import { API_URL } from "@/config/constants"; import EditFacilityClient from "./EditFacilityClient"; export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; + const cookieHeader = (await cookies()).toString(); // Henter anlegget vi skal redigere - const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' }); + const res = await fetch(`${API_URL}/admin/facilities/${slug}`, { + cache: 'no-store', + headers: cookieHeader ? { cookie: cookieHeader } : undefined, + }); const facility = await res.json(); // Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter - const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' }); + const allRes = await fetch(`${API_URL}/admin/facilities`, { + cache: 'no-store', + headers: cookieHeader ? { cookie: cookieHeader } : undefined, + }); const allFacilities = await allRes.json(); if (!facility || facility.error) { @@ -17,4 +25,4 @@ export default async function EditFacilityPage({ params }: { params: Promise<{ s } return ; -} \ No newline at end of file +} diff --git a/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx b/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx index 7ae5c42..9bdd873 100644 --- a/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx +++ b/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { STATUS_MAP } from "@/config/constants"; +type Gender = 'herrer' | 'damer'; + // Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene const getTeeTheme = (label: string) => { const name = label.toLowerCase(); @@ -30,7 +32,7 @@ const getTeeTheme = (label: string) => { export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) { const [hcp, setHcp] = useState("15.0"); - const [gender, setGender] = useState<'herrer' | 'damer'>('herrer'); + const [gender, setGender] = useState('herrer'); const [selectedTeeIndex, setSelectedTeeIndex] = useState(0); const allHoles = course.holes || []; @@ -39,18 +41,44 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour const hasInHoles = holesIn.length > 0; const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']; + const activeLengthKeys = lengthKeys.filter((key) => allHoles.some((h: any) => h.lengths?.[key])); const availableTees = course.tee_boxes?.[gender] || []; - - const activeColumns = lengthKeys - .filter(k => allHoles.some((h: any) => h.lengths?.[k])) - .map((key, idx) => { - const info = availableTees[idx]; - const label = info?.navn_utslag || info?.navn_utslag_damer || key.toUpperCase(); - return { key, label, theme: getTeeTheme(label) }; - }); + const fallbackTees = course.tee_boxes?.[gender === 'damer' ? 'herrer' : 'damer'] || []; + + const getTeeForColumn = (tees: any[], teeIndex: number, activePosition: number) => { + if (!Array.isArray(tees) || tees.length === 0) { + return undefined; + } + + // Older/broken saves may store only active tees in sequence instead of the full 6-slot layout. + if (tees.length < lengthKeys.length) { + return tees[activePosition]; + } + + return tees[teeIndex]; + }; + + const getTeeLabel = (tee: any, fallbackTee: any, fallback: string) => { + const primaryLabel = gender === 'damer' ? tee?.navn_utslag_damer : tee?.navn_utslag; + const secondaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag : fallbackTee?.navn_utslag_damer; + const tertiaryLabel = gender === 'damer' ? tee?.navn_utslag : tee?.navn_utslag_damer; + const quaternaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag_damer : fallbackTee?.navn_utslag; + return String(primaryLabel || secondaryLabel || tertiaryLabel || quaternaryLabel || fallback).trim(); + }; + + const activeColumns = activeLengthKeys.map((key, activePosition) => { + const teeIndex = lengthKeys.indexOf(key); + const tee = getTeeForColumn(availableTees, teeIndex, activePosition); + const fallbackTee = getTeeForColumn(fallbackTees, teeIndex, activePosition); + const label = getTeeLabel(tee, fallbackTee, key[0].toUpperCase() + key.slice(1)); + return { key, teeIndex, activePosition, label, theme: getTeeTheme(label) }; + }); // Kalkulering av SpH - const activeTee = availableTees[selectedTeeIndex]; + const selectedColumn = activeColumns.find((column) => column.teeIndex === selectedTeeIndex) || activeColumns[0] || null; + const activeTee = selectedColumn + ? getTeeForColumn(availableTees, selectedColumn.teeIndex, selectedColumn.activePosition) + : undefined; let playingHandicap = 0; if (activeTee && hcp) { @@ -69,8 +97,13 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0); const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0); - const selectedColumn = activeColumns[selectedTeeIndex] || activeColumns[0] || null; - const selectedTeeLabel = selectedColumn?.label || activeTee?.navn_utslag || activeTee?.navn_utslag_damer || 'Valgt utslag'; + const selectedTeeLabel = activeTee + ? getTeeLabel( + activeTee, + selectedColumn ? getTeeForColumn(fallbackTees, selectedColumn.teeIndex, selectedColumn.activePosition) : undefined, + selectedColumn?.label || 'Valgt utslag' + ) + : 'Valgt utslag'; // Formater utløpsdato const slopeExpiry = course.slope_valid_until @@ -147,13 +180,36 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
Kjønn - { + const nextGender = e.target.value as Gender; + const nextTees = course.tee_boxes?.[nextGender] || []; + const nextFallbackTees = course.tee_boxes?.[nextGender === 'damer' ? 'herrer' : 'damer'] || []; + const nextFirstTeeIndex = activeLengthKeys.findIndex((key, activePosition) => + Boolean( + String( + nextGender === 'damer' + ? getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer || + getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag || + getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag || + getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer || + "" + : getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag || + getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer || + getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer || + getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag || + "" + ).trim() + ) + ); + setGender(nextGender); + setSelectedTeeIndex(nextFirstTeeIndex >= 0 ? lengthKeys.indexOf(activeLengthKeys[nextFirstTeeIndex]) : 0); + }} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 text-[#11280f] font-black outline-none cursor-pointer">
Utslag
Ditt HCP diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 40788a2..780544c 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -496,6 +496,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
Drivingrange:{renderValue(amenities.drivingrange, 'Nei')}
Nærspill:{renderValue(amenities.treningsgreen, 'Ja')}
+
Pro:{renderValue(amenities.pro)}
Proshop:{renderValue(amenities.proshop)}
Kølleutleie:{renderValue(amenities.kolleutleie, 'Ja')}
Bilutleie:{renderValue(amenities.bilutleie, 'Nei')}