From fec5f4e8c650ef339f0e940c3c07b60b5b410cc1 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 25 Apr 2026 07:45:34 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20grensesnitt=20for=20=C3=A5=20registr?= =?UTF-8?q?ere=20nytt=20golfanlegg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 335 +++++++++++++++++- backend/scrape_vtg.py | 17 +- backend/vtg_courses.py | 202 +++++++++++ frontend/src/app/admin/vtg/page.tsx | 126 +++++-- .../golfbaner/[slug]/FacilityDetailView.tsx | 9 +- 5 files changed, 636 insertions(+), 53 deletions(-) create mode 100644 backend/vtg_courses.py diff --git a/backend/main.py b/backend/main.py index d92e443..ca8381f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -42,6 +42,7 @@ from scrape_jobs import ( list_scrape_jobs, ) from env_config import get_database_url, get_required_env +from vtg_courses import filter_upcoming_courses, normalize_vtg_course_rows from weather_forecast import ensure_weather_forecast_table, weather_sync_loop # --- KONFIGURASJON --- @@ -597,6 +598,25 @@ class BulkVtgRequest(BaseModel): approvals: List[VtgApproval] +class VtgContentApproval(BaseModel): + facility_id: int + vtg_pris: int | None + vtg_beskrivelse: str | None + + +class BulkVtgContentRequest(BaseModel): + approvals: List[VtgContentApproval] + + +class VtgCoursesApproval(BaseModel): + facility_id: int + vtg_datoer: List[dict] | None + + +class BulkVtgCoursesRequest(BaseModel): + approvals: List[VtgCoursesApproval] + + class BulkGolfpakkerRequest(BaseModel): approvals: List[GolfpakkerApproval] @@ -675,7 +695,7 @@ def format_row(row): for key in [ 'status_updated_at', 'created_at', 'slope_valid_until', 'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at', - 'golfpakker_updated_at' + 'golfpakker_updated_at', 'vtg_content_updated_at', 'vtg_courses_updated_at' ]: if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat() @@ -683,11 +703,12 @@ def format_row(row): json_list_fields = [ 'course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer', - 'weather_forecast' + 'weather_forecast', 'vtg_courses_draft' ] json_dict_fields = [ 'amenities', 'vtg', 'nsg_data', 'golfamore_data', - 'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft', 'hole_par_counts' + 'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft', 'hole_par_counts', + 'vtg_content_draft' ] for field in json_list_fields: @@ -719,6 +740,75 @@ def format_row(row): return d +def prepare_vtg_content_draft_payload(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + + payload: dict[str, Any] = {} + if "foreslatt_vtg_pris" in value: + payload["foreslatt_vtg_pris"] = value.get("foreslatt_vtg_pris") + if "foreslatt_vtg_beskrivelse" in value: + payload["foreslatt_vtg_beskrivelse"] = value.get("foreslatt_vtg_beskrivelse") + ai_reason = str(value.get("ai_begrunnelse") or "").strip() + if ai_reason: + payload["ai_begrunnelse"] = ai_reason + return payload + + +def prepare_vtg_course_draft_payload(value: Any) -> list[dict[str, Any]] | None: + if not isinstance(value, dict) or "foreslatt_vtg_datoer" not in value: + return None + return filter_upcoming_courses(value.get("foreslatt_vtg_datoer")) + + +async def replace_facility_vtg_courses(conn, facility_id: int, rows: Any) -> list[dict[str, Any]]: + normalized_rows = filter_upcoming_courses(rows) + await conn.execute("DELETE FROM facility_vtg_courses WHERE facility_id = $1", facility_id) + + if normalized_rows: + await conn.executemany( + """ + INSERT INTO facility_vtg_courses ( + facility_id, + display_label, + status, + start_date, + end_date, + sort_order + ) VALUES ($1, $2, $3, $4, $5, $6) + """, + [ + ( + facility_id, + row.get("dato"), + row.get("status"), + row.get("start_date"), + row.get("end_date"), + int(row.get("sort_order") or index), + ) + for index, row in enumerate(normalized_rows) + ], + ) + + legacy_rows = json.dumps( + [ + { + "dato": row.get("dato"), + "status": row.get("status"), + "start_date": row.get("start_date"), + "end_date": row.get("end_date"), + } + for row in normalized_rows + ] + ) + await conn.execute( + "UPDATE facilities SET vtg_datoer = $1::jsonb WHERE id = $2", + legacy_rows, + facility_id, + ) + return normalized_rows + + def format_place_page_row(row): if row is None: return None @@ -1450,10 +1540,109 @@ async def ensure_facility_columns(conn): ADD COLUMN IF NOT EXISTS golfamore_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB, - ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ + ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS vtg_content_draft JSONB, + ADD COLUMN IF NOT EXISTS vtg_courses_draft JSONB, + ADD COLUMN IF NOT EXISTS vtg_content_updated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS vtg_courses_updated_at TIMESTAMPTZ """) +async def ensure_vtg_course_tables(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS facility_vtg_courses ( + id SERIAL PRIMARY KEY, + facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE, + display_label TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'Ledig', + start_date DATE, + end_date DATE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_facility_vtg_courses_facility_id + ON facility_vtg_courses (facility_id) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_facility_vtg_courses_start_date + ON facility_vtg_courses (start_date) + """) + + facility_columns = await get_table_columns(conn, "facilities") + if "vtg_datoer" in facility_columns: + rows = await conn.fetch( + """ + SELECT f.id, f.vtg_datoer + FROM facilities f + WHERE COALESCE(jsonb_array_length(CASE WHEN jsonb_typeof(f.vtg_datoer) = 'array' THEN f.vtg_datoer ELSE '[]'::jsonb END), 0) > 0 + AND NOT EXISTS ( + SELECT 1 + FROM facility_vtg_courses c + WHERE c.facility_id = f.id + ) + """ + ) + for row in rows: + await replace_facility_vtg_courses(conn, int(row["id"]), row["vtg_datoer"]) + + if "vtg_content_draft" in facility_columns and "vtg_courses_draft" in facility_columns and "vtg_draft" in facility_columns: + draft_rows = await conn.fetch( + """ + SELECT id, vtg_draft, vtg_content_draft, vtg_courses_draft + FROM facilities + WHERE vtg_draft IS NOT NULL + AND vtg_draft::text != '{}' + """ + ) + for row in draft_rows: + legacy_draft = row["vtg_draft"] + content_draft = row["vtg_content_draft"] + courses_draft = row["vtg_courses_draft"] + + if isinstance(legacy_draft, str): + try: + legacy_draft = json.loads(legacy_draft) + except json.JSONDecodeError: + legacy_draft = {} + if isinstance(content_draft, str): + try: + content_draft = json.loads(content_draft) + except json.JSONDecodeError: + content_draft = None + if isinstance(courses_draft, str): + try: + courses_draft = json.loads(courses_draft) + except json.JSONDecodeError: + courses_draft = None + + next_content_draft = ( + content_draft + if isinstance(content_draft, dict) and content_draft + else prepare_vtg_content_draft_payload(legacy_draft) + ) + next_courses_draft = ( + courses_draft + if isinstance(courses_draft, list) + else prepare_vtg_course_draft_payload(legacy_draft) + ) + + if next_content_draft != content_draft or next_courses_draft != courses_draft: + await conn.execute( + """ + UPDATE facilities + SET vtg_content_draft = $1::jsonb, + vtg_courses_draft = $2::jsonb + WHERE id = $3 + """, + json.dumps(next_content_draft), + json.dumps(next_courses_draft if next_courses_draft is not None else []), + int(row["id"]), + ) + + async def ensure_place_pages_table(conn): await conn.execute(""" CREATE TABLE IF NOT EXISTS place_pages ( @@ -1631,6 +1820,7 @@ async def lifespan(app: FastAPI): ) async with app.state.pool.acquire() as conn: await ensure_facility_columns(conn) + await ensure_vtg_course_tables(conn) await ensure_place_pages_table(conn) await ensure_articles_table(conn) await ensure_public_user_tables(conn) @@ -3143,7 +3333,8 @@ 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', 'is_published', + 'vtg_updated_at', 'vtg_draft', 'vtg_content_draft', 'vtg_courses_draft', + 'vtg_content_updated_at', 'vtg_courses_updated_at', 'footnote_updated_at', 'is_published', 'golfpakker_draft', 'golfpakker_updated_at' ] @@ -3152,7 +3343,9 @@ async def update_facility_full(facility_id: int, request: Request): 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'membership_updated_at' } - vtg_fields = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer', 'vtg_updated_at'} + vtg_content_fields = {'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris'} + vtg_course_fields = {'vtg_datoer'} + vtg_fields = vtg_content_fields | vtg_course_fields | {'vtg_updated_at', 'vtg_content_updated_at', 'vtg_courses_updated_at'} changed_field_names = set(update_data.keys()) facility_slug = "" @@ -3185,11 +3378,23 @@ async def update_facility_full(facility_id: int, request: Request): 'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', + 'vtg_content_updated_at', + 'vtg_courses_updated_at', 'status_updated_at', 'footnote_updated_at', 'golfpakker_updated_at' ] + if changed_field_names & vtg_content_fields: + vtg_content_ts = datetime.utcnow() + update_data.setdefault('vtg_content_updated_at', vtg_content_ts) + update_data.setdefault('vtg_updated_at', vtg_content_ts) + + if changed_field_names & vtg_course_fields: + vtg_course_ts = datetime.utcnow() + update_data.setdefault('vtg_courses_updated_at', vtg_course_ts) + update_data.setdefault('vtg_updated_at', vtg_course_ts) + for i, (k, v) in enumerate(update_data.items(), 1): if isinstance(v, (dict, list)): set_clauses.append(f"{k} = ${i}::jsonb") @@ -3216,6 +3421,9 @@ async def update_facility_full(facility_id: int, request: Request): query = f"UPDATE facilities SET {', '.join(set_clauses)} WHERE id = ${len(values)}" await conn.execute(query, *values) + if 'vtg_datoer' in update_data: + await replace_facility_vtg_courses(conn, facility_id, update_data.get('vtg_datoer')) + # 2. OPPDATER BANER (COURSES) OG HULL (HOLES) if 'courses' in data: submitted_courses = [course for course in (data.get('courses') or []) if course] @@ -3602,34 +3810,123 @@ async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, http_request: @app.get("/api/admin/vtg/drafts") async def get_vtg_drafts(): - """Henter alle anlegg som har et ventende VTG-forslag.""" + """Henter alle anlegg som har ventende VTG-forslag for innhold eller kurs.""" async with app.state.pool.acquire() as conn: rows = await conn.fetch(""" - SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft + SELECT + id, + name, + slug, + vtg_lenke, + vtg_pris, + vtg_beskrivelse, + vtg_datoer, + vtg_content_draft, + vtg_courses_draft, + CASE + WHEN vtg_content_draft IS NOT NULL AND vtg_content_draft::text != '{}' + THEN TRUE + ELSE FALSE + END AS has_vtg_content_draft, + CASE + WHEN vtg_courses_draft IS NOT NULL + THEN TRUE + ELSE FALSE + END AS has_vtg_courses_draft FROM facilities - WHERE vtg_draft IS NOT NULL - AND vtg_draft::text != '{}' + WHERE ( + vtg_content_draft IS NOT NULL AND vtg_content_draft::text != '{}' + ) OR vtg_courses_draft IS NOT NULL ORDER BY name ASC """) return [format_row(row) for row in rows] -@app.post("/api/admin/vtg/approve-bulk") -async def approve_vtg_bulk(request: BulkVtgRequest): - """Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet.""" +@app.post("/api/admin/vtg/approve-content-bulk") +async def approve_vtg_content_bulk(request: BulkVtgContentRequest): facility_ids = [approval.facility_id for approval in request.approvals] async with app.state.pool.acquire() as conn: async with conn.transaction(): for approval in request.approvals: - datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]' - await conn.execute(""" - UPDATE facilities + await conn.execute( + """ + UPDATE facilities SET vtg_pris = $1, vtg_beskrivelse = $2, - vtg_datoer = $3::jsonb, + vtg_content_updated_at = NOW(), vtg_updated_at = NOW(), + vtg_content_draft = NULL, + vtg_draft = CASE + WHEN vtg_courses_draft IS NULL THEN NULL + ELSE vtg_draft + END + WHERE id = $3 + """, + approval.vtg_pris, + approval.vtg_beskrivelse, + approval.facility_id, + ) + facility_slugs = await fetch_facility_slugs(conn, facility_ids) + schedule_indexnow_submission( + collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), + reason="vtg content bulk approval", + ) + return {"status": "success"} + + +@app.post("/api/admin/vtg/approve-courses-bulk") +async def approve_vtg_courses_bulk(request: BulkVtgCoursesRequest): + facility_ids = [approval.facility_id for approval in request.approvals] + async with app.state.pool.acquire() as conn: + async with conn.transaction(): + for approval in request.approvals: + await replace_facility_vtg_courses(conn, approval.facility_id, approval.vtg_datoer) + await conn.execute( + """ + UPDATE facilities + SET vtg_courses_updated_at = NOW(), + vtg_updated_at = NOW(), + vtg_courses_draft = NULL, + vtg_draft = CASE + WHEN vtg_content_draft IS NULL THEN NULL + ELSE vtg_draft + END + WHERE id = $1 + """, + approval.facility_id, + ) + facility_slugs = await fetch_facility_slugs(conn, facility_ids) + schedule_indexnow_submission( + collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), + reason="vtg courses bulk approval", + ) + return {"status": "success"} + + +@app.post("/api/admin/vtg/approve-bulk") +async def approve_vtg_bulk(request: BulkVtgRequest): + """Kompatibilitets-endepunkt som godkjenner både innhold og kurs.""" + facility_ids = [approval.facility_id for approval in request.approvals] + async with app.state.pool.acquire() as conn: + async with conn.transaction(): + for approval in request.approvals: + await replace_facility_vtg_courses(conn, approval.facility_id, approval.vtg_datoer) + await conn.execute( + """ + UPDATE facilities + SET vtg_pris = $1, + vtg_beskrivelse = $2, + vtg_content_updated_at = NOW(), + vtg_courses_updated_at = NOW(), + vtg_updated_at = NOW(), + vtg_content_draft = NULL, + vtg_courses_draft = NULL, vtg_draft = NULL - WHERE id = $4 - """, approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id) + WHERE id = $3 + """, + approval.vtg_pris, + approval.vtg_beskrivelse, + approval.facility_id, + ) facility_slugs = await fetch_facility_slugs(conn, facility_ids) schedule_indexnow_submission( collect_facility_indexnow_urls(facility_slugs, extra_paths=["/vtg", "/golfbaner"]), diff --git a/backend/scrape_vtg.py b/backend/scrape_vtg.py index f6ac626..4cd0d9c 100644 --- a/backend/scrape_vtg.py +++ b/backend/scrape_vtg.py @@ -17,6 +17,7 @@ import google.generativeai as genai from dotenv import load_dotenv from env_config import get_database_url from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json +from vtg_courses import filter_upcoming_courses load_dotenv() @@ -209,14 +210,22 @@ async def run_vtg_scraper(facility_ids=None, progress_callback: ProgressCallback continue analyzed_count += 1 - found_dates = len(draft_data.get('foreslatt_vtg_datoer', [])) + normalized_course_draft = filter_upcoming_courses(draft_data.get('foreslatt_vtg_datoer', [])) + content_draft = { + "foreslatt_vtg_pris": draft_data.get("foreslatt_vtg_pris"), + "foreslatt_vtg_beskrivelse": draft_data.get("foreslatt_vtg_beskrivelse"), + "ai_begrunnelse": draft_data.get("ai_begrunnelse"), + } + found_dates = len(normalized_course_draft) print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {found_dates} datoer.") await conn.execute(""" UPDATE facilities - SET vtg_draft = $1::jsonb - WHERE id = $2 - """, json.dumps(draft_data), fac_id) + SET vtg_draft = $1::jsonb, + vtg_content_draft = $2::jsonb, + vtg_courses_draft = $3::jsonb + WHERE id = $4 + """, json.dumps(draft_data), json.dumps(content_draft), json.dumps(normalized_course_draft), fac_id) print(" 💾 VTG-utkast lagret i databasen!") saved_count += 1 diff --git a/backend/vtg_courses.py b/backend/vtg_courses.py new file mode 100644 index 0000000..6a939e0 --- /dev/null +++ b/backend/vtg_courses.py @@ -0,0 +1,202 @@ +import re +from datetime import date, datetime +from typing import Any + + +MONTH_MAP: dict[str, int] = { + "januar": 1, + "jan": 1, + "februar": 2, + "feb": 2, + "mars": 3, + "mar": 3, + "april": 4, + "apr": 4, + "mai": 5, + "juni": 6, + "jun": 6, + "juli": 7, + "jul": 7, + "august": 8, + "aug": 8, + "september": 9, + "sep": 9, + "sept": 9, + "oktober": 10, + "okt": 10, + "november": 11, + "nov": 11, + "desember": 12, + "des": 12, +} + + +def normalize_whitespace(value: str) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def _to_date(year: int, month: int, day: int) -> date | None: + try: + return date(year, month, day) + except ValueError: + return None + + +def _infer_year(month: int, day: int, explicit_year: int | None, today: date) -> int: + if explicit_year: + return explicit_year + + candidate = _to_date(today.year, month, day) + if candidate and candidate < today.replace(day=max(1, min(today.day, 28))): + if (today - candidate).days > 7: + return today.year + 1 + return today.year + + +def _parse_numeric_date(raw: str) -> date | None: + match = re.search(r"\b(\d{1,2})[./](\d{1,2})[./](\d{2,4})\b", raw) + if not match: + return None + + day = int(match.group(1)) + month = int(match.group(2)) + year = int(match.group(3)) + if year < 100: + year += 2000 + return _to_date(year, month, day) + + +def _parse_textual_dates(raw: str, today: date) -> list[date]: + results: list[date] = [] + pattern = re.compile( + r"\b(\d{1,2})\.?\s*(" + "|".join(sorted(MONTH_MAP.keys(), key=len, reverse=True)) + r")\b(?:\s+(20\d{2}))?", + re.IGNORECASE, + ) + for match in pattern.finditer(raw): + day = int(match.group(1)) + month = MONTH_MAP.get(match.group(2).lower()) + if not month: + continue + explicit_year = int(match.group(3)) if match.group(3) else None + year = _infer_year(month, day, explicit_year, today) + candidate = _to_date(year, month, day) + if candidate: + results.append(candidate) + return results + + +def parse_course_date_range(raw: str, today: date | None = None) -> tuple[date | None, date | None]: + reference_today = today or date.today() + normalized = normalize_whitespace(raw).lower() + if not normalized: + return None, None + + iso_candidate = None + try: + iso_candidate = datetime.fromisoformat(normalized).date() + except ValueError: + iso_candidate = None + if iso_candidate: + return iso_candidate, iso_candidate + + numeric_dates = re.findall(r"\b\d{1,2}[./]\d{1,2}[./]\d{2,4}\b", normalized) + if len(numeric_dates) >= 2: + start = _parse_numeric_date(numeric_dates[0]) + end = _parse_numeric_date(numeric_dates[1]) + return start, end or start + if len(numeric_dates) == 1: + single = _parse_numeric_date(numeric_dates[0]) + return single, single + + range_match = re.search( + r"\b(\d{1,2})\s*\.?\s*(?:-|–|—|til)\s*(\d{1,2})\.?\s*(" + "|".join(sorted(MONTH_MAP.keys(), key=len, reverse=True)) + r")\b(?:\s+(20\d{2}))?", + normalized, + re.IGNORECASE, + ) + if range_match: + start_day = int(range_match.group(1)) + end_day = int(range_match.group(2)) + month = MONTH_MAP.get(range_match.group(3).lower()) + explicit_year = int(range_match.group(4)) if range_match.group(4) else None + if month: + year = _infer_year(month, end_day, explicit_year, reference_today) + start = _to_date(year, month, start_day) + end = _to_date(year, month, end_day) + return start, end or start + + textual_dates = _parse_textual_dates(normalized, reference_today) + if len(textual_dates) >= 2: + return textual_dates[0], textual_dates[1] + if len(textual_dates) == 1: + return textual_dates[0], textual_dates[0] + + return None, None + + +def normalize_vtg_course_rows(rows: Any) -> list[dict[str, Any]]: + if not isinstance(rows, list): + return [] + + normalized_rows: list[dict[str, Any]] = [] + for index, row in enumerate(rows): + if not isinstance(row, dict): + continue + display_label = normalize_whitespace(str(row.get("dato") or row.get("display_label") or "")) + if not display_label: + continue + status = normalize_whitespace(str(row.get("status") or "Ledig")) or "Ledig" + explicit_start = row.get("start_date") + explicit_end = row.get("end_date") + if explicit_start: + try: + start_date = datetime.fromisoformat(str(explicit_start)).date() + except ValueError: + start_date = None + else: + start_date = None + if explicit_end: + try: + end_date = datetime.fromisoformat(str(explicit_end)).date() + except ValueError: + end_date = None + else: + end_date = None + + if not start_date and not end_date: + start_date, end_date = parse_course_date_range(display_label) + + normalized_rows.append( + { + "dato": display_label, + "status": status, + "start_date": start_date.isoformat() if start_date else None, + "end_date": end_date.isoformat() if end_date else None, + "sort_order": index, + } + ) + + normalized_rows.sort( + key=lambda row: ( + row.get("start_date") or row.get("end_date") or "9999-12-31", + int(row.get("sort_order") or 0), + row.get("dato") or "", + ) + ) + return normalized_rows + + +def is_upcoming_course(row: dict[str, Any], today: date | None = None) -> bool: + reference_today = today or date.today() + end_value = row.get("end_date") or row.get("start_date") + if not end_value: + return True + try: + end_date = datetime.fromisoformat(str(end_value)).date() + except ValueError: + return True + return end_date >= reference_today + + +def filter_upcoming_courses(rows: Any) -> list[dict[str, Any]]: + normalized_rows = normalize_vtg_course_rows(rows) + return [row for row in normalized_rows if is_upcoming_course(row)] diff --git a/frontend/src/app/admin/vtg/page.tsx b/frontend/src/app/admin/vtg/page.tsx index 5b47617..e969398 100644 --- a/frontend/src/app/admin/vtg/page.tsx +++ b/frontend/src/app/admin/vtg/page.tsx @@ -8,13 +8,17 @@ import Link from 'next/link'; type VtgDateRow = { dato?: string; status?: string; + start_date?: string | null; + end_date?: string | null; }; const normalizeDateRows = (value: any): VtgDateRow[] => { if (!Array.isArray(value)) return []; return value.map((row) => ({ dato: typeof row?.dato === 'string' ? row.dato : '', - status: typeof row?.status === 'string' ? row.status : 'Ledig' + status: typeof row?.status === 'string' ? row.status : 'Ledig', + start_date: typeof row?.start_date === 'string' ? row.start_date : null, + end_date: typeof row?.end_date === 'string' ? row.end_date : null, })); }; @@ -37,6 +41,18 @@ const formatPriceLabel = (value: any) => { return parsed === null ? 'Ingen pris registrert' : `${parsed} kr`; }; +const hasContentDraftRecord = (draft: any) => Boolean(draft?.has_vtg_content_draft); +const hasCourseDraftRecord = (draft: any) => Boolean(draft?.has_vtg_courses_draft); +const hasContentDraftChanges = (draft: any) => ( + hasContentDraftRecord(draft) && ( + priceValue(draft?.vtg_pris) !== priceValue(draft?.edit_pris) || + textValue(draft?.vtg_beskrivelse) !== textValue(draft?.edit_beskrivelse) + ) +); +const hasCourseDraftChanges = (draft: any) => ( + hasCourseDraftRecord(draft) && !datesAreEqual(draft?.vtg_datoer, draft?.edit_datoer) +); + function ReadOnlyDateList({ dates, emptyLabel }: { dates: any; emptyLabel: string }) { const normalizedDates = normalizeDateRows(dates); @@ -68,20 +84,34 @@ export default function VtgWasher() { .then(res => res.json()) .then(data => { const editableDrafts = data.map((f: any) => { - let parsedDraft = f.vtg_draft; - if (typeof parsedDraft === 'string') { - try { parsedDraft = JSON.parse(parsedDraft); } + let parsedContentDraft = f.vtg_content_draft; + if (typeof parsedContentDraft === 'string') { + try { parsedContentDraft = JSON.parse(parsedContentDraft); } catch (e) { console.error("Kunne ikke parse JSON", e); } } + const currentDates = normalizeDateRows(f.vtg_datoer); + const parsedCourseDraft = normalizeDateRows(f.vtg_courses_draft); + const contentDraftActive = Boolean(f.has_vtg_content_draft); + const courseDraftActive = Boolean(f.has_vtg_courses_draft); return { ...f, - vtg_draft: parsedDraft, - edit_pris: parsedDraft?.foreslatt_vtg_pris || f.vtg_pris || '', - edit_beskrivelse: parsedDraft?.foreslatt_vtg_beskrivelse || f.vtg_beskrivelse || '', - edit_datoer: parsedDraft?.foreslatt_vtg_datoer || [] + vtg_content_draft: parsedContentDraft, + vtg_datoer: currentDates, + vtg_courses_draft: parsedCourseDraft, + has_vtg_content_draft: contentDraftActive, + has_vtg_courses_draft: courseDraftActive, + edit_pris: contentDraftActive ? (parsedContentDraft?.foreslatt_vtg_pris ?? f.vtg_pris ?? '') : (f.vtg_pris ?? ''), + edit_beskrivelse: contentDraftActive ? (parsedContentDraft?.foreslatt_vtg_beskrivelse ?? f.vtg_beskrivelse ?? '') : (f.vtg_beskrivelse ?? ''), + edit_datoer: courseDraftActive ? parsedCourseDraft : currentDates }; - }); + }).filter((draft: any) => ( + (draft.has_vtg_content_draft && ( + priceValue(draft.vtg_pris) !== priceValue(draft.edit_pris) || + textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse) + )) || + (draft.has_vtg_courses_draft && !datesAreEqual(draft.vtg_datoer, draft.edit_datoer)) + )); setDrafts(editableDrafts); setLoading(false); }) @@ -135,25 +165,24 @@ export default function VtgWasher() { })); }; - const handleApprove = async () => { - const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({ + const handleApproveContent = async () => { + const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasContentDraftChanges(d)).map(d => ({ facility_id: d.id, vtg_pris: Number(d.edit_pris) || null, vtg_beskrivelse: d.edit_beskrivelse, - vtg_datoer: d.edit_datoer })); - if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne."); + if (toApprove.length === 0) return alert("Velg minst ett anlegg med ventende VTG-innhold."); setSaving(true); try { - const res = await adminFetch(`${API_URL}/admin/vtg/approve-bulk`, { + const res = await adminFetch(`${API_URL}/admin/vtg/approve-content-bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approvals: toApprove }) }); if (res.ok) { - alert(`${toApprove.length} anlegg oppdatert!`); + alert(`${toApprove.length} VTG-innhold oppdatert!`); setSelectedIds([]); fetchDrafts(); } else { @@ -165,6 +194,37 @@ export default function VtgWasher() { setSaving(false); }; + const handleApproveCourses = async () => { + const toApprove = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).map(d => ({ + facility_id: d.id, + vtg_datoer: d.edit_datoer + })); + + if (toApprove.length === 0) return alert("Velg minst ett anlegg med ventende kommende kurs."); + + setSaving(true); + try { + const res = await adminFetch(`${API_URL}/admin/vtg/approve-courses-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approvals: toApprove }) + }); + if (res.ok) { + alert(`${toApprove.length} kurslister oppdatert!`); + setSelectedIds([]); + fetchDrafts(); + } else { + alert("Noe gikk galt under lagring."); + } + } catch (e) { + alert("Nettverksfeil"); + } + setSaving(false); + }; + + const selectedContentCount = drafts.filter(d => selectedIds.includes(d.id) && hasContentDraftChanges(d)).length; + const selectedCourseCount = drafts.filter(d => selectedIds.includes(d.id) && hasCourseDraftChanges(d)).length; + if (loading) return
Laster VTG-utkast...
; return ( @@ -175,11 +235,16 @@ export default function VtgWasher() {
← Tilbake til oversikten

VTG-Vaskeriet

-

Gå gjennom og godkjenn kursinformasjon for Veien til Golf.

+

Gå gjennom og godkjenn stabilt VTG-innhold og kommende kurs hver for seg.

+
+
+ +
- {drafts.length === 0 ? ( @@ -200,6 +265,7 @@ export default function VtgWasher() { const priceChanged = priceValue(draft.vtg_pris) !== priceValue(draft.edit_pris); const descriptionChanged = textValue(draft.vtg_beskrivelse) !== textValue(draft.edit_beskrivelse); const datesChanged = !datesAreEqual(draft.vtg_datoer, draft.edit_datoer); + const contentChangedCount = [priceChanged, descriptionChanged].filter(Boolean).length; const changedCount = [priceChanged, descriptionChanged, datesChanged].filter(Boolean).length; return ( @@ -213,23 +279,25 @@ export default function VtgWasher() { 0 ? 'bg-amber-100 text-amber-900' : 'bg-gray-100 text-gray-500'}`}> {changedCount > 0 ? `${changedCount} felt endret` : 'Ingen reell endring'} + {hasContentDraftChanges(draft) && Innholdsdraft} + {hasCourseDraftChanges(draft) && Kursdraft} {priceChanged && Pris} {descriptionChanged && Beskrivelse} - {datesChanged && Kursdatoer} + {datesChanged && Kommende kurs} Sjekk Nettside ↗ - {draft.vtg_draft?.ai_begrunnelse && ( + {draft.vtg_content_draft?.ai_begrunnelse && (
- 🤖 AI Begrunnelse: {draft.vtg_draft.ai_begrunnelse} + 🤖 AI Begrunnelse: {draft.vtg_content_draft.ai_begrunnelse}
)}
-

Pris & Beskrivelse

+

Stabilt innhold

I dag
@@ -246,11 +314,11 @@ export default function VtgWasher() {
-
+
0 ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
Forslag
- - {priceChanged || descriptionChanged ? 'Har endringer' : 'Lik dagens verdi'} + 0 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}> + {contentChangedCount > 0 ? 'Har endringer' : 'Lik dagens verdi'}
@@ -266,7 +334,7 @@ export default function VtgWasher() {
-

Kursdatoer

+

Kommende kurs

I dag
@@ -276,12 +344,12 @@ export default function VtgWasher() {
Forslag
- {datesChanged ? 'Har endringer' : 'Lik dagens datoer'} + {datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
{draft.edit_datoer.length === 0 ? ( -
Fant ingen spesifikke kursdatoer.
+
Ingen kommende kurs funnet i forslaget.
) : ( draft.edit_datoer.map((row: any, idx: number) => (
diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 8635623..52711c8 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -209,7 +209,14 @@ export default function FacilityDetailView({ // Pris og kurs-arrays const greenfeeRaw = parseJson(facility.greenfee, []); const golfpakkerRaw = parseJson(facility.golfpakker, []); - const vtgDatoer = parseJson(facility.vtg_datoer, []); + const todayKey = new Date().toISOString().slice(0, 10); + const vtgDatoer = parseJson(facility.vtg_datoer, []).filter((row: any) => { + const endDate = typeof row?.end_date === 'string' ? row.end_date : ''; + const startDate = typeof row?.start_date === 'string' ? row.start_date : ''; + const comparable = endDate || startDate; + if (!comparable) return true; + return comparable >= todayKey; + }); const golfamoreData = parseJson(facility.golfamore_data, {}); const nsgData = parseJson(facility.nsg_data, {});