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]
class GolfpakkerApproval(BaseModel):
facility_id: int
golfpakker: List[dict]
class VtgApproval(BaseModel):
facility_id: int
vtg_pris: int | None
@ -312,6 +317,10 @@ class BulkVtgRequest(BaseModel):
approvals: List[VtgApproval]
class BulkGolfpakkerRequest(BaseModel):
approvals: List[GolfpakkerApproval]
class AdminPasswordConfirm(BaseModel):
password: str
@ -358,7 +367,8 @@ def format_row(row):
for key in [
'status_updated_at', 'created_at', 'slope_valid_until',
'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at'
'membership_updated_at', 'greenfee_updated_at', 'vtg_updated_at', 'footnote_updated_at',
'golfpakker_updated_at'
]:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
@ -369,7 +379,7 @@ def format_row(row):
]
json_dict_fields = [
'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:
@ -397,10 +407,80 @@ def format_row(row):
d[field] = {}
elif not isinstance(val, dict):
d[field] = {}
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:
image = qrcode.make(
provisioning_uri,
@ -782,7 +862,10 @@ async def ensure_facility_columns(conn):
"""Legger til nye facility-kolonner ved behov."""
await conn.execute("""
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',
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector', 'vtg_lenke',
'footnote_updated_at'
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
'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}
@ -1876,7 +1960,8 @@ async def update_facility_full(facility_id: int, request: Request):
'greenfee_updated_at',
'vtg_updated_at',
'status_updated_at',
'footnote_updated_at'
'footnote_updated_at',
'golfpakker_updated_at'
]
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")
async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
# Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
# Sikkerhet: Tillat KUN disse URL-/tekstfeltene for hurtigredigering
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:
raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
async with app.state.pool.acquire() as conn:
# 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)
if request.field == 'footnote':
normalized_value = str(request.value or '').strip() or None
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"}
# --- GREENFEE "VASKERI" ENDEPUNKTER ---
@ -2108,13 +2206,30 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
async with app.state.pool.acquire() as conn:
async with conn.transaction():
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("""
UPDATE facilities
SET greenfee = $1::jsonb,
cooperating_clubs = CASE
WHEN $2::jsonb = '[]'::jsonb THEN cooperating_clubs
ELSE $2::jsonb
END,
greenfee_updated_at = NOW(),
greenfee_draft = NULL
WHERE id = $2
""", json.dumps(approval.greenfee), approval.facility_id)
WHERE id = $3
""", json.dumps(approval.greenfee), json.dumps(cooperating_club_slugs), approval.facility_id)
return {"status": "success"}
@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}")
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__":
import uvicorn
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 scrape_golfpakker import run_golfpakker_scraper
from scrape_greenfee import run_greenfee_scraper
from scrape_membership import run_scraper as run_membership_scraper
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)
elif job_type == "vtg":
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:
raise ValueError(f"Ukjent scrape-jobbtype: {job_type}")

View file

