Før scraper med golfpakker

This commit is contained in:
Erol 2026-04-15 08:15:53 +02:00
parent 05ded6513e
commit d166a7ce5d
9 changed files with 1574 additions and 65 deletions

View file

@ -302,6 +302,11 @@ class GreenfeeApproval(BaseModel):
greenfee: List[dict] greenfee: List[dict]
class GolfpakkerApproval(BaseModel):
facility_id: int
golfpakker: List[dict]
class VtgApproval(BaseModel): class VtgApproval(BaseModel):
facility_id: int facility_id: int
vtg_pris: int | None vtg_pris: int | None
@ -312,6 +317,10 @@ class BulkVtgRequest(BaseModel):
approvals: List[VtgApproval] approvals: List[VtgApproval]
class BulkGolfpakkerRequest(BaseModel):
approvals: List[GolfpakkerApproval]
class AdminPasswordConfirm(BaseModel): class AdminPasswordConfirm(BaseModel):
password: str password: str
@ -358,7 +367,8 @@ def format_row(row):
for key in [ for key in [
'status_updated_at', 'created_at', 'slope_valid_until', 'status_updated_at', 'created_at', 'slope_valid_until',
'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at' 'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at',
'golfpakker_updated_at'
]: ]:
if isinstance(d.get(key), (date, datetime)): if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat() d[key] = d[key].isoformat()
@ -369,7 +379,7 @@ def format_row(row):
] ]
json_dict_fields = [ json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data', 'amenities', 'vtg', 'nsg_data', 'golfamore_data',
'membership_draft', 'greenfee_draft', 'vtg_draft' 'membership_draft', 'greenfee_draft', 'vtg_draft', 'golfpakker_draft'
] ]
for field in json_list_fields: for field in json_list_fields:
@ -397,10 +407,80 @@ def format_row(row):
d[field] = {} d[field] = {}
elif not isinstance(val, dict): elif not isinstance(val, dict):
d[field] = {} d[field] = {}
return d return d
def normalize_club_lookup_value(value: str | None) -> str:
text = str(value or "").strip().lower()
if not text:
return ""
text = text.replace("&", " og ")
text = re.sub(r"\bgk\b", " golfklubb ", text)
text = re.sub(r"\bgs\b", " golfsenter ", text)
text = re.sub(r"[^a-z0-9æøå]+", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
async def resolve_cooperating_club_slugs(
conn,
suggested_names: list[str] | None,
*,
exclude_facility_id: int | None = None,
) -> list[str]:
cleaned_names = [str(name or "").strip() for name in (suggested_names or []) if str(name or "").strip()]
if not cleaned_names:
return []
rows = await conn.fetch("SELECT id, name, slug FROM facilities ORDER BY name ASC")
candidates: list[tuple[str, str]] = []
for row in rows:
facility_id = int(row["id"])
if exclude_facility_id and facility_id == exclude_facility_id:
continue
name = str(row["name"] or "").strip()
slug = str(row["slug"] or "").strip()
normalized_candidates = " ".join(
part for part in [
normalize_club_lookup_value(name),
normalize_club_lookup_value(slug.replace("-", " ")),
] if part
).strip()
candidates.append((slug, normalized_candidates))
resolved_slugs: list[str] = []
seen_slugs: set[str] = set()
for suggested_name in cleaned_names:
normalized_suggestion = normalize_club_lookup_value(suggested_name)
if not normalized_suggestion:
continue
exact_matches = [candidate for candidate in candidates if candidate[1] == normalized_suggestion]
if len(exact_matches) == 1:
slug = exact_matches[0][0]
if slug and slug not in seen_slugs:
resolved_slugs.append(slug)
seen_slugs.add(slug)
continue
partial_matches = [
candidate
for candidate in candidates
if normalized_suggestion in candidate[1] or candidate[1] in normalized_suggestion
]
if len(partial_matches) == 1:
slug = partial_matches[0][0]
if slug and slug not in seen_slugs:
resolved_slugs.append(slug)
seen_slugs.add(slug)
return resolved_slugs
def generate_totp_qr_svg(provisioning_uri: str) -> str: def generate_totp_qr_svg(provisioning_uri: str) -> str:
image = qrcode.make( image = qrcode.make(
provisioning_uri, provisioning_uri,
@ -782,7 +862,10 @@ async def ensure_facility_columns(conn):
"""Legger til nye facility-kolonner ved behov.""" """Legger til nye facility-kolonner ved behov."""
await conn.execute(""" await conn.execute("""
ALTER TABLE facilities ALTER TABLE facilities
ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ,
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
""") """)
@ -1845,8 +1928,9 @@ async def update_facility_full(facility_id: int, request: Request):
'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer', 'vtg_beskrivelse', 'vtg_lenke', 'vtg_pris', 'vtg_datoer',
'guest_requirements', 'scrape_method', 'scrape_status_url', 'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at', 'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke', 'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
'footnote_updated_at' 'vtg_updated_at', 'vtg_draft', 'footnote_updated_at',
'golfpakker_draft', 'golfpakker_updated_at'
] ]
update_data = {k: v for k, v in data.items() if k in allowed_fields} update_data = {k: v for k, v in data.items() if k in allowed_fields}
@ -1876,7 +1960,8 @@ async def update_facility_full(facility_id: int, request: Request):
'greenfee_updated_at', 'greenfee_updated_at',
'vtg_updated_at', 'vtg_updated_at',
'status_updated_at', 'status_updated_at',
'footnote_updated_at' 'footnote_updated_at',
'golfpakker_updated_at'
] ]
for i, (k, v) in enumerate(update_data.items(), 1): for i, (k, v) in enumerate(update_data.items(), 1):
@ -2073,15 +2158,28 @@ async def approve_membership_bulk(request: BulkApprovalRequest):
@app.patch("/api/admin/facilities/{facility_id}/quick-edit") @app.patch("/api/admin/facilities/{facility_id}/quick-edit")
async def quick_edit_facility(facility_id: int, request: QuickEditRequest): async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet.""" """Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
# Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering # Sikkerhet: Tillat KUN disse URL-/tekstfeltene for hurtigredigering
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector'] allowed_fields = ['scrape_status_url', 'medlemskap_url', 'greenfee_url', 'golfpakker_url', 'vtg_lenke', 'scrape_status_selector', 'footnote', 'website_url']
if request.field not in allowed_fields: if request.field not in allowed_fields:
raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.") raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen if request.field == 'footnote':
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2", normalized_value = str(request.value or '').strip() or None
request.value, facility_id) await conn.execute(
"""
UPDATE facilities
SET footnote = $1,
footnote_updated_at = CASE WHEN $1 IS NULL THEN NULL ELSE NOW() END
WHERE id = $2
""",
normalized_value,
facility_id
)
else:
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
request.value, facility_id)
return {"status": "success"} return {"status": "success"}
# --- GREENFEE "VASKERI" ENDEPUNKTER --- # --- GREENFEE "VASKERI" ENDEPUNKTER ---
@ -2108,13 +2206,30 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
async with app.state.pool.acquire() as conn: async with app.state.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
for approval in request.approvals: for approval in request.approvals:
draft_row = await conn.fetchrow(
"SELECT greenfee_draft FROM facilities WHERE id = $1",
approval.facility_id
)
draft_payload = format_row(draft_row) if draft_row else {}
draft_data = draft_payload.get("greenfee_draft", {}) if isinstance(draft_payload, dict) else {}
suggested_clubs = draft_data.get("foreslatt_avtaleklubber", []) if isinstance(draft_data, dict) else []
cooperating_club_slugs = await resolve_cooperating_club_slugs(
conn,
suggested_clubs if isinstance(suggested_clubs, list) else [],
exclude_facility_id=approval.facility_id,
)
await conn.execute(""" await conn.execute("""
UPDATE facilities UPDATE facilities
SET greenfee = $1::jsonb, SET greenfee = $1::jsonb,
cooperating_clubs = CASE
WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs
ELSE $2::jsonb
END,
greenfee_updated_at = NOW(), greenfee_updated_at = NOW(),
greenfee_draft = NULL greenfee_draft = NULL
WHERE id = $2 WHERE id = $3
""", json.dumps(approval.greenfee), approval.facility_id) """, json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
return {"status": "success"} return {"status": "success"}
@app.post("/api/admin/run-greenfee-scraper") @app.post("/api/admin/run-greenfee-scraper")
@ -2162,6 +2277,43 @@ async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, http_request: Requ
print(f"📡 API mottok forespørsel om VTG-skraping for IDer: {request.facility_ids}") print(f"📡 API mottok forespørsel om VTG-skraping for IDer: {request.facility_ids}")
return await queue_scrape_job("vtg", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None)) return await queue_scrape_job("vtg", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
@app.get("/api/admin/golfpakker/drafts")
async def get_golfpakker_drafts():
"""Henter alle anlegg som har et ventende golfpakke-forslag."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, slug, website_url, golfpakker_url, golfpakker, golfpakker_draft
FROM facilities
WHERE golfpakker_draft IS NOT NULL
AND golfpakker_draft::text != '{}'
ORDER BY name ASC
""")
return [format_row(row) for row in rows]
@app.post("/api/admin/golfpakker/approve-bulk")
async def approve_golfpakker_bulk(request: BulkGolfpakkerRequest):
"""Godkjenner AI-forslag for golfpakker og sletter utkastet."""
async with app.state.pool.acquire() as conn:
async with conn.transaction():
for approval in request.approvals:
await conn.execute("""
UPDATE facilities
SET golfpakker = $1::jsonb,
golfpakker_updated_at = NOW(),
golfpakker_draft = NULL
WHERE id = $2
""", json.dumps(approval.golfpakker), approval.facility_id)
return {"status": "success"}
@app.post("/api/admin/run-golfpakker-scraper")
async def run_golfpakker_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Tar imot IDer for golfpakkeskraping og legger jobben i kø."""
print(f"📡 API mottok forespørsel om golfpakkeskraping for IDer: {request.facility_ids}")
return await queue_scrape_job("golfpakker", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -0,0 +1,368 @@
"""
TEE OFF - GOLFPAKKE-SKRAPER MED GEMINI AI
---------------------------------------------------------------------------
Starter klubbens nettside, følger relevante interne lenker om golfpakker/
opphold/hotell, og lagrer AI-forslag som utkast.
---------------------------------------------------------------------------
"""
import argparse
import asyncio
import json
import os
from urllib.parse import urljoin, urlparse
import asyncpg
import google.generativeai as genai
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from playwright.async_api import async_playwright
from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json
load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
if not GEMINI_API_KEY:
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel("gemini-2.5-flash")
PACKAGE_LINK_HINTS = (
"golfpakke",
"golfpakker",
"pakke",
"pakker",
"opphold",
"overnatting",
"hotel",
"hotell",
"resort",
"accommodation",
"stay",
)
def _extract_text(html_content: str) -> str:
soup = BeautifulSoup(html_content, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.extract()
return soup.get_text(separator=" ", strip=True)
def _extract_candidate_links(html_content: str, base_url: str) -> list[str]:
soup = BeautifulSoup(html_content, "html.parser")
base_host = urlparse(base_url).netloc.lower()
candidates: list[str] = []
seen: set[str] = set()
for anchor in soup.find_all("a", href=True):
href = str(anchor.get("href") or "").strip()
if not href or href.startswith("#") or href.startswith("mailto:") or href.startswith("tel:") or href.startswith("javascript:"):
continue
absolute_url = urljoin(base_url, href)
parsed = urlparse(absolute_url)
if parsed.scheme not in {"http", "https"}:
continue
if parsed.netloc.lower() != base_host:
continue
haystack = f"{absolute_url} {anchor.get_text(' ', strip=True)}".lower()
if not any(hint in haystack for hint in PACKAGE_LINK_HINTS):
continue
normalized_url = absolute_url.rstrip("/")
if normalized_url in seen:
continue
seen.add(normalized_url)
candidates.append(normalized_url)
return candidates[:6]
async def fetch_page_data(url: str, browser) -> tuple[str, str]:
url = url.strip()
if not url.startswith("http"):
return "", ""
print(f" 🌐 Laster inn: {url}")
page = await browser.new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=20000)
html_content = await page.content()
return _extract_text(html_content), html_content
except Exception as exc:
print(f" ❌ Feil ved lasting av {url}: {exc}")
return "", ""
finally:
await page.close()
async def collect_package_source_text(urls: list[str], browser) -> str:
combined_sections: list[str] = []
visited_urls: set[str] = set()
for source_url in urls:
page_text, html_content = await fetch_page_data(source_url, browser)
if page_text:
combined_sections.append(f"--- TEKST FRA SIDE ({source_url}) ---\n{page_text}")
visited_urls.add(source_url.rstrip("/"))
if not html_content:
continue
for candidate_url in _extract_candidate_links(html_content, source_url):
normalized_candidate = candidate_url.rstrip("/")
if normalized_candidate in visited_urls:
continue
visited_urls.add(normalized_candidate)
candidate_text, _ = await fetch_page_data(candidate_url, browser)
if candidate_text:
combined_sections.append(f"--- TEKST FRA SIDE ({candidate_url}) ---\n{candidate_text}")
return "\n\n".join(combined_sections)
def analyze_golfpakker_with_gemini(text: str, club_name: str) -> dict | None:
print(f" 🧠 Sender {len(text)} tegn til Gemini for golfpakke-analyse...")
prompt = f"""
Du er en ekspert norske golfklubber og golfpakker.
Din oppgave er å lese tekster hentet fra nettsidene til "{club_name}" og identifisere eventuelle golfpakker, oppholdspakker eller overnattingstilbud som er relevante for greenfeespillere.
REGLER:
- Trekk bare ut faktiske golfpakker/oppholdspakker. Ikke vanlige greenfeepriser, medlemskap eller bedriftspakker.
- For hver pakke skal du hente ut:
1. navn
2. pris hvis den er eksplisitt oppgitt
3. en kort oppsummering 1-3 setninger om hva pakken går ut
4. lenke til siden der pakken presenteres
- Hvis flere pakker beskrives samme side, kan de bruke samme lenke.
- Hvis pris ikke finnes eksplisitt, sett den til null.
- Hvis du ikke finner noen golfpakker, returner en tom liste.
- Bruk URL-ene som står i markørene `--- TEKST FRA SIDE (...) ---` når du fyller inn lenke.
TEKST FRA NETTSIDENE:
{text}
Returner KUN gyldig JSON med denne strukturen:
{{
"foreslatt_golfpakker": [
{{
"navn": "Golfpakke med hotell",
"pris": 2490,
"beskrivelse": "Én natt på hotell, frokost og greenfee for to personer. Pakken gjelder i utvalgte perioder gjennom sesongen.",
"lenke": "https://eksempel.no/golfpakke"
}}
],
"ai_begrunnelse": "Kort forklaring på hvilke sider og signaler du brukte."
}}
"""
try:
response = model.generate_content(prompt)
parsed = parse_llm_json(response.text)
return parsed if isinstance(parsed, dict) else None
except Exception as exc:
print(f" ❌ AI-analyse feilet: {exc}")
return None
async def run_golfpakker_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None):
print("🚀 Starter golfpakke-skraperen...")
conn = await asyncpg.connect(DB_URL)
facilities = []
analyzed_count = 0
saved_count = 0
skipped_count = 0
failed_count = 0
try:
query = """
SELECT
id,
name,
website_url,
golfpakker_url,
COALESCE(NULLIF(TRIM(golfpakker_url), ''), NULLIF(TRIM(website_url), '')) AS source_url
FROM facilities
WHERE COALESCE(NULLIF(TRIM(golfpakker_url), ''), NULLIF(TRIM(website_url), '')) IS NOT NULL
"""
if facility_ids:
query += f" AND id IN ({','.join(map(str, facility_ids))})"
facilities = await conn.fetch(query)
total_facilities = len(facilities)
print(f"📋 Fant {total_facilities} anlegg å skrape.")
await emit_progress(
progress_callback,
progress_total=total_facilities,
progress_completed=0,
progress_ok=0,
progress_failed=0,
progress_skipped=0,
event=make_progress_event(
facility_id=None,
facility_name="Golfpakker",
outcome="info",
message=f"Starter golfpakkeskraping for {total_facilities} anlegg.",
processed=0,
total=total_facilities,
),
)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
for index, facility in enumerate(facilities, start=1):
fac_id = facility["id"]
name = facility["name"]
urls_raw = facility["source_url"]
print(f"\n▶️ Behandler golfpakker for: {name} (ID: {fac_id})")
await emit_progress(
progress_callback,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="info",
message="Starter henting av golfpakke-kilde med fallback til nettside.",
processed=index - 1,
total=total_facilities,
),
)
urls = [url.strip() for url in str(urls_raw or "").split(",") if url.strip()]
try:
combined_text = await collect_package_source_text(urls, browser)
if len(combined_text) < 50:
print(" ⚠️ Fant for lite tekst, hopper over.")
skipped_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="warning",
message="Hoppet over fordi det ble funnet for lite relevant tekst.",
processed=index,
total=total_facilities,
),
)
continue
draft_data = analyze_golfpakker_with_gemini(combined_text[:30000], name)
if not draft_data:
failed_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message="AI-analysen ga ikke et gyldig golfpakkeutkast.",
processed=index,
total=total_facilities,
),
)
continue
analyzed_count += 1
found_packages = len(draft_data.get("foreslatt_golfpakker", []))
print(f" ✅ AI fant {found_packages} golfpakker.")
await conn.execute(
"""
UPDATE facilities
SET golfpakker_draft = $1::jsonb
WHERE id = $2
""",
json.dumps(draft_data),
fac_id,
)
print(" 💾 Golfpakke-utkast lagret i databasen!")
saved_count += 1
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="success",
message=f"Utkast lagret med {found_packages} golfpakker.",
processed=index,
total=total_facilities,
),
)
except Exception as exc:
failed_count += 1
print(f" ❌ Uventet feil for {name}: {exc}")
await emit_progress(
progress_callback,
progress_completed=index,
progress_ok=saved_count,
progress_failed=failed_count,
progress_skipped=skipped_count,
current_facility_id=fac_id,
current_facility_name=name,
event=make_progress_event(
facility_id=fac_id,
facility_name=name,
outcome="error",
message=f"Feilet under behandling: {str(exc).splitlines()[0]}",
processed=index,
total=total_facilities,
),
)
await browser.close()
finally:
await conn.close()
print("\n🏁 Golfpakkeskraping fullført.")
return {
"processed_facilities": len(facilities),
"analyzed_facilities": analyzed_count,
"saved_drafts": saved_count,
"skipped_facilities": skipped_count,
"failed_facilities": failed_count,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Skrap golfpakker via AI.")
parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None)
args = parser.parse_args()
facility_ids_list = None
if args.ids:
facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()]
asyncio.run(run_golfpakker_scraper(facility_ids_list))

View file

@ -1,5 +1,6 @@
from typing import Any from typing import Any
from scrape_golfpakker import run_golfpakker_scraper
from scrape_greenfee import run_greenfee_scraper from scrape_greenfee import run_greenfee_scraper
from scrape_membership import run_scraper as run_membership_scraper from scrape_membership import run_scraper as run_membership_scraper
from scrape_status import run_daily_scraping from scrape_status import run_daily_scraping
@ -19,6 +20,8 @@ async def run_scrape_job(job: dict[str, Any], progress_callback: ProgressCallbac
result = await run_greenfee_scraper(facility_ids, progress_callback=progress_callback) result = await run_greenfee_scraper(facility_ids, progress_callback=progress_callback)
elif job_type == "vtg": elif job_type == "vtg":
result = await run_vtg_scraper(facility_ids, progress_callback=progress_callback) result = await run_vtg_scraper(facility_ids, progress_callback=progress_callback)
elif job_type == "golfpakker":
result = await run_golfpakker_scraper(facility_ids, progress_callback=progress_callback)
else: else:
raise ValueError(f"Ukjent scrape-jobbtype: {job_type}") raise ValueError(f"Ukjent scrape-jobbtype: {job_type}")

View file

@ -2,7 +2,7 @@ import json
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Iterable from typing import Any, Iterable
SCRAPE_JOB_TYPES = ("banestatus", "medlemskap", "greenfee", "vtg") SCRAPE_JOB_TYPES = ("banestatus", "medlemskap", "greenfee", "vtg", "golfpakker")
SCRAPE_JOB_STATUSES = ("pending", "running", "completed", "failed") SCRAPE_JOB_STATUSES = ("pending", "running", "completed", "failed")
DEFAULT_MAX_ATTEMPTS = 3 DEFAULT_MAX_ATTEMPTS = 3
DEFAULT_RECENT_EVENTS_LIMIT = 12 DEFAULT_RECENT_EVENTS_LIMIT = 12
@ -132,7 +132,7 @@ async def ensure_scrape_jobs_table(conn) -> None:
""" """
CREATE TABLE IF NOT EXISTS scrape_jobs ( CREATE TABLE IF NOT EXISTS scrape_jobs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
job_type VARCHAR(50) NOT NULL CHECK (job_type IN ('banestatus', 'medlemskap', 'greenfee', 'vtg')), job_type VARCHAR(50) NOT NULL CHECK (job_type IN ('banestatus', 'medlemskap', 'greenfee', 'vtg', 'golfpakker')),
facility_ids JSONB NOT NULL DEFAULT '[]'::jsonb, facility_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
total_facilities INTEGER NOT NULL DEFAULT 0, total_facilities INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')), status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
@ -175,6 +175,29 @@ async def ensure_scrape_jobs_table(conn) -> None:
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS current_facility_name TEXT") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS current_facility_name TEXT")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ")
await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS last_error_at TIMESTAMPTZ") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS last_error_at TIMESTAMPTZ")
await conn.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'scrape_jobs_job_type_check'
) THEN
ALTER TABLE scrape_jobs DROP CONSTRAINT scrape_jobs_job_type_check;
END IF;
EXCEPTION
WHEN undefined_object THEN NULL;
END $$;
"""
)
await conn.execute(
"""
ALTER TABLE scrape_jobs
ADD CONSTRAINT scrape_jobs_job_type_check
CHECK (job_type IN ('banestatus', 'medlemskap', 'greenfee', 'vtg', 'golfpakker'))
"""
)
await conn.execute( await conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status_created_at CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status_created_at

View file

@ -0,0 +1,243 @@
"use client";
import { useEffect, useState } from 'react';
import Link from 'next/link';
import AdminMobileMenu from "@/components/AdminMobileMenu";
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
type GolfpakkeRow = {
navn: string;
pris: string;
beskrivelse: string;
lenke: string;
};
const EMPTY_ROW: GolfpakkeRow = {
navn: '',
pris: '',
beskrivelse: '',
lenke: '',
};
export default function GolfpakkerWasher() {
const [drafts, setDrafts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const fetchDrafts = () => {
setLoading(true);
adminFetch(`${API_URL}/admin/golfpakker/drafts`)
.then((res) => res.json())
.then((data) => {
const editableDrafts = data.map((facility: any) => {
let parsedDraft = facility.golfpakker_draft;
if (typeof parsedDraft === 'string') {
try {
parsedDraft = JSON.parse(parsedDraft);
} catch (error) {
console.error("Kunne ikke parse golfpakkeutkast", error);
}
}
const suggestedPackages = Array.isArray(parsedDraft?.foreslatt_golfpakker)
? parsedDraft.foreslatt_golfpakker
: (Array.isArray(facility.golfpakker) ? facility.golfpakker : []);
return {
...facility,
golfpakker_draft: parsedDraft,
edit_golfpakker: suggestedPackages.map((row: any) => ({
navn: row?.navn || '',
pris: row?.pris ?? '',
beskrivelse: row?.beskrivelse || '',
lenke: row?.lenke || '',
})),
};
});
setDrafts(editableDrafts);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchDrafts();
}, []);
const toggleSelectAll = (checked: boolean) => {
if (checked) setSelectedIds(drafts.map((draft) => draft.id));
else setSelectedIds([]);
};
const toggleOne = (id: number) => {
if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter((item) => item !== id));
else setSelectedIds([...selectedIds, id]);
};
const updateField = (facilityId: number, rowIndex: number, field: keyof GolfpakkeRow, value: string) => {
setDrafts(drafts.map((draft) => {
if (draft.id !== facilityId) return draft;
const nextRows = [...draft.edit_golfpakker];
nextRows[rowIndex] = { ...nextRows[rowIndex], [field]: value };
return { ...draft, edit_golfpakker: nextRows };
}));
};
const addRow = (facilityId: number) => {
setDrafts(drafts.map((draft) => {
if (draft.id !== facilityId) return draft;
return { ...draft, edit_golfpakker: [...draft.edit_golfpakker, { ...EMPTY_ROW }] };
}));
};
const removeRow = (facilityId: number, rowIndex: number) => {
setDrafts(drafts.map((draft) => {
if (draft.id !== facilityId) return draft;
return { ...draft, edit_golfpakker: draft.edit_golfpakker.filter((_: unknown, index: number) => index !== rowIndex) };
}));
};
const handleApprove = async () => {
const approvals = drafts
.filter((draft) => selectedIds.includes(draft.id))
.map((draft) => ({
facility_id: draft.id,
golfpakker: draft.edit_golfpakker
.map((row: GolfpakkeRow) => ({
navn: row.navn.trim(),
pris: row.pris.trim() ? Number(row.pris) : null,
beskrivelse: row.beskrivelse.trim(),
lenke: row.lenke.trim(),
}))
.filter((row: any) => row.navn || row.pris !== null || row.beskrivelse || row.lenke),
}));
if (approvals.length === 0) {
alert("Velg minst ett anlegg å godkjenne.");
return;
}
setSaving(true);
try {
const res = await adminFetch(`${API_URL}/admin/golfpakker/approve-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals }),
});
if (!res.ok) throw new Error("Kunne ikke lagre golfpakker");
alert(`${approvals.length} anlegg oppdatert!`);
setSelectedIds([]);
fetchDrafts();
} catch {
alert("Noe gikk galt under lagring.");
} finally {
setSaving(false);
}
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">Laster golfpakkeutkast...</div>;
return (
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
<div className="mx-auto max-w-[1400px]">
<AdminMobileMenu />
<div className="mb-10 flex flex-col gap-5 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<Link href="/admin" className="mb-2 block text-sm font-bold text-gray-500 hover:text-[#8bc34a]"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black">Golfpakke-Vaskeriet</h1>
<p className="mt-2 text-sm text-gray-600"> gjennom AI-funnede golfpakker, korriger lenker og beskrivelser, og godkjenn til live.</p>
</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 ? (
<div className="rounded-[2rem] bg-white p-20 text-center shadow-sm">
<span className="mb-4 block text-6xl">🧳</span>
<h2 className="text-2xl font-black text-gray-400">Ingen ventende golfpakkeutkast!</h2>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm">
<input type="checkbox" className="ml-2 h-5 w-5 accent-[#8bc34a]" checked={selectedIds.length === drafts.length && drafts.length > 0} onChange={(e) => toggleSelectAll(e.target.checked)} />
<span className="text-xs font-black uppercase tracking-widest text-gray-500">Velg Alle</span>
</div>
{drafts.map((draft, index) => (
<div key={draft.id} className={`rounded-3xl border-2 p-6 shadow-sm transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/10 ring-2 ring-[#8bc34a]/20' : index % 2 === 0 ? 'border-[#e3edd7] bg-white' : 'border-[#dbe7f5] bg-[#f8fbff]'}`}>
<div className="flex items-start gap-6">
<div className="pt-2">
<input type="checkbox" className="h-6 w-6 cursor-pointer accent-[#8bc34a]" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} />
</div>
<div className="flex-grow space-y-4">
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="flex flex-wrap items-center gap-3 text-2xl font-black">
{draft.name}
<span className="rounded-md bg-gray-100 px-2 py-1 text-xs font-mono font-bold text-gray-400">ID: {draft.id}</span>
</h3>
<a href={(draft.golfpakker_url || draft.website_url)?.split(',')[0]} target="_blank" rel="noopener noreferrer" className="btn btn-md btn-secondary w-full md:w-auto">
Sjekk Nettside
</a>
</div>
{draft.golfpakker_draft?.ai_begrunnelse && (
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 text-sm italic text-blue-900">
<strong>🤖 AI Begrunnelse:</strong> {draft.golfpakker_draft.ai_begrunnelse}
</div>
)}
<div className="grid gap-8 md:grid-cols-2">
<div>
<h4 className="mb-2 text-xs font-black uppercase tracking-widest text-gray-400">Slik ser det ut i databasen :</h4>
<div className="space-y-2 rounded-xl bg-gray-50 p-4 text-xs opacity-80">
{draft.golfpakker && draft.golfpakker.length > 0 ? draft.golfpakker.map((pakke: any, idx: number) => (
<div key={idx} className="border-b pb-2">
<div className="font-bold">{pakke.navn || 'Uten navn'}</div>
<div>{pakke.pris ? `${pakke.pris},-` : 'Uten pris'}</div>
<div className="text-gray-500">{pakke.beskrivelse || 'Ingen beskrivelse'}</div>
</div>
)) : 'Ingen golfpakker registrert.'}
</div>
</div>
<div>
<h4 className="mb-2 text-xs font-black uppercase tracking-widest text-green-600">Nytt forslag å godkjenne:</h4>
<div className="space-y-3">
{draft.edit_golfpakker.length === 0 ? (
<div className="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-500 italic">
AI fant ingen konkrete golfpakker, men du kan legge til manuelt.
</div>
) : (
draft.edit_golfpakker.map((row: GolfpakkeRow, rowIndex: number) => (
<div key={rowIndex} className="space-y-2 rounded-xl border border-gray-200 bg-white p-4">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_140px_auto] sm:items-center">
<input className="rounded border border-gray-100 p-2 text-xs font-bold outline-none focus:border-[#8bc34a]" value={row.navn} onChange={(e) => updateField(draft.id, rowIndex, 'navn', e.target.value)} placeholder="Pakkenavn" />
<input className="rounded border border-gray-100 p-2 text-center text-xs outline-none focus:border-[#8bc34a]" type="number" value={row.pris} onChange={(e) => updateField(draft.id, rowIndex, 'pris', e.target.value)} placeholder="Pris" />
<button onClick={() => removeRow(draft.id, rowIndex)} className="btn btn-sm btn-danger"></button>
</div>
<input className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a]" value={row.lenke} onChange={(e) => updateField(draft.id, rowIndex, 'lenke', e.target.value)} placeholder="Lenke til pakken" />
<textarea className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a]" rows={3} value={row.beskrivelse} onChange={(e) => updateField(draft.id, rowIndex, 'beskrivelse', e.target.value)} placeholder="Kort oppsummering av pakken" />
</div>
))
)}
<button onClick={() => addRow(draft.id)} className="btn btn-sm btn-secondary">
+ Legg til golfpakke
</button>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -9,7 +9,7 @@ import { adminFetch } from "@/config/adminFetch";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect"; import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
import Link from 'next/link'; import Link from 'next/link';
type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'vtg'; type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'golfpakker' | 'vtg';
type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed'; type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed';
@ -64,10 +64,41 @@ type TwoFactorSetupResponse = {
qr_svg: string; qr_svg: string;
}; };
type ManualOverrideTab = 'medlemskap' | 'greenfee' | 'vtg';
type GreenfeeRow = {
banenavn: string;
priskategori: string;
pris_voksne: string;
pris_junior: string;
};
type VtgDateRow = {
dato: string;
status: string;
};
type ManualOverrideForm = {
medlemskap_url: string;
navn_standard_medlemskap: string;
standard_medlemskap: string;
standard_medlemskap_kommentarer: string;
navn_rimeligste_alternativ: string;
rimeligste_alternativ: string;
greenfee_url: string;
guest_requirements: string;
greenfee: GreenfeeRow[];
vtg_lenke: string;
vtg_pris: string;
vtg_beskrivelse: string;
vtg_datoer: VtgDateRow[];
};
const JOB_LABELS: Record<AdminTab, string> = { const JOB_LABELS: Record<AdminTab, string> = {
banestatus: 'Banestatus', banestatus: 'Banestatus',
medlemskap: 'Medlemskap', medlemskap: 'Medlemskap',
greenfee: 'Greenfee', greenfee: 'Greenfee',
golfpakker: 'Golfpakker',
vtg: 'VTG', vtg: 'VTG',
}; };
@ -97,10 +128,96 @@ const JOB_EVENT_TONE_CLASSES: Record<string, string> = {
info: 'bg-slate-50 text-slate-700 border-slate-200', info: 'bg-slate-50 text-slate-700 border-slate-200',
}; };
const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => { const EMPTY_GREENFEE_ROW: GreenfeeRow = {
banenavn: '',
priskategori: '',
pris_voksne: '',
pris_junior: '',
};
const EMPTY_VTG_DATE_ROW: VtgDateRow = {
dato: '',
status: 'Ledig',
};
const EMPTY_MANUAL_OVERRIDE_FORM: ManualOverrideForm = {
medlemskap_url: '',
navn_standard_medlemskap: '',
standard_medlemskap: '',
standard_medlemskap_kommentarer: '',
navn_rimeligste_alternativ: '',
rimeligste_alternativ: '',
greenfee_url: '',
guest_requirements: '',
greenfee: [],
vtg_lenke: '',
vtg_pris: '',
vtg_beskrivelse: '',
vtg_datoer: [],
};
const toInputString = (value: unknown) => {
if (value === null || value === undefined) return '';
return String(value);
};
const normalizeGreenfeeRows = (value: unknown): GreenfeeRow[] => {
if (!Array.isArray(value)) return [];
return value.map((row: any) => ({
banenavn: toInputString(row?.banenavn),
priskategori: toInputString(row?.priskategori),
pris_voksne: toInputString(row?.pris_voksne),
pris_junior: toInputString(row?.pris_junior),
}));
};
const normalizeVtgDates = (value: unknown): VtgDateRow[] => {
if (!Array.isArray(value)) return [];
return value.map((row: any) => ({
dato: toInputString(row?.dato),
status: toInputString(row?.status) || 'Ledig',
}));
};
const toNullableNumber = (value: unknown) => {
const normalized = toInputString(value).trim();
if (!normalized) return null;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
};
type InlineEditProps = {
facilityId: number;
field: string;
initialValue: string;
onSave: (id: number, field: string, val: string) => void;
placeholder?: string;
emptyLabel?: string;
title?: string;
inputRows?: number;
editorWidthClassName?: string;
displayClassName?: string;
};
const InlineEdit = ({
facilityId,
field,
initialValue,
onSave,
placeholder = 'Lim inn verdi...',
emptyLabel = 'Mangler verdi',
title = 'Klikk for å redigere',
inputRows = 2,
editorWidthClassName = 'max-w-[200px]',
displayClassName = 'text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2',
}: InlineEditProps) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(initialValue || ''); const [value, setValue] = useState(initialValue || '');
useEffect(() => {
setValue(initialValue || '');
}, [initialValue]);
const handleSave = () => { const handleSave = () => {
setIsEditing(false); setIsEditing(false);
if (value !== initialValue) { if (value !== initialValue) {
@ -110,8 +227,8 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n
if (isEditing) { if (isEditing) {
return ( return (
<div className="flex flex-col gap-1 w-full max-w-[200px] animate-fade-in"> <div className={`flex w-full flex-col gap-1 animate-fade-in ${editorWidthClassName}`}>
<textarea autoFocus rows={2} className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white" value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} placeholder="Lim inn URL(er)..." /> <textarea autoFocus rows={inputRows} className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white" value={value} onChange={e => setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} placeholder={placeholder} />
<div className="flex gap-1"> <div className="flex gap-1">
<button onClick={handleSave} className="btn btn-sm btn-primary flex-1">Lagre</button> <button onClick={handleSave} className="btn btn-sm btn-primary flex-1">Lagre</button>
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="btn btn-sm btn-secondary">Avbryt</button> <button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="btn btn-sm btn-secondary">Avbryt</button>
@ -121,9 +238,9 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n
} }
return ( return (
<div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title="Klikk for å redigere URL"> <div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title={title}>
<div className="text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2"> <div className={displayClassName}>
{initialValue ? initialValue : <span className="text-red-400 italic">Mangler URL</span>} {initialValue ? initialValue : <span className="text-red-400 italic">{emptyLabel}</span>}
</div> </div>
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity"></span> <span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity"></span>
</div> </div>
@ -151,6 +268,10 @@ export default function AdminDashboard() {
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResponse | null>(null); const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResponse | null>(null);
const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null); const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null);
const [queueFeedback, setQueueFeedback] = useState<QueueFeedback | null>(null); const [queueFeedback, setQueueFeedback] = useState<QueueFeedback | null>(null);
const [manualEditFacility, setManualEditFacility] = useState<any | null>(null);
const [manualEditTab, setManualEditTab] = useState<ManualOverrideTab | null>(null);
const [manualEditForm, setManualEditForm] = useState<ManualOverrideForm>(EMPTY_MANUAL_OVERRIDE_FORM);
const [isManualSaving, setIsManualSaving] = useState(false);
const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({}); const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({});
const fetchFacilities = () => { const fetchFacilities = () => {
@ -346,6 +467,7 @@ export default function AdminDashboard() {
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' : const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' : activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' : activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
activeTab === 'golfpakker' ? '/admin/run-golfpakker-scraper' :
'/admin/run-vtg-scraper'; '/admin/run-vtg-scraper';
try { try {
const response = await adminFetch(`${API_URL}${endpoint}`, { const response = await adminFetch(`${API_URL}${endpoint}`, {
@ -421,6 +543,138 @@ export default function AdminDashboard() {
} finally { setIsSaving(false); } } finally { setIsSaving(false); }
}; };
const closeManualOverrideModal = () => {
setManualEditFacility(null);
setManualEditTab(null);
setManualEditForm(EMPTY_MANUAL_OVERRIDE_FORM);
setIsManualSaving(false);
};
const openManualOverrideModal = (facility: any, tab: ManualOverrideTab) => {
setManualEditFacility(facility);
setManualEditTab(tab);
setManualEditForm({
medlemskap_url: facility.medlemskap_url || '',
navn_standard_medlemskap: facility.navn_standard_medlemskap || '',
standard_medlemskap: toInputString(facility.standard_medlemskap),
standard_medlemskap_kommentarer: facility.standard_medlemskap_kommentarer || '',
navn_rimeligste_alternativ: facility.navn_rimeligste_alternativ || '',
rimeligste_alternativ: toInputString(facility.rimeligste_alternativ),
greenfee_url: facility.greenfee_url || '',
guest_requirements: facility.guest_requirements || '',
greenfee: normalizeGreenfeeRows(facility.greenfee),
vtg_lenke: facility.vtg_lenke || '',
vtg_pris: toInputString(facility.vtg_pris),
vtg_beskrivelse: facility.vtg_beskrivelse || '',
vtg_datoer: normalizeVtgDates(facility.vtg_datoer),
});
};
const updateManualOverrideField = (field: keyof ManualOverrideForm, value: string) => {
setManualEditForm((prev) => ({ ...prev, [field]: value }));
};
const updateManualGreenfeeRow = (index: number, field: keyof GreenfeeRow, value: string) => {
setManualEditForm((prev) => {
const nextRows = [...prev.greenfee];
nextRows[index] = { ...nextRows[index], [field]: value };
return { ...prev, greenfee: nextRows };
});
};
const addManualGreenfeeRow = () => {
setManualEditForm((prev) => ({ ...prev, greenfee: [...prev.greenfee, { ...EMPTY_GREENFEE_ROW }] }));
};
const removeManualGreenfeeRow = (index: number) => {
setManualEditForm((prev) => ({ ...prev, greenfee: prev.greenfee.filter((_, rowIndex) => rowIndex !== index) }));
};
const updateManualVtgDateRow = (index: number, field: keyof VtgDateRow, value: string) => {
setManualEditForm((prev) => {
const nextRows = [...prev.vtg_datoer];
nextRows[index] = { ...nextRows[index], [field]: value };
return { ...prev, vtg_datoer: nextRows };
});
};
const addManualVtgDateRow = () => {
setManualEditForm((prev) => ({ ...prev, vtg_datoer: [...prev.vtg_datoer, { ...EMPTY_VTG_DATE_ROW }] }));
};
const removeManualVtgDateRow = (index: number) => {
setManualEditForm((prev) => ({ ...prev, vtg_datoer: prev.vtg_datoer.filter((_, rowIndex) => rowIndex !== index) }));
};
const handleSaveManualOverride = async () => {
if (!manualEditFacility || !manualEditTab) return;
const now = new Date().toISOString();
let payload: Record<string, unknown> = {};
if (manualEditTab === 'medlemskap') {
payload = {
medlemskap_url: manualEditForm.medlemskap_url.trim(),
navn_standard_medlemskap: manualEditForm.navn_standard_medlemskap.trim(),
standard_medlemskap: toNullableNumber(manualEditForm.standard_medlemskap),
standard_medlemskap_kommentarer: manualEditForm.standard_medlemskap_kommentarer.trim(),
navn_rimeligste_alternativ: manualEditForm.navn_rimeligste_alternativ.trim(),
rimeligste_alternativ: toNullableNumber(manualEditForm.rimeligste_alternativ),
membership_updated_at: now,
membership_draft: null,
};
}
if (manualEditTab === 'greenfee') {
payload = {
greenfee_url: manualEditForm.greenfee_url.trim(),
guest_requirements: manualEditForm.guest_requirements.trim(),
greenfee: manualEditForm.greenfee
.map((row) => ({
banenavn: row.banenavn.trim(),
priskategori: row.priskategori.trim(),
pris_voksne: toNullableNumber(row.pris_voksne),
pris_junior: toNullableNumber(row.pris_junior),
}))
.filter((row) => row.banenavn || row.priskategori || row.pris_voksne !== null || row.pris_junior !== null),
greenfee_updated_at: now,
greenfee_draft: null,
};
}
if (manualEditTab === 'vtg') {
payload = {
vtg_lenke: manualEditForm.vtg_lenke.trim(),
vtg_pris: toNullableNumber(manualEditForm.vtg_pris),
vtg_beskrivelse: manualEditForm.vtg_beskrivelse.trim(),
vtg_datoer: manualEditForm.vtg_datoer
.map((row) => ({
dato: row.dato.trim(),
status: row.status.trim() || 'Ledig',
}))
.filter((row) => row.dato),
vtg_updated_at: now,
vtg_draft: null,
};
}
setIsManualSaving(true);
try {
const response = await adminFetch(`${API_URL}/admin/facilities/${manualEditFacility.id}/full`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("Feil ved lagring");
closeManualOverrideModal();
fetchFacilities();
} catch (error) {
alert("Kunne ikke lagre de manuelle verdiene.");
} finally {
setIsManualSaving(false);
}
};
const openTwoFactorModal = () => { const openTwoFactorModal = () => {
setShowTwoFactorModal(true); setShowTwoFactorModal(true);
setTwoFactorPassword(''); setTwoFactorPassword('');
@ -556,6 +810,254 @@ export default function AdminDashboard() {
</div> </div>
)} )}
{manualEditFacility && manualEditTab && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-3xl bg-white shadow-2xl">
<div className="shrink-0 bg-[#11280f] p-6 text-white">
<h3 className="text-xl font-black uppercase tracking-widest">
Manuell overstyring: {JOB_LABELS[manualEditTab]}
</h3>
<p className="text-sm text-[#7ca982]">{manualEditFacility.name}</p>
</div>
<div className="flex-grow space-y-6 overflow-y-auto p-8">
{manualEditTab === 'medlemskap' && (
<div className="space-y-6">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Kilde-URL</label>
<input
type="text"
value={manualEditForm.medlemskap_url}
onChange={(e) => updateManualOverrideField('medlemskap_url', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="https://..."
/>
</div>
<div className="grid gap-6 md:grid-cols-2">
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Standard medlemskap</p>
<div className="mt-4 space-y-3">
<input
type="text"
value={manualEditForm.navn_standard_medlemskap}
onChange={(e) => updateManualOverrideField('navn_standard_medlemskap', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Navn på standard medlemskap"
/>
<input
type="number"
value={manualEditForm.standard_medlemskap}
onChange={(e) => updateManualOverrideField('standard_medlemskap', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Pris i kroner"
/>
<textarea
rows={3}
value={manualEditForm.standard_medlemskap_kommentarer}
onChange={(e) => updateManualOverrideField('standard_medlemskap_kommentarer', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm outline-none focus:border-[#8bc34a]"
placeholder="Kommentar eller forbehold"
/>
</div>
</section>
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste alternativ</p>
<div className="mt-4 space-y-3">
<input
type="text"
value={manualEditForm.navn_rimeligste_alternativ}
onChange={(e) => updateManualOverrideField('navn_rimeligste_alternativ', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Navn på billigste alternativ"
/>
<input
type="number"
value={manualEditForm.rimeligste_alternativ}
onChange={(e) => updateManualOverrideField('rimeligste_alternativ', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Pris i kroner"
/>
</div>
</section>
</div>
</div>
)}
{manualEditTab === 'greenfee' && (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Kilde-URL</label>
<input
type="text"
value={manualEditForm.greenfee_url}
onChange={(e) => updateManualOverrideField('greenfee_url', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="https://..."
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Krav til gjestespill</label>
<input
type="text"
value={manualEditForm.guest_requirements}
onChange={(e) => updateManualOverrideField('guest_requirements', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="F.eks. klubbhandicap"
/>
</div>
</div>
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Greenfee-priser</p>
<button type="button" onClick={addManualGreenfeeRow} className="btn btn-sm btn-secondary">
+ Legg til prisrad
</button>
</div>
<div className="mt-4 space-y-3">
{manualEditForm.greenfee.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500">
Ingen priser registrert ennå.
</div>
) : (
manualEditForm.greenfee.map((row, index) => (
<div key={index} className="grid gap-3 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_110px_110px_auto] md:items-center">
<input
type="text"
value={row.banenavn}
onChange={(e) => updateManualGreenfeeRow(index, 'banenavn', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="Bane"
/>
<input
type="text"
value={row.priskategori}
onChange={(e) => updateManualGreenfeeRow(index, 'priskategori', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm outline-none focus:border-[#8bc34a]"
placeholder="Kategori"
/>
<input
type="number"
value={row.pris_voksne}
onChange={(e) => updateManualGreenfeeRow(index, 'pris_voksne', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-center text-sm outline-none focus:border-[#8bc34a]"
placeholder="Voksen"
/>
<input
type="number"
value={row.pris_junior}
onChange={(e) => updateManualGreenfeeRow(index, 'pris_junior', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-center text-sm outline-none focus:border-[#8bc34a]"
placeholder="Junior"
/>
<button type="button" onClick={() => removeManualGreenfeeRow(index)} className="btn btn-sm btn-danger">
</button>
</div>
))
)}
</div>
</section>
</div>
)}
{manualEditTab === 'vtg' && (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">VTG-lenke</label>
<input
type="text"
value={manualEditForm.vtg_lenke}
onChange={(e) => updateManualOverrideField('vtg_lenke', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="https://..."
/>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Pris</label>
<input
type="number"
value={manualEditForm.vtg_pris}
onChange={(e) => updateManualOverrideField('vtg_pris', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm font-bold outline-none transition-colors focus:border-[#8bc34a]"
placeholder="Pris i kroner"
/>
</div>
</div>
<div>
<label className="mb-2 block text-xs font-black uppercase tracking-widest text-gray-500">Beskrivelse</label>
<textarea
rows={4}
value={manualEditForm.vtg_beskrivelse}
onChange={(e) => updateManualOverrideField('vtg_beskrivelse', e.target.value)}
className="w-full rounded-xl border-2 border-gray-100 p-3 text-sm outline-none transition-colors focus:border-[#8bc34a]"
placeholder="Beskriv hva som inngår i kurset"
/>
</div>
<section className="rounded-[1.5rem] border border-gray-200 bg-[#f8fbf4] p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Kursdatoer</p>
<button type="button" onClick={addManualVtgDateRow} className="btn btn-sm btn-secondary">
+ Legg til dato
</button>
</div>
<div className="mt-4 space-y-3">
{manualEditForm.vtg_datoer.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500">
Ingen kursdatoer registrert ennå.
</div>
) : (
manualEditForm.vtg_datoer.map((row, index) => (
<div key={index} className="grid gap-3 rounded-2xl border border-gray-200 bg-white p-4 md:grid-cols-[minmax(0,1fr)_180px_auto] md:items-center">
<input
type="text"
value={row.dato}
onChange={(e) => updateManualVtgDateRow(index, 'dato', e.target.value)}
className="w-full rounded-xl border border-gray-200 p-3 text-sm font-bold outline-none focus:border-[#8bc34a]"
placeholder="F.eks. 12.-14. mai"
/>
<select
value={row.status}
onChange={(e) => updateManualVtgDateRow(index, 'status', e.target.value)}
className="w-full rounded-xl border border-gray-200 bg-white p-3 text-sm outline-none focus:border-[#8bc34a]"
>
<option value="Ledig">Ledig</option>
<option value="Fulltegnet">Fulltegnet</option>
<option value="Venteliste">Venteliste</option>
<option value="Få plasser"> plasser</option>
</select>
<button type="button" onClick={() => removeManualVtgDateRow(index)} className="btn btn-sm btn-danger">
</button>
</div>
))
)}
</div>
</section>
</div>
)}
</div>
<div className="flex shrink-0 justify-end gap-4 bg-gray-50 p-6">
<button onClick={closeManualOverrideModal} className="btn btn-md btn-secondary">
Avbryt
</button>
<button onClick={handleSaveManualOverride} disabled={isManualSaving} className="btn btn-md btn-primary disabled:opacity-50">
{isManualSaving ? 'Lagrer...' : 'Lagre manuelt'}
</button>
</div>
</div>
</div>
)}
{showTwoFactorModal && ( {showTwoFactorModal && (
<div <div
className="fixed inset-0 z-50 overflow-y-auto bg-black/60 p-3 md:p-4" className="fixed inset-0 z-50 overflow-y-auto bg-black/60 p-3 md:p-4"
@ -735,6 +1237,9 @@ export default function AdminDashboard() {
<Link href="/admin/greenfee" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> <Link href="/admin/greenfee" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Greenfee Greenfee
</Link> </Link>
<Link href="/admin/golfpakker" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Golfpakker
</Link>
<Link href="/admin/vtg" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"> <Link href="/admin/vtg" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
VTG VTG
</Link> </Link>
@ -792,6 +1297,9 @@ export default function AdminDashboard() {
<Link href="/admin/greenfee" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Greenfee"> <Link href="/admin/greenfee" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Greenfee">
{isSidebarCollapsed ? 'G' : 'Greenfee'} {isSidebarCollapsed ? 'G' : 'Greenfee'}
</Link> </Link>
<Link href="/admin/golfpakker" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Golfpakker">
{isSidebarCollapsed ? 'GP' : 'Golfpakker'}
</Link>
<Link href="/admin/vtg" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Veien til Golf (VTG)"> <Link href="/admin/vtg" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Veien til Golf (VTG)">
{isSidebarCollapsed ? 'V' : 'VTG'} {isSidebarCollapsed ? 'V' : 'VTG'}
</Link> </Link>
@ -1140,6 +1648,7 @@ export default function AdminDashboard() {
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button> <button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap</button> <button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap</button>
<button onClick={() => setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee</button> <button onClick={() => setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee</button>
<button onClick={() => setActiveTab('golfpakker')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'golfpakker' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Golfpakker</button>
<button onClick={() => setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs</button> <button onClick={() => setActiveTab('vtg')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'vtg' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>VTG-Kurs</button>
</div> </div>
@ -1175,12 +1684,49 @@ export default function AdminDashboard() {
</label> </label>
</div> </div>
<div className="mb-6 rounded-[1.75rem] border border-[#dbe7cf] bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Hvordan Skraping Oppfører Seg</p>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Banestatus</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping oppdaterer live banestatus direkte. Hvis skraperen ikke finner en trygg status, beholdes eksisterende status som før.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Medlemskap</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live medlemskapsdata endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Greenfee</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live greenfee endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">Golfpakker</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live golfpakker endres først når du godkjenner i vaskeriet eller redigerer manuelt.
</p>
</article>
<article className="rounded-2xl bg-[#f8fbf4] p-4">
<p className="text-xs font-black uppercase tracking-widest text-[#11280f]">VTG</p>
<p className="mt-2 text-sm leading-6 text-gray-600">
Skraping lager eller erstatter bare et utkast. Live VTG-data endres først når du godkjenner i vaskeriet eller overstyrer manuelt.
</p>
</article>
</div>
</div>
<div className="space-y-5 pb-4"> <div className="space-y-5 pb-4">
{filteredFacilities.map((f: any, index: number) => { {filteredFacilities.map((f: any, index: number) => {
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0; const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0; const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
const hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_draft).length > 0;
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0; const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft); const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'golfpakker' && hasGpDraft) || (activeTab === 'vtg' && hasVtgDraft);
const accentStyles = [ const accentStyles = [
'bg-white border-gray-100', 'bg-white border-gray-100',
'bg-[#fbfdf8] border-[#e3edd7]', 'bg-[#fbfdf8] border-[#e3edd7]',
@ -1223,6 +1769,9 @@ export default function AdminDashboard() {
{activeTab === 'greenfee' && ( {activeTab === 'greenfee' && (
<span>{f.greenfee_updated_at ? `Vasket ${new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span> <span>{f.greenfee_updated_at ? `Vasket ${new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)} )}
{activeTab === 'golfpakker' && (
<span>{f.golfpakker_updated_at ? `Vasket ${new Date(f.golfpakker_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)}
{activeTab === 'vtg' && ( {activeTab === 'vtg' && (
<span>{f.vtg_updated_at ? `Vasket ${new Date(f.vtg_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span> <span>{f.vtg_updated_at ? `Vasket ${new Date(f.vtg_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)} )}
@ -1236,16 +1785,36 @@ export default function AdminDashboard() {
Innstillinger Innstillinger
</button> </button>
)} )}
{activeTab === 'medlemskap' && (
<button onClick={() => openManualOverrideModal(f, 'medlemskap')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'medlemskap' && hasMemDraft && ( {activeTab === 'medlemskap' && hasMemDraft && (
<Link href="/admin/medlemskap" className="btn btn-md btn-danger text-center"> <Link href="/admin/medlemskap" className="btn btn-md btn-danger text-center">
Til vaskeri Til vaskeri
</Link> </Link>
)} )}
{activeTab === 'greenfee' && (
<button onClick={() => openManualOverrideModal(f, 'greenfee')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'greenfee' && hasGfDraft && ( {activeTab === 'greenfee' && hasGfDraft && (
<Link href="/admin/greenfee" className="btn btn-md btn-danger text-center"> <Link href="/admin/greenfee" className="btn btn-md btn-danger text-center">
Til vaskeri Til vaskeri
</Link> </Link>
)} )}
{activeTab === 'golfpakker' && hasGpDraft && (
<Link href="/admin/golfpakker" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
{activeTab === 'vtg' && (
<button onClick={() => openManualOverrideModal(f, 'vtg')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'vtg' && hasVtgDraft && ( {activeTab === 'vtg' && hasVtgDraft && (
<Link href="/admin/vtg" className="btn btn-md btn-danger text-center"> <Link href="/admin/vtg" className="btn btn-md btn-danger text-center">
Til vaskeri Til vaskeri
@ -1258,38 +1827,58 @@ export default function AdminDashboard() {
</div> </div>
{activeTab === 'banestatus' && ( {activeTab === 'banestatus' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]"> <div className="space-y-4">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm"> <div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde og metode</p> <section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"> <p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde og metode</p>
<div className="space-y-2"> <div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} /> <div className="space-y-2">
<p className="break-all text-[10px] font-mono text-gray-400">{f.scrape_status_selector || 'Ingen selector lagret'}</p> <InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} placeholder="Lim inn URL(er)..." emptyLabel="Mangler URL" title="Klikk for å redigere URL" />
<p className="break-all text-[10px] font-mono text-gray-400">{f.scrape_status_selector || 'Ingen selector lagret'}</p>
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Metode</p>
<ScrapeMethodSelect facility={f} />
</div>
</div> </div>
<div className="space-y-2"> </section>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Metode</p>
<ScrapeMethodSelect facility={f} /> <section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Banestatus</p>
<div className="mt-4 space-y-2">
{f.course_statuses && f.course_statuses.length > 0 ? f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
return (
<div key={idx} className="flex items-center justify-between gap-3 rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="truncate text-xs font-black uppercase tracking-[0.18em] text-gray-500">{cs.name}</span>
<span className={`rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
</div>
);
}) : (
<p className="text-sm text-gray-500">Ingen baner registrert.</p>
)}
</div> </div>
</div> </section>
</section> </div>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm"> <section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Banestatus</p> <p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Viktig beskjed</p>
<div className="mt-4 space-y-2"> <div className="mt-3 rounded-2xl bg-[#f8fbf4] p-3">
{f.course_statuses && f.course_statuses.length > 0 ? f.course_statuses.map((cs: any, idx: number) => { <InlineEdit
let badgeColor = "bg-gray-100 text-gray-500"; facilityId={f.id}
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700"; field="footnote"
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700"; initialValue={f.footnote || ''}
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700"; onSave={handleQuickEdit}
return ( placeholder="Skriv viktig beskjed / kursiv intro-tekst..."
<div key={idx} className="flex items-center justify-between gap-3 rounded-2xl bg-[#f8fbf4] px-4 py-3"> emptyLabel="Ingen viktig beskjed"
<span className="truncate text-xs font-black uppercase tracking-[0.18em] text-gray-500">{cs.name}</span> title="Klikk for å redigere viktig beskjed"
<span className={`rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${badgeColor}`}>{cs.status || 'UKJENT'}</span> inputRows={4}
</div> editorWidthClassName="max-w-full"
); displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap"
}) : ( />
<p className="text-sm text-gray-500">Ingen baner registrert.</p>
)}
</div> </div>
</section> </section>
</div> </div>
@ -1396,6 +1985,46 @@ export default function AdminDashboard() {
</section> </section>
</div> </div>
)} )}
{activeTab === 'golfpakker' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)_220px]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
<div className="mt-4">
<InlineEdit facilityId={f.id} field="golfpakker_url" initialValue={f.golfpakker_url || ''} onSave={handleQuickEdit} placeholder="Lim inn golfpakke-URL..." emptyLabel={f.website_url ? 'Bruker website_url som fallback' : 'Mangler golfpakke-URL'} title="Klikk for å redigere golfpakke-URL" />
{f.website_url && (
<p className="mt-2 text-[10px] font-mono text-gray-400 break-all">
Fallback: {f.website_url}
</p>
)}
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Registrerte golfpakker</p>
<div className="mt-4 space-y-2">
{f.golfpakker && f.golfpakker.length > 0 ? f.golfpakker.map((pakke: any, i: number) => (
<div key={i} className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<p className="text-xs font-black text-[#11280f]">{pakke.navn || 'Uten navn'}</p>
<p className="mt-1 text-[10px] text-gray-500">{pakke.beskrivelse || 'Ingen beskrivelse registrert.'}</p>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#7ca982]">
{pakke.pris ? `${pakke.pris},-` : 'Pris ikke registrert'}
</p>
</div>
)) : (
<p className="text-sm text-gray-500">Ingen golfpakker registrert.</p>
)}
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
<div className="mt-4 space-y-3">
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasGpDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasGpDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
</span>
</div>
</section>
</div>
)}
</div> </div>
</article> </article>
); );
@ -1439,6 +2068,14 @@ export default function AdminDashboard() {
<th className="pb-4">Sist Vasket</th> <th className="pb-4">Sist Vasket</th>
</> </>
)} )}
{activeTab === 'golfpakker' && (
<>
<th className="pb-4">Nettside</th>
<th className="pb-4">Golfpakker</th>
<th className="pb-4 text-center">Nytt Utkast?</th>
<th className="pb-4">Sist Vasket</th>
</>
)}
{activeTab === 'vtg' && ( {activeTab === 'vtg' && (
<> <>
<th className="pb-4">VTG-side (Klikk for å redigere)</th> <th className="pb-4">VTG-side (Klikk for å redigere)</th>
@ -1455,8 +2092,9 @@ export default function AdminDashboard() {
{filteredFacilities.map((f: any) => { {filteredFacilities.map((f: any) => {
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0; const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0; const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
const hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_draft).length > 0;
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0; const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft); const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'golfpakker' && hasGpDraft) || (activeTab === 'vtg' && hasVtgDraft);
return ( return (
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50'}`}> <tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : 'hover:bg-gray-50/50'}`}>
@ -1470,13 +2108,25 @@ export default function AdminDashboard() {
{activeTab === 'banestatus' && ( {activeTab === 'banestatus' && (
<> <>
<td className="py-6 pr-4"> <td className="py-6 pr-4">
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} /> <InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} placeholder="Lim inn URL(er)..." emptyLabel="Mangler URL" title="Klikk for å redigere URL" />
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div> <div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div>
</td> </td>
<td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td> <td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td>
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td> <td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
<td className="py-6 pr-4"> <td className="py-6 pr-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<InlineEdit
facilityId={f.id}
field="footnote"
initialValue={f.footnote || ''}
onSave={handleQuickEdit}
placeholder="Skriv viktig beskjed / kursiv intro-tekst..."
emptyLabel="Ingen viktig beskjed"
title="Klikk for å redigere viktig beskjed"
inputRows={4}
editorWidthClassName="max-w-[320px]"
displayClassName="mb-2 text-[11px] italic leading-5 text-[#11280f] whitespace-pre-wrap"
/>
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => { {f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500"; let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700"; if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
@ -1542,12 +2192,37 @@ export default function AdminDashboard() {
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td> <td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.vtg_updated_at ? new Date(f.vtg_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
</> </>
)} )}
{activeTab === 'golfpakker' && (
<>
<td className="py-6 pr-4">
<InlineEdit facilityId={f.id} field="golfpakker_url" initialValue={f.golfpakker_url || ''} onSave={handleQuickEdit} placeholder="Lim inn golfpakke-URL..." emptyLabel={f.website_url ? 'Bruker website_url som fallback' : 'Mangler golfpakke-URL'} title="Klikk for å redigere golfpakke-URL" />
{f.website_url && <div className="mt-1 text-[9px] font-mono text-gray-300 truncate max-w-[180px]" title={f.website_url}>{f.website_url}</div>}
</td>
<td className="py-6 pr-4">
<div className="flex flex-col gap-1 text-[10px] text-gray-500 max-h-16 overflow-y-auto pr-2">
{f.golfpakker && f.golfpakker.length > 0 ? f.golfpakker.map((pakke: any, i: number) => (
<div key={i} className="border-b border-gray-50 pb-1">
<span className="font-bold text-[#11280f]">{pakke.navn || 'Uten navn'}</span>
<span className="ml-2">{pakke.pris ? `${pakke.pris},-` : 'Uten pris'}</span>
</div>
)) : 'Ingen golfpakker'}
</div>
</td>
<td className="py-6 pr-4 text-center">{hasGpDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja, vask!</span> : <span className="text-gray-300">-</span>}</td>
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">{f.golfpakker_updated_at ? new Date(f.golfpakker_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}</td>
</>
)}
<td className={`py-6 text-right pr-4 sticky right-0 z-10 min-w-[150px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}> <td className={`py-6 text-right pr-4 sticky right-0 z-10 min-w-[150px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
<div className="flex flex-col gap-2 items-end"> <div className="flex flex-col gap-2 items-end">
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="btn btn-sm btn-secondary whitespace-nowrap">Innstillinger</button>} {activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="btn btn-sm btn-secondary whitespace-nowrap">Innstillinger</button>}
{activeTab === 'medlemskap' && <button onClick={() => openManualOverrideModal(f, 'medlemskap')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>} {activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'greenfee' && <button onClick={() => openManualOverrideModal(f, 'greenfee')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>} {activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'golfpakker' && hasGpDraft && <Link href="/admin/golfpakker" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
{activeTab === 'vtg' && <button onClick={() => openManualOverrideModal(f, 'vtg')} className="btn btn-sm btn-secondary whitespace-nowrap">Overstyr manuelt</button>}
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>} {activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="btn btn-sm btn-danger whitespace-nowrap"> til Vaskeri</Link>}
<Link href={`/admin/rediger/${f.slug}`} className="btn btn-sm btn-ink whitespace-nowrap text-center">Rediger alt</Link> <Link href={`/admin/rediger/${f.slug}`} className="btn btn-sm btn-ink whitespace-nowrap text-center">Rediger alt</Link>

View file

@ -441,7 +441,18 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6"> <div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
<div> <div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link> <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 text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1> <h1 className="text-4xl font-black text-[#11280f]">
Rediger:{" "}
<Link
href={`/golfbaner/${initialData.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-[#8bc34a]"
title="Åpne anleggssiden i ny fane"
>
{initialData.name}
</Link>
</h1>
</div> </div>
<button <button
onClick={handleSave} onClick={handleSave}
@ -524,15 +535,6 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('length_meters', 'number')} onChange={e => handleChange('length_meters', Number(e.target.value))} /> <input type="number" className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('length_meters', 'number')} onChange={e => handleChange('length_meters', Number(e.target.value))} />
</div> </div>
<MultiSelect
label="Samarbeidende Klubber (Gjestespill etc.)"
options={allFacilities.filter(f => f.id !== initialData.id)}
selected={coopClubs}
onChange={(val) => {
setCoopClubs(val);
handleChange('cooperating_clubs', val);
}}
/>
</div> </div>
)} )}
@ -598,6 +600,15 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til Greenfee-side</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('greenfee_url', 'text')} onChange={e => handleChange('greenfee_url', e.target.value)} /></div> <div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til Greenfee-side</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('greenfee_url', 'text')} onChange={e => handleChange('greenfee_url', e.target.value)} /></div>
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Krav til Gjestespill (f.eks Klubbhandicap)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('guest_requirements', 'text')} onChange={e => handleChange('guest_requirements', e.target.value)} /></div> <div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Krav til Gjestespill (f.eks Klubbhandicap)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('guest_requirements', 'text')} onChange={e => handleChange('guest_requirements', e.target.value)} /></div>
</div> </div>
<MultiSelect
label="Samarbeidende Klubber (Gjestespill etc.)"
options={allFacilities.filter(f => f.id !== initialData.id)}
selected={coopClubs}
onChange={(val) => {
setCoopClubs(val);
handleChange('cooperating_clubs', val);
}}
/>
<ListObjectEditor <ListObjectEditor
label="Greenfee Priser (Legg til rader for Voksen/Junior etc)" label="Greenfee Priser (Legg til rader for Voksen/Junior etc)"
value={formData.greenfee} value={formData.greenfee}
@ -626,12 +637,16 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} /> <KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
<KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} /> <KeyValueEditor label="Norsk Seniorgolf (NSG)" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} /> <KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Lenke til Golfpakker-side</label>
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('golfpakker_url', 'text')} onChange={e => handleChange('golfpakker_url', e.target.value)} placeholder="Tomt felt bruker ordinær nettside som fallback" />
</div>
{/* HER ER GOLFPAKKENE SOM JEG MISTET I FORRIGE RUNDE */} {/* HER ER GOLFPAKKENE SOM JEG MISTET I FORRIGE RUNDE */}
<ListObjectEditor <ListObjectEditor
label="Golfpakker" label="Golfpakker"
value={formData.golfpakker} value={formData.golfpakker}
templateKeys={['navn', 'pris', 'beskrivelse']} templateKeys={['navn', 'pris', 'beskrivelse', 'lenke']}
onChange={(v) => handleChange('golfpakker', v)} onChange={(v) => handleChange('golfpakker', v)}
/> />
</div> </div>

View file

@ -173,6 +173,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
// Pris og kurs-arrays // Pris og kurs-arrays
const greenfeeRaw = parseJson(facility.greenfee, []); const greenfeeRaw = parseJson(facility.greenfee, []);
const golfpakkerRaw = parseJson(facility.golfpakker, []);
const vtgDatoer = parseJson(facility.vtg_datoer, []); const vtgDatoer = parseJson(facility.vtg_datoer, []);
const golfamoreData = parseJson(facility.golfamore_data, {}); const golfamoreData = parseJson(facility.golfamore_data, {});
@ -432,6 +433,34 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</div> </div>
</div> </div>
)} )}
{golfpakkerRaw.length > 0 && (
<div className="pt-4">
<span className="text-gray-400 block mb-2">Golfpakker:</span>
<div className="space-y-3">
{golfpakkerRaw.map((pakke: any, index: number) => (
<div key={index} className="rounded-2xl border border-gray-100 bg-white p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="font-black text-[#11280f]">{pakke.navn || 'Golfpakke'}</p>
{pakke.beskrivelse && <p className="mt-1 text-sm text-gray-500">{pakke.beskrivelse}</p>}
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
<span className="text-xs font-black uppercase tracking-widest text-[#7ca982]">
{pakke.pris ? `${pakke.pris},-` : 'Pris på forespørsel'}
</span>
{pakke.lenke && (
<a href={pakke.lenke} target="_blank" rel="noopener noreferrer" className="text-xs font-black uppercase tracking-widest text-blue-600 hover:underline">
Les mer
</a>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
</section> </section>

View file

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