Før scraper med golfpakker
This commit is contained in:
parent
05ded6513e
commit
d166a7ce5d
9 changed files with 1574 additions and 65 deletions
180
backend/main.py
180
backend/main.py
|
|
@ -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)
|
||||||
|
|
|
||||||
368
backend/scrape_golfpakker.py
Normal file
368
backend/scrape_golfpakker.py
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
"""
|
||||||
|
TEE OFF - GOLFPAKKE-SKRAPER MED GEMINI AI
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
Starter på 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 på 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 på 1-3 setninger om hva pakken går ut på
|
||||||
|
4. lenke til siden der pakken presenteres
|
||||||
|
- Hvis flere pakker beskrives på 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))
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
243
frontend/src/app/admin/golfpakker/page.tsx
Normal file
243
frontend/src/app/admin/golfpakker/page.tsx
Normal 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">Gå 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 nå:</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">Få 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">Gå til Vaskeri</Link>}
|
{activeTab === 'medlemskap' && hasMemDraft && <Link href="/admin/medlemskap" className="btn btn-sm btn-danger whitespace-nowrap">Gå 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">Gå til Vaskeri</Link>}
|
{activeTab === 'greenfee' && hasGfDraft && <Link href="/admin/greenfee" className="btn btn-sm btn-danger whitespace-nowrap">Gå til Vaskeri</Link>}
|
||||||
|
{activeTab === 'golfpakker' && hasGpDraft && <Link href="/admin/golfpakker" className="btn btn-sm btn-danger whitespace-nowrap">Gå 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">Gå til Vaskeri</Link>}
|
{activeTab === 'vtg' && hasVtgDraft && <Link href="/admin/vtg" className="btn btn-sm btn-danger whitespace-nowrap">Gå 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue