Før grensesnitt for å registrere nytt golfanlegg
This commit is contained in:
parent
f17075da0f
commit
fec5f4e8c6
5 changed files with 636 additions and 53 deletions
335
backend/main.py
335
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"]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
202
backend/vtg_courses.py
Normal file
202
backend/vtg_courses.py
Normal file
|
|
@ -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)]
|
||||
|
|
@ -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 <div className="p-20 text-center font-black animate-pulse">Laster VTG-utkast...</div>;
|
||||
|
||||
return (
|
||||
|
|
@ -175,11 +235,16 @@ export default function VtgWasher() {
|
|||
<div>
|
||||
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||
<h1 className="text-4xl font-black">VTG-Vaskeriet</h1>
|
||||
<p className="text-sm text-gray-600 mt-2">Gå gjennom og godkjenn kursinformasjon for Veien til Golf.</p>
|
||||
<p className="text-sm text-gray-600 mt-2">Gå gjennom og godkjenn stabilt VTG-innhold og kommende kurs hver for seg.</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3 md:w-auto md:flex-row">
|
||||
<button onClick={handleApproveContent} disabled={saving || selectedContentCount === 0} className="btn btn-lg btn-secondary w-full md:w-auto disabled:opacity-50">
|
||||
{saving ? 'Lagrer...' : `Godkjenn innhold (${selectedContentCount})`}
|
||||
</button>
|
||||
<button onClick={handleApproveCourses} disabled={saving || selectedCourseCount === 0} className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50">
|
||||
{saving ? 'Lagrer...' : `Godkjenn kurs (${selectedCourseCount})`}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50">
|
||||
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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() {
|
|||
<span className={`rounded-full px-3 py-1 text-[11px] font-black uppercase tracking-widest ${changedCount > 0 ? 'bg-amber-100 text-amber-900' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{changedCount > 0 ? `${changedCount} felt endret` : 'Ingen reell endring'}
|
||||
</span>
|
||||
{hasContentDraftChanges(draft) && <span className="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-slate-700">Innholdsdraft</span>}
|
||||
{hasCourseDraftChanges(draft) && <span className="rounded-full bg-sky-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-sky-800">Kursdraft</span>}
|
||||
{priceChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Pris</span>}
|
||||
{descriptionChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Beskrivelse</span>}
|
||||
{datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kursdatoer</span>}
|
||||
{datesChanged && <span className="rounded-full bg-green-100 px-3 py-1 text-[11px] font-black uppercase tracking-widest text-green-800">Kommende kurs</span>}
|
||||
</div>
|
||||
</div>
|
||||
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="btn btn-md btn-secondary w-full md:w-auto">Sjekk Nettside ↗</a>
|
||||
</div>
|
||||
|
||||
{draft.vtg_draft?.ai_begrunnelse && (
|
||||
{draft.vtg_content_draft?.ai_begrunnelse && (
|
||||
<div className="bg-blue-50/50 p-4 rounded-xl text-sm italic text-blue-900 border border-blue-100">
|
||||
<strong>🤖 AI Begrunnelse:</strong> {draft.vtg_draft.ai_begrunnelse}
|
||||
<strong>🤖 AI Begrunnelse:</strong> {draft.vtg_content_draft.ai_begrunnelse}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Pris & Beskrivelse</h4>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600">Stabilt innhold</h4>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
|
||||
|
|
@ -246,11 +314,11 @@ export default function VtgWasher() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`rounded-2xl border p-4 ${priceChanged || descriptionChanged ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
|
||||
<div className={`rounded-2xl border p-4 ${contentChangedCount > 0 ? 'border-green-200 bg-green-50/60' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
|
||||
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${priceChanged || descriptionChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{priceChanged || descriptionChanged ? 'Har endringer' : 'Lik dagens verdi'}
|
||||
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${contentChangedCount > 0 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{contentChangedCount > 0 ? 'Har endringer' : 'Lik dagens verdi'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -266,7 +334,7 @@ export default function VtgWasher() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kursdatoer</h4>
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-4">Kommende kurs</h4>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-3 text-[10px] font-black uppercase tracking-widest text-gray-500">I dag</div>
|
||||
|
|
@ -276,12 +344,12 @@ export default function VtgWasher() {
|
|||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">Forslag</div>
|
||||
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-widest ${datesChanged ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{datesChanged ? 'Har endringer' : 'Lik dagens datoer'}
|
||||
{datesChanged ? 'Har endringer' : 'Lik dagens kurs'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{draft.edit_datoer.length === 0 ? (
|
||||
<div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Fant ingen spesifikke kursdatoer.</div>
|
||||
<div className="p-4 bg-white rounded-xl text-sm text-gray-500 italic border border-gray-200">Ingen kommende kurs funnet i forslaget.</div>
|
||||
) : (
|
||||
draft.edit_datoer.map((row: any, idx: number) => (
|
||||
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center">
|
||||
|
|
|
|||
|
|
@ -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, {});
|
||||
|
|
|
|||
Loading…
Reference in a new issue