@ -2,7 +2,7 @@ import json
from datetime import date, datetime
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")
DEFAULT_MAX_ATTEMPTS = 3
DEFAULT_RECENT_EVENTS_LIMIT = 12
@ -132,7 +132,7 @@ async def ensure_scrape_jobs_table(conn) -> None:
"""
CREATE TABLE IF NOT EXISTS scrape_jobs (
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,
total_facilities INTEGER NOT NULL DEFAULT 0,
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 next_retry_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(
"""
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 Link from 'next/link';
type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'vtg';
type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'golfpakker' | 'vtg';
type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed';
@ -64,10 +64,41 @@ type TwoFactorSetupResponse = {
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> = {
banestatus: 'Banestatus',
medlemskap: 'Medlemskap',
greenfee: 'Greenfee',
golfpakker: 'Golfpakker',
vtg: 'VTG',
};
@ -97,10 +128,96 @@ const JOB_EVENT_TONE_CLASSES: Record<string, string> = {
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 [value, setValue] = useState(initialValue || '');
useEffect(() => {
setValue(initialValue || '');
}, [initialValue]);
const handleSave = () => {
setIsEditing(false);
if (value !== initialValue) {
@ -110,8 +227,8 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n
if (isEditing) {
return (
<div className="flex flex-col gap-1 w-full max-w-[200px] animate-fade-in">
<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)..." />
<div className={`flex w-full flex-col gap-1 animate-fade-in ${editorWidthClassName}`}>
<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">
<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>
@ -121,9 +238,9 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n
}
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="text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2">
{initialValue ? initialValue : <span className="text-red-400 italic">Mangler URL</span>}
<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={displayClassName}>
{initialValue ? initialValue : <span className="text-red-400 italic">{emptyLabel}</span>}
</div>
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity"></span>
</div>
@ -151,6 +268,10 @@ export default function AdminDashboard() {
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResponse | null>(null);
const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | 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 fetchFacilities = () => {
@ -346,6 +467,7 @@ export default function AdminDashboard() {
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' :
activeTab === 'medlemskap' ? '/admin/run-membership-scraper' :
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
activeTab === 'golfpakker' ? '/admin/run-golfpakker-scraper' :
'/admin/run-vtg-scraper';
try {
const response = await adminFetch(`${API_URL}${endpoint}`, {
@ -421,6 +543,138 @@ export default function AdminDashboard() {
} 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 = () => {
setShowTwoFactorModal(true);
setTwoFactorPassword('');
@ -556,6 +810,254 @@ export default function AdminDashboard() {
</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 && (
<div
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">
Greenfee
</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">
VTG
</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">
{isSidebarCollapsed ? 'G' : 'Greenfee'}
</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)">
{isSidebarCollapsed ? 'V' : 'VTG'}
</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('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('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>
</div>
@ -1175,12 +1684,49 @@ export default function AdminDashboard() {
</label>
</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">
{filteredFacilities.map((f: any, index: number) => {
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 hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_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 = [
'bg-white border-gray-100',
'bg-[#fbfdf8] border-[#e3edd7]',
@ -1223,6 +1769,9 @@ export default function AdminDashboard() {
{activeTab === 'greenfee' && (
<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' && (
<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
</button>
)}
{activeTab === 'medlemskap' && (
<button onClick={() => openManualOverrideModal(f, 'medlemskap')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'medlemskap' && hasMemDraft && (
<Link href="/admin/medlemskap" className="btn btn-md btn-danger text-center">
Til vaskeri
</Link>
)}
{activeTab === 'greenfee' && (
<button onClick={() => openManualOverrideModal(f, 'greenfee')} className="btn btn-md btn-secondary">
Overstyr manuelt
</button>
)}
{activeTab === 'greenfee' && hasGfDraft && (
<Link href="/admin/greenfee" className="btn btn-md btn-danger text-center">
Til vaskeri
</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 && (
<Link href="/admin/vtg" className="btn btn-md btn-danger text-center">
Til vaskeri
@ -1258,38 +1827,58 @@ export default function AdminDashboard() {
</div>
{activeTab === 'banestatus' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<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 og metode</p>
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
<p className="break-all text-[10px] font-mono text-gray-400">{f.scrape_status_selector || 'Ingen selector lagret'}</p>
<div className="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<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 og metode</p>
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<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 className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Metode</p>
<ScrapeMethodSelect facility={f} />
</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">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>
</section>
</section>
</div>
<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>
)}
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Viktig beskjed</p>
<div className="mt-3 rounded-2xl bg-[#f8fbf4] p-3">
<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-full"
displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap"
/>
</div>
</section>
</div>
@ -1396,6 +1985,46 @@ export default function AdminDashboard() {
</section>
</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>
</article>
);
@ -1439,6 +2068,14 @@ export default function AdminDashboard() {
<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' && (
<>
<th className="pb-4">VTG-side (Klikk for å redigere)</th>
@ -1455,8 +2092,9 @@ export default function AdminDashboard() {
{filteredFacilities.map((f: any) => {
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 hasGpDraft = f.golfpakker_draft && Object.keys(f.golfpakker_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 (
<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' && (
<>
<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>
</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 pr-4">
<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) => {
let badgeColor = "bg-gray-100 text-gray-500";
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>
</>
)}
{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'}`}>
<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 === '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 === '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 === '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>}
<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>
<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>
<button
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))} />
</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>
)}
@ -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">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>
<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
label="Greenfee Priser (Legg til rader for Voksen/Junior etc)"
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="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)} />
<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 */}
<ListObjectEditor
label="Golfpakker"
value={formData.golfpakker}
templateKeys={['navn', 'pris', 'beskrivelse']}
templateKeys={['navn', 'pris', 'beskrivelse', 'lenke']}
onChange={(v) => handleChange('golfpakker', v)}
/>
</div>

View file

@ -173,6 +173,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
// Pris og kurs-arrays
const greenfeeRaw = parseJson(facility.greenfee, []);
const golfpakkerRaw = parseJson(facility.golfpakker, []);
const vtgDatoer = parseJson(facility.vtg_datoer, []);
const golfamoreData = parseJson(facility.golfamore_data, {});
@ -432,6 +433,34 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</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>
</section>

View file

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