Før Greenfee skraping
This commit is contained in:
parent
aca30c9adc
commit
16d6e207af
8 changed files with 574 additions and 356 deletions
Binary file not shown.
|
|
@ -62,6 +62,10 @@ class MembershipDraftApproval(BaseModel):
|
|||
class BulkApprovalRequest(BaseModel):
|
||||
approvals: List[MembershipDraftApproval]
|
||||
|
||||
class QuickEditRequest(BaseModel):
|
||||
field: str
|
||||
value: str
|
||||
|
||||
# --- FUNKSJONER ---
|
||||
def format_row(row):
|
||||
"""
|
||||
|
|
@ -137,6 +141,17 @@ def run_scrape_worker(facility_ids: List[int]):
|
|||
except Exception as e:
|
||||
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
|
||||
|
||||
def run_membership_worker(facility_ids: List[int]):
|
||||
"""Kjører medlemskap-skraperen i bakgrunnen."""
|
||||
print(f"🔄 STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}")
|
||||
try:
|
||||
ids_arg = ",".join(map(str, facility_ids))
|
||||
command = f"python -u scrape_membership.py --ids {ids_arg}"
|
||||
subprocess.run(command, shell=True, check=True)
|
||||
print(f"✅ MEDLEMSKAP-SKRAPING FULLFØRT FOR IDER: {facility_ids}")
|
||||
except Exception as e:
|
||||
print(f"🔥 FEIL UNDER MEDLEMSKAP-SKRAPING: {e}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -420,6 +435,17 @@ async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: Back
|
|||
|
||||
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
||||
|
||||
@app.post("/api/admin/run-membership-scraper")
|
||||
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
|
||||
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
|
||||
if not request.facility_ids:
|
||||
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
|
||||
|
||||
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
|
||||
background_tasks.add_task(run_membership_worker, request.facility_ids)
|
||||
|
||||
return {"status": "queued", "message": f"Medlemskapsskraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Enkel sjekk for å se at API og DB lever."""
|
||||
|
|
@ -473,6 +499,20 @@ async def approve_membership_bulk(request: BulkApprovalRequest):
|
|||
approval.facility_id)
|
||||
return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"}
|
||||
|
||||
@app.patch("/api/admin/facilities/{facility_id}/quick-edit")
|
||||
async def quick_edit_facility(facility_id: int, request: QuickEditRequest):
|
||||
"""Lyn-redigering av enkle URL-felter fra admin-dashbordet."""
|
||||
# Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering
|
||||
allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector']
|
||||
if request.field not in allowed_fields:
|
||||
raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.")
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
# F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen
|
||||
await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2",
|
||||
request.value, facility_id)
|
||||
return {"status": "success"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
|
@ -12,3 +12,4 @@ python-jose[cryptography]
|
|||
passlib[bcrypt]
|
||||
pyotp
|
||||
google-genai
|
||||
google-generativeai
|
||||
173
backend/scrape_greenfee.py
Normal file
173
backend/scrape_greenfee.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""
|
||||
TEE OFF - GREENFEE-SKRAPER MED GEMINI AI
|
||||
---------------------------------------------------------------------------
|
||||
Henter alle greenfee-varianter fra en (eller flere) URL-er og strukturerer
|
||||
dem i en JSON-liste. Finner også avtaleklubber/vennskapsklubber.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from bs4 import BeautifulSoup
|
||||
from playwright.async_api import async_playwright
|
||||
import google.generativeai as genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
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')
|
||||
|
||||
async def fetch_page_text(url: str, browser) -> str:
|
||||
url = url.strip()
|
||||
if not url.startswith("http"):
|
||||
return ""
|
||||
|
||||
print(f" 🌐 Laster inn: {url}")
|
||||
try:
|
||||
page = await browser.new_page()
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
||||
html_content = await page.content()
|
||||
await page.close()
|
||||
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
||||
script.extract()
|
||||
|
||||
return soup.get_text(separator=' ', strip=True)
|
||||
except Exception as e:
|
||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
||||
return ""
|
||||
|
||||
def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict:
|
||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for greenfee-analyse...")
|
||||
|
||||
prompt = f"""
|
||||
Du er en ekspert på norske golfklubber og prissetting.
|
||||
Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og hente ut TO ting:
|
||||
1. ALLE varianter av greenfee-priser.
|
||||
2. Navn på eventuelle vennskapsklubber/avtaleklubber (hvis nevnt).
|
||||
|
||||
REGLER FOR GREENFEE:
|
||||
- Trekk ut absolutt alle priskategorier du finner (f.eks. "Hverdag høysesong", "Helg før kl 14", "Gjest av medlem", "9 hull kveld", osv.).
|
||||
- Finn både voksenpris og juniorpris for hver kategori.
|
||||
- HVIS juniorpris er oppgitt som en regel (f.eks. "Juniorer betaler halv pris" eller "50% rabatt for junior"), MÅ du selv regne ut prisen og skrive inn heltallet.
|
||||
- "banenavn": Bruk navnet på banen hvis det er spesifisert (f.eks. "18-hullsbanen", "Korthullsbanen"). Hvis ikke spesifisert, bruk "{club_name}".
|
||||
- Priser SKAL være tall (integer). Sett pris til null (null) hvis den ikke finnes.
|
||||
|
||||
REGLER FOR AVTALEKLUBBER:
|
||||
- Let etter overskrifter som "Vennskapsklubber", "Avtaleklubber", "Gjestespill", "Samarbeidsklubber".
|
||||
- Trekk ut kun navnene på klubbene i en liste (f.eks. ["Haga GK", "Oslo GK"]). La listen være tom hvis du ikke finner noen.
|
||||
|
||||
TEKST FRA NETTSIDEN:
|
||||
{text}
|
||||
|
||||
OPPGAVE:
|
||||
Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur:
|
||||
{{
|
||||
"foreslatt_greenfee": [
|
||||
{{
|
||||
"banenavn": "Navn på banen",
|
||||
"priskategori": "F.eks: Hverdag Gjest av Medlem",
|
||||
"pris_voksne": 600,
|
||||
"pris_junior": 300
|
||||
}}
|
||||
],
|
||||
"foreslatt_avtaleklubber": [
|
||||
"Klubb 1 GK",
|
||||
"Klubb 2 GK"
|
||||
],
|
||||
"ai_begrunnelse": "Kort forklaring, f.eks: 'Fant et komplekst prissystem for høy/lavsesong. Regnet ut juniorpriser til 50% som angitt i teksten. Fant 3 samarbeidsklubber nederst.'"
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
raw_response = response.text.strip()
|
||||
|
||||
if raw_response.startswith("```json"):
|
||||
raw_response = raw_response[7:]
|
||||
if raw_response.endswith("```"):
|
||||
raw_response = raw_response[:-3]
|
||||
|
||||
return json.loads(raw_response.strip())
|
||||
except Exception as e:
|
||||
print(f" ❌ AI-analyse feilet: {e}")
|
||||
return None
|
||||
|
||||
async def run_greenfee_scraper(facility_ids=None):
|
||||
print("🚀 Starter Greenfee-skraperen...")
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
|
||||
try:
|
||||
query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_url != ''"
|
||||
if facility_ids:
|
||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
||||
|
||||
facilities = await conn.fetch(query)
|
||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
|
||||
for facility in facilities:
|
||||
fac_id = facility['id']
|
||||
name = facility['name']
|
||||
urls_raw = facility['greenfee_url']
|
||||
|
||||
print(f"\n▶️ Behandler Greenfee for: {name} (ID: {fac_id})")
|
||||
|
||||
urls = [u.strip() for u in urls_raw.split(',')]
|
||||
combined_text = ""
|
||||
|
||||
for idx, url in enumerate(urls, 1):
|
||||
page_text = await fetch_page_text(url, browser)
|
||||
if page_text:
|
||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
||||
|
||||
if len(combined_text) < 50:
|
||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
||||
continue
|
||||
|
||||
draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name)
|
||||
|
||||
if not draft_data:
|
||||
continue
|
||||
|
||||
funnet_priser = len(draft_data.get('foreslatt_greenfee', []))
|
||||
funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', []))
|
||||
print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.")
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE facilities
|
||||
SET greenfee_draft = $1::jsonb
|
||||
WHERE id = $2
|
||||
""", json.dumps(draft_data), fac_id)
|
||||
|
||||
print(" 💾 Greenfee-utkast lagret i databasen!")
|
||||
|
||||
await browser.close()
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
print("\n🏁 Skraping fullført.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Skrap greenfeepriser via AI.")
|
||||
parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)")
|
||||
args = parser.parse_args()
|
||||
|
||||
ids_to_scrape = None
|
||||
if args.ids:
|
||||
ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")]
|
||||
|
||||
asyncio.run(run_greenfee_scraper(ids_to_scrape))
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
"""
|
||||
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI
|
||||
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI (MULTI-URL VERSJON)
|
||||
---------------------------------------------------------------------------
|
||||
Går til oppgitte medlemskaps-URLer, henter ut tekst, og bruker Gemini til å
|
||||
finne 'Standard' og 'Rimeligste' medlemskap basert på TeeOffs definisjoner.
|
||||
Lagrer resultatet som et utkast i databasen (membership_draft).
|
||||
Går til oppgitte medlemskaps-URLer (støtter flere URLer adskilt med komma),
|
||||
henter ut tekst, og bruker Gemini til å summere og finne 'Standard' og
|
||||
'Rimeligste' medlemskap.
|
||||
---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
|
|
@ -17,7 +17,6 @@ from playwright.async_api import async_playwright
|
|||
import google.generativeai as genai
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Last inn miljøvariabler
|
||||
load_dotenv()
|
||||
|
||||
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
||||
|
|
@ -26,72 +25,70 @@ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|||
if not GEMINI_API_KEY:
|
||||
raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!")
|
||||
|
||||
# Konfigurer Gemini
|
||||
genai.configure(api_key=GEMINI_API_KEY)
|
||||
model = genai.GenerativeModel('gemini-2.5-flash') # Eller gemini-1.5-pro avhengig av hva du har tilgang til
|
||||
model = genai.GenerativeModel('gemini-2.5-flash')
|
||||
|
||||
async def fetch_page_text(url: str, browser) -> str:
|
||||
"""Bruker Playwright for å hente all synlig tekst fra EN nettside."""
|
||||
url = url.strip()
|
||||
if not url.startswith("http"):
|
||||
return ""
|
||||
|
||||
async def fetch_page_text(url: str) -> str:
|
||||
"""Bruker Playwright for å hente all synlig tekst fra nettsiden."""
|
||||
print(f" 🌐 Laster inn: {url}")
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
# Setter timeout til 15 sekunder
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
|
||||
|
||||
# Hent hele HTML-innholdet
|
||||
html_content = await page.content()
|
||||
await browser.close()
|
||||
await page.close()
|
||||
|
||||
# Bruk BeautifulSoup til å renske ut bare den synlige teksten
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
# Fjern script og style tags
|
||||
for script in soup(["script", "style", "nav", "footer", "header"]):
|
||||
script.extract()
|
||||
|
||||
text = soup.get_text(separator=' ', strip=True)
|
||||
|
||||
# Begrens teksten slik at vi ikke sprenger token-grensen til AI (f.eks max 15000 tegn)
|
||||
return text[:15000]
|
||||
return text
|
||||
except Exception as e:
|
||||
print(f" ❌ Feil ved lasting av side: {e}")
|
||||
print(f" ❌ Feil ved lasting av {url}: {e}")
|
||||
return ""
|
||||
|
||||
def analyze_with_gemini(text: str, club_name: str) -> dict:
|
||||
"""Sender teksten til Gemini for å trekke ut priser."""
|
||||
"""Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere priser."""
|
||||
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
|
||||
|
||||
prompt = f"""
|
||||
Du er en ekspert på norske golfklubber og medlemskap.
|
||||
Din oppgave er å lese teksten hentet fra nettsiden til "{club_name}" og trekke ut to spesifikke medlemskapspriser.
|
||||
Du er en ekspert på norske golfklubber. Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og finne to spesifikke priser.
|
||||
|
||||
VIKTIG REGEL OM NORSK GOLF:
|
||||
Mange steder er "Klubbkontingent/Medlemskap" og "Spillerett/Årskort" to forskjellige ting.
|
||||
For å få spille ubegrenset (Fritt spill) MÅ man betale BEGGE DELER. Hvis du ser at prisene for kontingent og spillerett er oppgitt hver for seg, SKAL DU SUMMERE disse to summene og bruke totalen som "Standard pris".
|
||||
|
||||
ALDERSPREMISS FOR BEGGE PRISER:
|
||||
Vi forutsetter at personen som skal ha medlemskap er en VOKSEN GOLFER PÅ MINST 35 ÅR. Du må ALDRI velge priser som gjelder for barn, junior, ung voksen (f.eks. 20-29 år), student eller senior/pensjonist.
|
||||
|
||||
DEFINISJONER DU MÅ FØLGE STRENGT:
|
||||
1. "Standard medlemskap": Hva vil det koste for en gjennomsnittsgolfer (voksen over 25/30 år, ikke student/senior) å spille SÅ RYE VEDKOMMENDE ØNSKER (Fritt spill) på denne banen i år?
|
||||
2. "Rimeligste alternativ": Det absolutt billigste medlemskapet som gir medlemskap i klubben (golfkortet), forutsatt at man aksepterer å måtte betale greenfee for hver runde man spiller. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem el.l.)
|
||||
1. "Standard medlemskap": Hva er TOTALPRISEN (inkludert evt. spillerett/årskort) for en voksen person (35+ år) for å spille SÅ MYE VEDKOMMENDE ØNSKER (Fritt spill) i år?
|
||||
2. "Rimeligste alternativ": Det absolutt billigste alternativet FOR EN VOKSEN PERSON (35+ år) som gir medlemskap i klubben (golfkortet), forutsatt at man betaler greenfee for hver runde. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem, eller kun "Klubbkontingent for voksne" uten spillerett).
|
||||
|
||||
TEKST FRA NETTSIDEN:
|
||||
TEKST FRA NETTSIDEN(E):
|
||||
{text}
|
||||
|
||||
OPPGAVE:
|
||||
Returner KUN et gyldig JSON-objekt med følgende struktur (og ingenting annet, ingen markdown):
|
||||
Returner KUN et gyldig JSON-objekt med følgende struktur:
|
||||
{{
|
||||
"foreslatt_standard_navn": "Navnet på medlemskapet (eks: Hovedmedlem Voksen)",
|
||||
"foreslatt_standard_navn": "Navn (eks: Hovedmedlem Voksen inkl. spillerett)",
|
||||
"foreslatt_standard_pris": 1234,
|
||||
"foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)",
|
||||
"foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)",
|
||||
"foreslatt_standard_kommentar": "Kort kommentar (eks: Måtte summere kontingent på 900 og årskort på 5000)",
|
||||
"foreslatt_rimeligste_navn": "Navn (eks: Greenfeemedlemskap Voksen)",
|
||||
"foreslatt_rimeligste_pris": 500,
|
||||
"ai_begrunnelse": "Kort forklaring på hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'."
|
||||
"ai_begrunnelse": "Kort forklaring på utregningen din."
|
||||
}}
|
||||
|
||||
Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Prisen SKAL være et tall (integer), ikke en tekststreng (bruk 6500, ikke "6 500").
|
||||
Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke finner det.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = model.generate_content(prompt)
|
||||
raw_response = response.text.strip()
|
||||
|
||||
# Rensker vekk eventuell markdown-formatering som ```json
|
||||
if raw_response.startswith("```json"):
|
||||
raw_response = raw_response[7:]
|
||||
if raw_response.endswith("```"):
|
||||
|
|
@ -103,13 +100,10 @@ Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Pr
|
|||
return None
|
||||
|
||||
async def run_scraper(facility_ids=None):
|
||||
"""Hovedfunksjon som henter fra DB, skraper, og lagrer utkast."""
|
||||
print("🚀 Starter Medlemskaps-skraperen...")
|
||||
|
||||
print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...")
|
||||
conn = await asyncpg.connect(DB_URL)
|
||||
|
||||
try:
|
||||
# Hent anlegg som har en url for medlemskap
|
||||
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
|
||||
if facility_ids:
|
||||
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
||||
|
|
@ -117,26 +111,35 @@ async def run_scraper(facility_ids=None):
|
|||
facilities = await conn.fetch(query)
|
||||
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
|
||||
for facility in facilities:
|
||||
fac_id = facility['id']
|
||||
name = facility['name']
|
||||
url = facility['medlemskap_url']
|
||||
urls_raw = facility['medlemskap_url']
|
||||
|
||||
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
|
||||
|
||||
# 1. Hent tekst
|
||||
page_text = await fetch_page_text(url)
|
||||
if not page_text or len(page_text) < 50:
|
||||
print(" ⚠️ Fant for lite tekst på siden, hopper over.")
|
||||
# Sjekker om det er flere URL-er adskilt med komma
|
||||
urls = [u.strip() for u in urls_raw.split(',')]
|
||||
combined_text = ""
|
||||
|
||||
for idx, url in enumerate(urls, 1):
|
||||
page_text = await fetch_page_text(url, browser)
|
||||
if page_text:
|
||||
combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}"
|
||||
|
||||
if len(combined_text) < 50:
|
||||
print(" ⚠️ Fant for lite tekst, hopper over.")
|
||||
continue
|
||||
|
||||
# 2. Analyser med Gemini
|
||||
draft_data = analyze_with_gemini(page_text, name)
|
||||
# Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks)
|
||||
draft_data = analyze_with_gemini(combined_text[:25000], name)
|
||||
|
||||
if not draft_data:
|
||||
continue
|
||||
|
||||
# 3. Lagre i databasen som utkast
|
||||
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
|
||||
|
||||
await conn.execute("""
|
||||
|
|
@ -147,6 +150,8 @@ async def run_scraper(facility_ids=None):
|
|||
|
||||
print(" 💾 Utkast lagret i databasen!")
|
||||
|
||||
await browser.close()
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
print("\n🏁 Skraping fullført.")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
/**
|
||||
* TEE OFF ADMIN DASHBOARD v1.9 - RESPONSIVT MED AI-HVISKER, KILL SWITCH, FILTER & FULL EDIT
|
||||
* TEE OFF ADMIN DASHBOARD v2.1 - MISSION CONTROL + INLINE EDIT
|
||||
* ---------------------------------------------------------------------------
|
||||
* PLASSERING: frontend/src/app/admin/page.tsx
|
||||
* FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering.
|
||||
* FUNKSJON: "Mission Control" med faner for Banestatus og Medlemskap,
|
||||
* og klikk-for-å-redigere URL-er direkte i tabellen!
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
|
|
@ -12,6 +12,49 @@ import { API_URL } from "@/config/constants";
|
|||
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
||||
import Link from 'next/link';
|
||||
|
||||
// KOMPONENT FOR HURTIGREDIGERING AV URL-ER
|
||||
const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(initialValue || '');
|
||||
|
||||
const handleSave = () => {
|
||||
setIsEditing(false);
|
||||
if (value !== initialValue) {
|
||||
onSave(facilityId, field, value);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full max-w-[200px] animate-fade-in">
|
||||
<textarea
|
||||
autoFocus
|
||||
rows={2}
|
||||
className="border-2 border-[#8bc34a] p-2 text-[10px] w-full rounded-lg outline-none resize-y shadow-sm font-mono text-black bg-white"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }}
|
||||
placeholder="Lim inn URL(er)..."
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={handleSave} className="bg-[#8bc34a] text-white px-3 py-1.5 rounded-md text-[10px] font-black uppercase flex-1 shadow-sm hover:bg-[#7ca982]">Lagre</button>
|
||||
<button onClick={() => { setIsEditing(false); setValue(initialValue || ''); }} className="bg-gray-200 text-gray-600 px-3 py-1.5 rounded-md text-[10px] font-black uppercase hover:bg-gray-300">Avbryt</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-2 cursor-pointer p-1.5 -ml-1.5 rounded-lg hover:bg-white border border-transparent hover:border-gray-200 hover:shadow-sm transition-all" onClick={() => setIsEditing(true)} title="Klikk for å redigere URL">
|
||||
<div className="text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2">
|
||||
{initialValue ? initialValue : <span className="text-red-400 italic">Mangler URL</span>}
|
||||
</div>
|
||||
<span className="opacity-0 group-hover:opacity-100 text-[10px] bg-gray-100 p-1 rounded transition-opacity">✏️</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [facilities, setFacilities] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -19,18 +62,9 @@ export default function AdminDashboard() {
|
|||
const [isScraping, setIsScraping] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [editingFacility, setEditingFacility] = useState<any | null>(null);
|
||||
|
||||
// NYTT: State for å holde styr på hvilket filter som er aktivt
|
||||
const [activeTab, setActiveTab] = useState<'banestatus' | 'medlemskap'>('banestatus');
|
||||
const [statusFilter, setStatusFilter] = useState('alle');
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
scrape_status_url: '',
|
||||
scrape_status_selector: '',
|
||||
scrape_method: '',
|
||||
ai_instruction: '',
|
||||
courses: [] as any[]
|
||||
});
|
||||
|
||||
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchFacilities = () => {
|
||||
|
|
@ -50,85 +84,75 @@ export default function AdminDashboard() {
|
|||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isScraping) {
|
||||
interval = setInterval(() => {
|
||||
fetchFacilities();
|
||||
}, 10000);
|
||||
interval = setInterval(() => fetchFacilities(), 10000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isScraping]);
|
||||
|
||||
// Filtreringslogikken som kjører automatisk når facilities eller filteret endres
|
||||
useEffect(() => {
|
||||
setSelectedFacilities([]);
|
||||
}, [activeTab]);
|
||||
|
||||
const filteredFacilities = useMemo(() => {
|
||||
if (statusFilter === 'alle') return facilities;
|
||||
|
||||
return facilities.map(facility => {
|
||||
if (!facility.course_statuses) return facility;
|
||||
|
||||
// Filtrer banene innad i hvert anlegg
|
||||
const filteredCourses = facility.course_statuses.filter((cs: any) => {
|
||||
const s = cs.status || 'ukjent';
|
||||
|
||||
if (statusFilter === 'aapne') {
|
||||
return s === 'aapen';
|
||||
}
|
||||
if (statusFilter === 'ikke_stengt') {
|
||||
return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
|
||||
}
|
||||
if (statusFilter === 'stengt') {
|
||||
return s === 'stengt' || s === 'nedlagt';
|
||||
}
|
||||
if (statusFilter === 'ukjent_feil') {
|
||||
return s === 'ukjent' || s === 'NOT_FOUND';
|
||||
}
|
||||
if (statusFilter === 'aapne') return s === 'aapen';
|
||||
if (statusFilter === 'ikke_stengt') return ['aapen', 'aapen_med_vintergreener', 'aapner_snart'].includes(s);
|
||||
if (statusFilter === 'stengt') return s === 'stengt' || s === 'nedlagt';
|
||||
if (statusFilter === 'ukjent_feil') return s === 'ukjent' || s === 'NOT_FOUND';
|
||||
return true;
|
||||
});
|
||||
|
||||
// Returner anlegget kun med de banene som matcher
|
||||
return { ...facility, course_statuses: filteredCourses };
|
||||
|
||||
}).filter(facility => facility.course_statuses && facility.course_statuses.length > 0);
|
||||
|
||||
}, [facilities, statusFilter]);
|
||||
|
||||
// "Velg alle" gjelder kun de anleggene som er synlige i filteret
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedFacilities(filteredFacilities.map(f => f.id));
|
||||
} else {
|
||||
setSelectedFacilities([]);
|
||||
}
|
||||
if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id));
|
||||
else setSelectedFacilities([]);
|
||||
};
|
||||
|
||||
const handleSelectOne = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFacilities([...selectedFacilities, id]);
|
||||
} else {
|
||||
setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
||||
if (checked) setSelectedFacilities([...selectedFacilities, id]);
|
||||
else setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
|
||||
};
|
||||
|
||||
// Funksjon for å lagre inline redigering av URLer
|
||||
const handleQuickEdit = async (id: number, field: string, value: string) => {
|
||||
// "Optimistisk oppdatering" - Vi bytter teksten i skjermen med én gang så det føles lynraskt!
|
||||
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ field, value })
|
||||
});
|
||||
if (!res.ok) throw new Error("Feil ved lagring");
|
||||
} catch (e) {
|
||||
alert("Kunne ikke lagre endringen i databasen.");
|
||||
fetchFacilities(); // Henter gamle data tilbake hvis det kræsjet
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScrapers = async () => {
|
||||
if (isScraping) {
|
||||
setIsScraping(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isScraping) { setIsScraping(false); return; }
|
||||
setIsScraping(true);
|
||||
const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' : '/admin/run-membership-scraper';
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/run-scraper`, {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ facility_ids: selectedFacilities })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Kunne ikke starte skraping");
|
||||
|
||||
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
|
||||
setSelectedFacilities([]);
|
||||
setTimeout(() => setIsScraping(false), timeoutMs);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Feil ved start av skraperen.");
|
||||
alert(`Feil ved start av ${activeTab}-skraperen.`);
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -152,14 +176,11 @@ export default function AdminDashboard() {
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editForm)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Feil ved lagring");
|
||||
|
||||
setEditingFacility(null);
|
||||
fetchFacilities();
|
||||
} catch (error) {
|
||||
alert("Kunne ikke lagre endringene.");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
|
@ -170,7 +191,7 @@ export default function AdminDashboard() {
|
|||
return (
|
||||
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
|
||||
|
||||
{/* REDIGER-MODAL FOR SKRAPING */}
|
||||
{/* REDIGER-MODAL FOR BANESTATUS-SKRAPING */}
|
||||
{editingFacility && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
|
||||
|
|
@ -181,23 +202,13 @@ export default function AdminDashboard() {
|
|||
|
||||
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.scrape_status_url}
|
||||
onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})}
|
||||
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
||||
placeholder="f.eks. https://golfklubb.no/banestatus"
|
||||
/>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL (Banestatus)</label>
|
||||
<input type="text" value={editForm.scrape_status_url} onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="f.eks. https://golfklubb.no/banestatus" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
|
||||
<select
|
||||
value={editForm.scrape_method}
|
||||
onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})}
|
||||
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
||||
>
|
||||
<select value={editForm.scrape_method} onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors">
|
||||
<option value="css_selector">Standard (CSS)</option>
|
||||
<option value="llm_parse">✨ Gemini AI (LLM)</option>
|
||||
<option value="iframe_golfbox">Golfbox iframe</option>
|
||||
|
|
@ -209,14 +220,7 @@ export default function AdminDashboard() {
|
|||
{editForm.scrape_method === 'llm_parse' && (
|
||||
<div className="animate-fade-in">
|
||||
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2">✨ AI-Hviskeren (Instruks til Gemini)</label>
|
||||
<textarea
|
||||
value={editForm.ai_instruction || ''}
|
||||
onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})}
|
||||
className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
|
||||
placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 mt-1">Hjelper Gemini hvis nettsiden er forvirrende.</p>
|
||||
<textarea value={editForm.ai_instruction || ''} onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})} className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors" placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen." rows={3} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -227,15 +231,7 @@ export default function AdminDashboard() {
|
|||
{editForm.courses.map((course: any, idx: number) => (
|
||||
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
|
||||
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
|
||||
<select
|
||||
value={course.status || 'ukjent'}
|
||||
onChange={(e) => {
|
||||
const newCourses = [...editForm.courses];
|
||||
newCourses[idx].status = e.target.value;
|
||||
setEditForm({...editForm, courses: newCourses});
|
||||
}}
|
||||
className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0"
|
||||
>
|
||||
<select value={course.status || 'ukjent'} onChange={(e) => { const newCourses = [...editForm.courses]; newCourses[idx].status = e.target.value; setEditForm({...editForm, courses: newCourses}); }} className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0">
|
||||
<option value="aapen">🟢 Åpen</option>
|
||||
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
|
||||
<option value="aapner_snart">🟡 Åpner Snart</option>
|
||||
|
|
@ -254,16 +250,9 @@ export default function AdminDashboard() {
|
|||
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.scrape_status_selector}
|
||||
onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})}
|
||||
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono"
|
||||
placeholder="f.eks. .status-text"
|
||||
/>
|
||||
<input type="text" value={editForm.scrape_status_selector} onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})} className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono" placeholder="f.eks. .status-text" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
|
||||
|
|
@ -280,23 +269,16 @@ export default function AdminDashboard() {
|
|||
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
|
||||
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
|
||||
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">
|
||||
☰
|
||||
</button>
|
||||
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">☰</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
|
||||
{/* Lenke til Hoveddashbord / Scraping Monitor */}
|
||||
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Scraping Monitor">
|
||||
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
|
||||
<Link href="/admin" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-[#8bc34a] text-white'}`} title="Mission Control">
|
||||
{isSidebarCollapsed ? 'MC' : 'Mission Control'}
|
||||
</Link>
|
||||
|
||||
{/* Lenke til Medlemskaps-Vaskeriet */}
|
||||
<Link href="/admin/medlemskap" 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="Medlemskap">
|
||||
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
|
||||
<Link href="/admin/medlemskap" 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="Medlemskaps-vaskeriet">
|
||||
{isSidebarCollapsed ? 'V' : 'Vaskeriet'}
|
||||
</Link>
|
||||
|
||||
{/* Bildegalleri (Placeholder inntil vi bygger denne) */}
|
||||
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
|
||||
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
|
||||
</div>
|
||||
|
|
@ -311,17 +293,16 @@ export default function AdminDashboard() {
|
|||
|
||||
{/* HOVEDINNHOLD */}
|
||||
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
|
||||
|
||||
<div className="md:hidden flex justify-between items-center mb-6 bg-[#11280f] text-white p-4 rounded-2xl">
|
||||
<h1 className="text-xl font-black uppercase tracking-tighter">TeeOff Admin</h1>
|
||||
<span className="text-xs font-bold text-[#8bc34a]">MONITOR</span>
|
||||
<span className="text-xs font-bold text-[#8bc34a]">CONTROL</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
|
||||
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-6">
|
||||
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {filteredFacilities.length} anlegg (av {facilities.length})</p>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Mission Control</h2>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {filteredFacilities.length} anlegg</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -333,76 +314,82 @@ export default function AdminDashboard() {
|
|||
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør valgte skrapere (${selectedFacilities.length})`}
|
||||
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8">
|
||||
<div className="flex gap-2 mb-8 border-b-2 border-gray-100 pb-0">
|
||||
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>Banestatus</button>
|
||||
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white' : 'bg-gray-50 text-gray-400 hover:bg-gray-100'}`}>Medlemskap</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'banestatus' && (
|
||||
<div className="flex flex-wrap items-center gap-4 bg-gray-50 p-4 rounded-2xl border border-gray-100 mb-8 animate-fade-in">
|
||||
<label htmlFor="statusFilter" className="text-xs font-bold text-gray-500 uppercase tracking-widest">Filtrer på status:</label>
|
||||
<select
|
||||
id="statusFilter"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer"
|
||||
>
|
||||
<select id="statusFilter" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="border-2 border-gray-200 rounded-xl p-2 text-sm font-bold text-[#11280f] focus:border-[#8bc34a] focus:outline-none transition-colors cursor-pointer">
|
||||
<option value="alle">Vis alle anlegg</option>
|
||||
<option value="aapne">🟢 Kun åpne baner</option>
|
||||
<option value="ikke_stengt">🟡 Ikke stengt (Åpne/Vintergreen/Snart)</option>
|
||||
<option value="stengt">🔴 Kun stengte baner</option>
|
||||
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil (Krever tilsyn)</option>
|
||||
<option value="ukjent_feil">⚪ Ukjent / Skrapefeil</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<table className="w-full text-left border-collapse min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
|
||||
<th className="pb-4 pl-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
|
||||
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0} onChange={handleSelectAll} />
|
||||
</th>
|
||||
<th className="pb-4 w-12 text-center">ID</th>
|
||||
<th className="pb-4 pr-6">Anlegg</th>
|
||||
<th className="pb-4">Konfigurasjon</th>
|
||||
|
||||
{activeTab === 'banestatus' ? (
|
||||
<>
|
||||
<th className="pb-4">Konfigurasjon (URL & Selektor)</th>
|
||||
<th className="pb-4">Metode</th>
|
||||
<th className="pb-4">Siste Sjekk</th>
|
||||
<th className="pb-4">Banestatus</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th className="pb-4">Prisside (Klikk for å redigere URL)</th>
|
||||
<th className="pb-4">Nåværende Priser</th>
|
||||
<th className="pb-4 text-center">Utkast i Vaskeri</th>
|
||||
<th className="pb-4">Sist Vasket</th>
|
||||
</>
|
||||
)}
|
||||
<th className="pb-4 text-right pr-4">Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="text-sm font-bold text-[#11280f]">
|
||||
{filteredFacilities.map((f: any) => (
|
||||
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
|
||||
{filteredFacilities.map((f: any) => {
|
||||
const hasDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
|
||||
|
||||
return (
|
||||
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${hasDraft && activeTab === 'medlemskap' ? 'bg-[#8bc34a]/5' : 'hover:bg-gray-50/50'}`}>
|
||||
<td className="py-6 pl-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
|
||||
checked={selectedFacilities.includes(f.id)}
|
||||
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" className="w-4 h-4 cursor-pointer accent-[#8bc34a]" checked={selectedFacilities.includes(f.id)} onChange={(e) => handleSelectOne(f.id, e.target.checked)} />
|
||||
</td>
|
||||
|
||||
<td className="py-6 text-center text-xs font-mono text-gray-400">
|
||||
#{f.id}
|
||||
</td>
|
||||
<td className="py-6 text-center text-xs font-mono text-gray-400">#{f.id}</td>
|
||||
|
||||
<td className="py-6 pr-6">
|
||||
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
|
||||
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
||||
</td>
|
||||
|
||||
{activeTab === 'banestatus' ? (
|
||||
<>
|
||||
<td className="py-6 pr-4">
|
||||
<div className="text-[10px] text-blue-600 truncate max-w-[150px] mb-1">
|
||||
{f.scrape_status_url ? f.scrape_status_url : <span className="text-red-400 italic">Mangler URL</span>}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px]">{f.scrape_status_selector}</div>
|
||||
</td>
|
||||
<td className="py-6 pr-4">
|
||||
<ScrapeMethodSelect facility={f} />
|
||||
{/* HER BRUKER VI INLINE EDITOREN */}
|
||||
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
|
||||
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px] mt-1" title={f.scrape_status_selector}>{f.scrape_status_selector}</div>
|
||||
</td>
|
||||
<td className="py-6 pr-4"><ScrapeMethodSelect facility={f} /></td>
|
||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
|
||||
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
|
||||
</td>
|
||||
|
|
@ -413,40 +400,52 @@ export default function AdminDashboard() {
|
|||
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 gap-2">
|
||||
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>
|
||||
{cs.name}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>
|
||||
{cs.status || 'UKJENT'}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>{cs.name}</span>
|
||||
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-6 pr-4">
|
||||
{/* HER BRUKER VI INLINE EDITOREN OGSÅ! */}
|
||||
<InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} />
|
||||
</td>
|
||||
<td className="py-6 pr-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Standard: <strong>{f.standard_medlemskap ? `${f.standard_medlemskap},-` : '---'}</strong></span>
|
||||
<span className="text-xs text-gray-500">Rimeligste: <strong>{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : '---'}</strong></span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-6 pr-4 text-center">
|
||||
{hasDraft ? <span className="px-3 py-1 bg-yellow-100 text-yellow-700 text-xs font-black uppercase tracking-widest rounded-xl animate-pulse">Ja</span> : <span className="text-gray-300">-</span>}
|
||||
</td>
|
||||
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
|
||||
{f.membership_updated_at ? new Date(f.membership_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
<td className="py-6 text-right pr-4">
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<button
|
||||
onClick={() => openEditModal(f)}
|
||||
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap"
|
||||
>
|
||||
Skraper
|
||||
</button>
|
||||
<Link
|
||||
href={`/admin/rediger/${f.slug}`}
|
||||
className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center"
|
||||
>
|
||||
Rediger alt
|
||||
</Link>
|
||||
{activeTab === 'banestatus' ? (
|
||||
<button onClick={() => openEditModal(f)} className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap">Ekspert-skraper</button>
|
||||
) : (
|
||||
hasDraft ? (
|
||||
<Link href="/admin/medlemskap" className="bg-yellow-100 text-yellow-800 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-yellow-200 transition-all whitespace-nowrap">Gå til Vaskeriet</Link>
|
||||
) : null
|
||||
)}
|
||||
<Link href={`/admin/rediger/${f.slug}`} className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center">Rediger alt</Link>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -352,33 +352,13 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
|||
{ id: 'baner', label: 'Baner & Scorekort' }
|
||||
];
|
||||
|
||||
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => {
|
||||
// Håndter dato-formatet (YYYY-MM-DD) slik at HTML5 date picker forstår det
|
||||
let displayValue = formData[field] || "";
|
||||
if (type === 'date' && displayValue) {
|
||||
displayValue = displayValue.split('T')[0]; // Kutter vekk klokkeslettet
|
||||
// Hjelpefunksjon for å hente ut verdi (spesielt formatert for dato)
|
||||
const getValue = (field: string, type: string) => {
|
||||
let val = formData[field] || "";
|
||||
if (type === 'date' && val) {
|
||||
val = val.split('T')[0];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">{label}</label>
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none transition-all"
|
||||
rows={4}
|
||||
value={displayValue}
|
||||
onChange={e => handleChange(field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
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 transition-all"
|
||||
value={displayValue}
|
||||
onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return val;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -415,24 +395,42 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
|||
<div className="w-full md:w-3/4">
|
||||
{activeTab === 'generelt' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="col-span-1 md:col-span-2"><Input field="name" label="Anleggsnavn" /></div>
|
||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</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('name', 'text')} onChange={e => handleChange('name', e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* NYTT: Viktig beskjed / Kursiv intro */}
|
||||
<div className="col-span-1 md:col-span-2"><Input field="footnote" label="Viktig beskjed (Kursiv intro-tekst over beskrivelsen)" type="textarea" /></div>
|
||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Viktig beskjed (Kursiv intro-tekst)</label>
|
||||
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('footnote', 'textarea')} onChange={e => handleChange('footnote', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 md:col-span-2"><Input field="description" label="Hovedbeskrivelse" type="textarea" /></div>
|
||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Hovedbeskrivelse</label>
|
||||
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('description', 'textarea')} onChange={e => handleChange('description', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Input field="banetype" label="Banetype (f.eks Park/Skog)" />
|
||||
<Input field="season" label="Sesong (f.eks April-Oktober)" />
|
||||
<Input field="established_year" label="Byggeår" type="number" />
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banetype (f.eks Park/Skog)</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('banetype', 'text')} onChange={e => handleChange('banetype', 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">Sesong (f.eks April-Oktober)</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('season', 'text')} onChange={e => handleChange('season', 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">Byggeår</label>
|
||||
<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('established_year', 'number')} onChange={e => handleChange('established_year', Number(e.target.value))} />
|
||||
</div>
|
||||
|
||||
{/* NYTT: Arkitekt med forslag */}
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Arkitekt</label>
|
||||
<input
|
||||
list="architect-list"
|
||||
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 transition-all"
|
||||
value={formData.architect || ""}
|
||||
value={getValue('architect', 'text')}
|
||||
onChange={e => handleChange('architect', e.target.value)}
|
||||
placeholder="Velg eller skriv inn ny..."
|
||||
/>
|
||||
|
|
@ -442,9 +440,11 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
|||
</datalist>
|
||||
</div>
|
||||
|
||||
<Input field="length_meters" label="Totallengde (meter)" type="number" />
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Totallengde (meter)</label>
|
||||
<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>
|
||||
|
||||
{/* NYTT: Samarbeidende klubber */}
|
||||
<MultiSelect
|
||||
label="Samarbeidende Klubber (Gjestespill etc.)"
|
||||
options={allFacilities.filter(f => f.id !== initialData.id)}
|
||||
|
|
@ -459,29 +459,31 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
|||
|
||||
{activeTab === 'lokasjon' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
<div className="col-span-1 md:col-span-2"><Input field="address" label="Gateadresse" /></div>
|
||||
<Input field="zipcode" label="Postnummer" />
|
||||
<Input field="city" label="Poststed / By" />
|
||||
<Input field="county" label="Fylke" />
|
||||
<Input field="phone" label="Telefon" />
|
||||
<Input field="email" label="E-post" />
|
||||
<Input field="lat" label="Breddegrad (Latitude)" type="number" />
|
||||
<Input field="lng" label="Lengdegrad (Longitude)" type="number" />
|
||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Gateadresse</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('address', 'text')} onChange={e => handleChange('address', 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">Postnummer</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('zipcode', 'text')} onChange={e => handleChange('zipcode', 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">Poststed / By</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('city', 'text')} onChange={e => handleChange('city', 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">Fylke</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('county', 'text')} onChange={e => handleChange('county', 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">Telefon</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('phone', 'text')} onChange={e => handleChange('phone', 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">E-post</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('email', 'text')} onChange={e => handleChange('email', 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">Breddegrad (Latitude)</label><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('lat', 'number')} onChange={e => handleChange('lat', Number(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">Lengdegrad (Longitude)</label><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('lng', 'number')} onChange={e => handleChange('lng', Number(e.target.value))} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'linker' && (
|
||||
<div className="flex flex-col">
|
||||
<Input field="website_url" label="Nettside URL" />
|
||||
<Input field="golfbox_booking_url" label="Golfbox Booking URL" />
|
||||
<Input field="golfbox_tournament_url" label="Golfbox Turnering URL" />
|
||||
<Input field="baneguide_url" label="Baneguide URL" />
|
||||
<Input field="flyfoto_url" label="Flyfoto URL" />
|
||||
<Input field="weather_url" label="Vær URL (YR)" />
|
||||
<Input field="webcam_url" label="Webkamera URL" />
|
||||
<Input field="video_url" label="Video URL (YouTube/Vimeo)" />
|
||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Nettside URL</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('website_url', 'text')} onChange={e => handleChange('website_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">Golfbox Booking URL</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('golfbox_booking_url', 'text')} onChange={e => handleChange('golfbox_booking_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">Golfbox Turnering URL</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('golfbox_tournament_url', 'text')} onChange={e => handleChange('golfbox_tournament_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">Baneguide URL</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('baneguide_url', 'text')} onChange={e => handleChange('baneguide_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">Flyfoto URL</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('flyfoto_url', 'text')} onChange={e => handleChange('flyfoto_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">Vær URL (YR)</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('weather_url', 'text')} onChange={e => handleChange('weather_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">Webkamera URL</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('webcam_url', 'text')} onChange={e => handleChange('webcam_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">Video URL (YouTube/Vimeo)</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('video_url', 'text')} onChange={e => handleChange('video_url', e.target.value)} /></div>
|
||||
|
||||
{/* NYTT: Sosiale Medier lagt inn som liste-editor her */}
|
||||
<ListObjectEditor
|
||||
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
|
||||
value={formData.social_links}
|
||||
|
|
@ -493,28 +495,27 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
|||
|
||||
{activeTab === 'okonomi' && (
|
||||
<div className="flex flex-col">
|
||||
{/* NYTT FELT FOR MANUELL DATO */}
|
||||
<div className="mb-6">
|
||||
<Input field="membership_updated_at" label="Sist Oppdatert (Dato for Medlemskapspriser)" type="date" />
|
||||
<div className="mb-6 flex flex-col gap-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Sist Oppdatert (Dato for Medlemskapspriser)</label>
|
||||
<input type="date" 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('membership_updated_at', 'date')} onChange={e => handleChange('membership_updated_at', e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 p-6 rounded-2xl mb-8 border border-gray-200">
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Medlemskap</h3>
|
||||
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
|
||||
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
|
||||
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
|
||||
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
|
||||
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
|
||||
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
|
||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Navn på standard medlemskap</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('navn_standard_medlemskap', 'text')} onChange={e => handleChange('navn_standard_medlemskap', 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">Pris standard (kun tall)</label><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('standard_medlemskap', 'number')} onChange={e => handleChange('standard_medlemskap', Number(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">Kommentar standard</label><textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('standard_medlemskap_kommentarer', 'textarea')} onChange={e => handleChange('standard_medlemskap_kommentarer', 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">Navn på rimeligste</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('navn_rimeligste_alternativ', 'text')} onChange={e => handleChange('navn_rimeligste_alternativ', 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">Pris rimeligste (kun tall)</label><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('rimeligste_alternativ', 'number')} onChange={e => handleChange('rimeligste_alternativ', Number(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 medlemskapsside</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('medlemskap_url', 'text')} onChange={e => handleChange('medlemskap_url', e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 p-6 rounded-2xl border border-gray-200">
|
||||
<h3 className="font-black uppercase tracking-widest text-gray-800 mb-6 pb-2 border-b-2 border-gray-200">Veien til Golf (VTG)</h3>
|
||||
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
|
||||
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
|
||||
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Pris VTG kurs (kun tall)</label><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('vtg_pris', 'number')} onChange={e => handleChange('vtg_pris', Number(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 VTG påmelding</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('vtg_lenke', 'text')} onChange={e => handleChange('vtg_lenke', e.target.value)} /></div>
|
||||
</div>
|
||||
|
||||
{/* Tilbud (tidligere under Avansert) */}
|
||||
<div className="mt-8 border-t-2 border-gray-200 pt-8">
|
||||
<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)} />
|
||||
|
|
@ -537,7 +538,6 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* BANER & SCOREKORT MED NY GRAFISK BYGGER */}
|
||||
{activeTab === 'baner' && (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
|
||||
|
|
|
|||
|
|
@ -404,13 +404,13 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
|||
<div className="bg-[#f1f7ed] p-12 md:rounded-[2.5rem] mb-8 text-center border border-[#7ca982]/10">
|
||||
<p className="text-[#7ca982] text-[11px] font-black uppercase mb-3 tracking-widest">{facility.navn_standard_medlemskap || "Standard"}</p>
|
||||
<p className="text-6xl font-black text-[#11280f]">kr {facility.standard_medlemskap || '--'},-</p>
|
||||
{facility.standard_medlemskap_kommentarer && <p className="text-[10px] text-gray-400 mt-4 uppercase font-bold italic leading-tight">{facility.standard_medlemskap_kommentarer}</p>}
|
||||
{facility.standard_medlemskap_kommentarer && <p className="text-[10px] text-gray-400 mt-4 uppercase font-bold italic leading-tight whitespace-pre-wrap">{facility.standard_medlemskap_kommentarer}</p>}
|
||||
</div>
|
||||
{facility.navn_rimeligste_alternativ && (
|
||||
<div className="px-8 py-5 bg-gray-50 rounded-2xl border border-gray-100 flex justify-between items-center text-sm font-bold"><span className="text-gray-400 uppercase text-[10px] tracking-widest">{facility.navn_rimeligste_alternativ}</span><span className="text-[#11280f]">kr {facility.rimeligste_alternativ},-</span></div>
|
||||
)}
|
||||
</div>
|
||||
<a href={facility.medlemskap_url} target="_blank" className="mt-10 block w-full text-center bg-[#11280f] text-white p-7 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl hover:bg-black transition-all">Se alle alternativer</a>
|
||||
<a href={facility.medlemskap_url ? facility.medlemskap_url.split(',')[0].trim() : '#'} target="_blank" className="mt-10 block w-full text-center bg-[#11280f] text-white p-7 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl hover:bg-black transition-all">Se alle alternativer</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue