From 15aee27e24ea179d05f79861a1212d2e7b88e9eb Mon Sep 17 00:00:00 2001 From: Erol Date: Thu, 12 Mar 2026 14:51:17 +0100 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20vi=20henter=20Greenfee=20og=20Vtg=20?= =?UTF-8?q?fra=20Backend=20til=20Fronten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/__pycache__/main.cpython-311.pyc | Bin 33356 -> 43341 bytes backend/main.py | 124 +++++++++++++- backend/scrape_vtg.py | 161 ++++++++++++++++++ frontend/src/app/admin/greenfee/page.tsx | 203 ++++++++++++++++++++++ frontend/src/app/admin/page.tsx | 195 +++++++++++---------- frontend/src/app/admin/vtg/page.tsx | 208 +++++++++++++++++++++++ 6 files changed, 796 insertions(+), 95 deletions(-) create mode 100644 backend/scrape_vtg.py create mode 100644 frontend/src/app/admin/greenfee/page.tsx create mode 100644 frontend/src/app/admin/vtg/page.tsx diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc index 38317878e4338af052eadbd20900b1326b2e5c26..ac2b375bfa1b3d6bd234d71c639dd5d8651641bd 100644 GIT binary patch delta 9267 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 LqNyzT$=3e^(?Kbn delta 2896 zcmZ`)Yit`u5cZyZcCeGsQYAo}CT^Z~lQ?l6O56rmmKpTgkkrV2v9S8HTlD?OpbA3(rD$bP2SF5a|CDd~@T8I&p zl+u*NSGS114D|IhS1G3wSXHDfv%q-zBF|*7mrpW{AU+6~~$d zhPN)8q101L(4u&=SS`S6#eH~%d1+zFlySJV3(K`l8BUw&ItXJ;mcwg+(S{DaEg6hf z>QUBG7lg4ci?tqD*9z7(i?G_L4Or{5SQ~&PlH|QMgVjde$_APPtoAHc`#-FWmt<|q zVr>MLNL1D44Az#ctZn+owj;x~Gs|}KLNxqO%aWCCv>LLyHEZ`RU?sA;w>!flX0?sh zz)7#mVs+3?W!tzYNOrYC6gk}G>w2>77^Tr5rTo3+DXU>o)R6@Dt+-#N;Hgvp zWd)Pv2@v<_+N!T465>0{Yse^1mET%E4r&%gh#fPF#Cv&b#c2}P z&6V3EGS2&5b&Y6Y$RlAbPTdllLi;2@9M9`?vT9t;39CUGj|HM^KRiC9Kj&&BWWWBU z`w5xc#t+opNA~ct`h5i=79W_T{iIO3o1d)rlHL59`ZjV8uksdK(LI~t+q^~c2@p@| zVee`miY(3}_iU(2q5P*>vP$Fv7AE)uS zsznD4b2u>B>6Tf553b#`R|Inmjh{qVf?z=q2TccvkB=fe24KjsDJ>B*EQ9@n1MG2B zi&H&;QZ54CZI%yU*yA+eAJMdNmAZ4-J*a;M^|n}GDy#(*b}u$hHiz&OFIm?tzY5~# z`p~-5axT8vImwVb^~}%f+Bc9wuC;rL7m3S|gETQRIw!F?{!)9T<18?3P)+et!sqvw z=#Gtd$SsFJ>9$?+Sm{IKo^CUH7yi;w`T42N636@Sd=)xuOFO)*>x8B394My?Hb%oV z6ru@dP>n`u+^LR_QxC(~Yx7u^S1_tM2f;H&f7tKTtNXg`#H%N7vJl4(_q4`@dLhI@&>=)8zvFna`Qg2RX=%iA6fD#Qp~XcV_%}-7rbz|+&Tc_^!t2!PXQko z&gGHAHaQEJ8AFL87cCD3o*NtL1xk6>S&kxCx!^LQ`uX7Nb$ckd^K00Q>spi}bVpCL7#0b7-nToW#nSaX)c&@IDwh z0Y&jA0`YN%eTiKGzMw!wP{sV2P^DZ6nq&Hf&^N^4#AsF_9O7ryE>g-1$BL`VP_d|U zR*p;9uY_M#!Tn=i*#*iM^uuGSRGpbgv=j*v6^1PkTA8?ZN$!pm%XL6~Mem8&9y zB^Jl8NT^3}@{ZeGd+`qTK^PFjGUs|{x!1XKYsd8+PF2CD23CruF9JYo+Klog+n&hW z7PPedr?kxAS>!N|L??{&w_w@sq&hP0=il9)Z*2japULxgLqu;66hy-cJ%mOl*qx~Vw;vzn=Z};w$F{^O5kxJC7mptrHpNS3mIuY{z%K>E z_9-p_!ycIa)?xF4rX7F|$*^0%HBBZM);7ZuWLP!~3xMHEWSClpakDZswk0Rj5sgKe z#?Q>GbZmnK_?hC3R{f`$Nr~9?Z;$>))?7!(5P5R*yh#Wrt1b%v|NGHwlA6zxrg=$N R&HMBfvjc=I=XrDOe*@@My2bzi diff --git a/backend/main.py b/backend/main.py index 35b6ee6..c442104 100644 --- a/backend/main.py +++ b/backend/main.py @@ -66,6 +66,19 @@ class QuickEditRequest(BaseModel): field: str value: str +class GreenfeeApproval(BaseModel): + facility_id: int + greenfee: List[dict] + + +class VtgApproval(BaseModel): + facility_id: int + vtg_pris: int | None + vtg_beskrivelse: str | None + vtg_datoer: List[dict] | None + +class BulkVtgRequest(BaseModel): + approvals: List[VtgApproval] # --- FUNKSJONER --- def format_row(row): """ @@ -359,7 +372,8 @@ async def update_facility_full(facility_id: int, request: Request): 'navn_rimeligste_alternativ', 'rimeligste_alternativ', 'medlemskap_url', 'vtg_presentasjon', 'vtg_lenke', 'vtg_pris', 'vtg_kursdatoer', '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' ] update_data = {k: v for k, v in data.items() if k in allowed_fields} @@ -512,7 +526,113 @@ async def quick_edit_facility(facility_id: int, request: QuickEditRequest): await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2", request.value, facility_id) return {"status": "success"} - + +# --- GREENFEE "VASKERI" ENDEPUNKTER --- + +@app.get("/api/admin/greenfee/drafts") +async def get_greenfee_drafts(): + """Henter alle anlegg som har et ventende greenfee-forslag fra AI-skraperen.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, slug, greenfee_url, greenfee, greenfee_draft + FROM facilities + WHERE greenfee_draft IS NOT NULL + AND greenfee_draft::text != '{}' + ORDER BY name ASC + """) + return [format_row(row) for row in rows] + +class BulkGreenfeeRequest(BaseModel): + approvals: List[GreenfeeApproval] + +@app.post("/api/admin/greenfee/approve-bulk") +async def approve_greenfee_bulk(request: BulkGreenfeeRequest): + """Godkjenner AI-forslag, setter oppdatert-dato 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 greenfee = $1::jsonb, + greenfee_updated_at = NOW(), + greenfee_draft = NULL + WHERE id = $2 + """, json.dumps(approval.greenfee), approval.facility_id) + return {"status": "success"} + +def run_greenfee_worker(facility_ids: List[int]): + """Kjører greenfee-skraperen i bakgrunnen.""" + print(f"🔄 STARTER GREENFEE-SKRAPING FOR IDER: {facility_ids}") + try: + import subprocess + ids_arg = ",".join(map(str, facility_ids)) + command = f"python -u scrape_greenfee.py --ids {ids_arg}" + subprocess.run(command, shell=True, check=True) + print(f"✅ GREENFEE-SKRAPING FULLFØRT FOR IDER: {facility_ids}") + except Exception as e: + print(f"🔥 FEIL UNDER GREENFEE-SKRAPING: {e}") + +@app.post("/api/admin/run-greenfee-scraper") +async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): + """Tar imot IDer for greenfeeskraping og legger jobben i kø.""" + if not request.facility_ids: + raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") + background_tasks.add_task(run_greenfee_worker, request.facility_ids) + return {"status": "queued", "message": "Skraping startet"} + +# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER --- + +@app.get("/api/admin/vtg/drafts") +async def get_vtg_drafts(): + """Henter alle anlegg som har et ventende VTG-forslag.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, slug, vtg_lenke, vtg_pris, vtg_beskrivelse, vtg_datoer, vtg_draft + FROM facilities + WHERE vtg_draft IS NOT NULL + AND vtg_draft::text != '{}' + ORDER BY name ASC + """) + return [format_row(row) for row in rows] + +@app.post("/api/admin/vtg/approve-bulk") +async def approve_vtg_bulk(request: BulkVtgRequest): + """Godkjenner AI-forslag for VTG, setter oppdatert-dato og sletter utkastet.""" + async with app.state.pool.acquire() as conn: + async with conn.transaction(): + for approval in request.approvals: + datoer_json = json.dumps(approval.vtg_datoer) if approval.vtg_datoer is not None else '[]' + await conn.execute(""" + UPDATE facilities + SET vtg_pris = $1, + vtg_beskrivelse = $2, + vtg_datoer = $3::jsonb, + vtg_updated_at = NOW(), + vtg_draft = NULL + WHERE id = $4 + """, approval.vtg_pris, approval.vtg_beskrivelse, datoer_json, approval.facility_id) + return {"status": "success"} + +def run_vtg_worker(facility_ids: List[int]): + """Kjører VTG-skraperen i bakgrunnen.""" + print(f"🔄 STARTER VTG-SKRAPING FOR IDER: {facility_ids}") + try: + import subprocess + ids_arg = ",".join(map(str, facility_ids)) + command = f"python -u scrape_vtg.py --ids {ids_arg}" + subprocess.run(command, shell=True, check=True) + print(f"✅ VTG-SKRAPING FULLFØRT FOR IDER: {facility_ids}") + except Exception as e: + print(f"🔥 FEIL UNDER VTG-SKRAPING: {e}") + +@app.post("/api/admin/run-vtg-scraper") +async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): + """Tar imot IDer for VTG-skraping og legger jobben i kø.""" + if not request.facility_ids: + raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") + background_tasks.add_task(run_vtg_worker, request.facility_ids) + return {"status": "queued", "message": "Skraping startet"} + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/backend/scrape_vtg.py b/backend/scrape_vtg.py new file mode 100644 index 0000000..797545d --- /dev/null +++ b/backend/scrape_vtg.py @@ -0,0 +1,161 @@ +""" +TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI +--------------------------------------------------------------------------- +Henter pris, beskrivelse (inkl. lånekøller/medlemskap) og kursdatoer fra VTG-sider. +Støtter kommaseparerte URL-er. +--------------------------------------------------------------------------- +""" + +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_vtg_with_gemini(text: str, club_name: str) -> dict: + print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...") + + prompt = f""" +Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs. + +OPPGAVER: +1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet). +2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om: + - Er lån av køller/utstyr inkludert i kurset? + - Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)? + - Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring"). + Ignorer uvesentlig støy og lange historiske utgreiinger. +3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste"). + +TEKST FRA NETTSIDEN: +{text} + +OPPGAVE: +Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur: +{{ + "foreslatt_vtg_pris": 1990, + "foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring. Lån av golfkøller er inkludert under hele kurset, og prisen gir deg også fritt spill og medlemskap ut året.", + "foreslatt_vtg_datoer": [ + {{"dato": "12.-14. mai", "status": "Fulltegnet"}}, + {{"dato": "5.-7. juni", "status": "Ledig"}} + ], + "ai_begrunnelse": "Fant voksenpris på 1990,-. Teksten nevnte eksplisitt at medlemskap ut året er med i prisen, og at man får låne utstyr." +}} +Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du ikke finner datoer, la listen være tom []. +""" + + 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_vtg_scraper(facility_ids=None): + print("🚀 Starter Veien til Golf (VTG) skraperen...") + conn = await asyncpg.connect(DB_URL) + + try: + query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''" + 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['vtg_lenke'] + + print(f"\n▶️ Behandler VTG 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_vtg_with_gemini(combined_text[:25000], name) + + if not draft_data: + continue + + print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.") + + await conn.execute(""" + UPDATE facilities + SET vtg_draft = $1::jsonb + WHERE id = $2 + """, json.dumps(draft_data), fac_id) + + print(" 💾 VTG-utkast lagret i databasen!") + + await browser.close() + + finally: + await conn.close() + print("\n🏁 Skraping fullført.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Skrap VTG 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_vtg_scraper(ids_to_scrape)) \ No newline at end of file diff --git a/frontend/src/app/admin/greenfee/page.tsx b/frontend/src/app/admin/greenfee/page.tsx new file mode 100644 index 0000000..ed8a782 --- /dev/null +++ b/frontend/src/app/admin/greenfee/page.tsx @@ -0,0 +1,203 @@ +"use client"; +import { useState, useEffect } from 'react'; +import { API_URL } from "@/config/constants"; +import Link from 'next/link'; + +export default function GreenfeeWasher() { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState([]); + const [saving, setSaving] = useState(false); + + const fetchDrafts = () => { + setLoading(true); + fetch(`${API_URL}/admin/greenfee/drafts`) + .then(res => res.json()) + .then(data => { + const editableDrafts = data.map((f: any) => { + // JSONB fra Postgres kan noen ganger komme som en streng, + // vi må sikre at vi parser det hvis det trengs + let parsedDraft = f.greenfee_draft; + if (typeof parsedDraft === 'string') { + try { parsedDraft = JSON.parse(parsedDraft); } + catch (e) { console.error("Kunne ikke parse JSON", e); } + } + + // Hent ut selve listen (fallback til tom liste hvis noe er feil) + const greenfeeList = parsedDraft?.foreslatt_greenfee || []; + + return { + ...f, + greenfee_draft: parsedDraft, // Lagre den parsede versjonen for visning + edit_greenfee: greenfeeList // Dette er arrayet som binder seg til input-feltene + }; + }); + setDrafts(editableDrafts); + setLoading(false); + }) + .catch(() => setLoading(false)); + }; + + useEffect(() => { fetchDrafts(); }, []); + + const toggleSelectAll = (checked: boolean) => { + if (checked) setSelectedIds(drafts.map(d => d.id)); + else setSelectedIds([]); + }; + + const toggleOne = (id: number) => { + if (selectedIds.includes(id)) setSelectedIds(selectedIds.filter(i => i !== id)); + else setSelectedIds([...selectedIds, id]); + }; + + const removeRow = (facilityId: number, rowIndex: number) => { + setDrafts(drafts.map(d => { + if (d.id === facilityId) { + const newRows = [...d.edit_greenfee]; + newRows.splice(rowIndex, 1); + return { ...d, edit_greenfee: newRows }; + } + return d; + })); + }; + + const updateField = (facilityId: number, rowIndex: number, field: string, value: string | number) => { + setDrafts(drafts.map(d => { + if (d.id === facilityId) { + const newRows = [...d.edit_greenfee]; + newRows[rowIndex] = { ...newRows[rowIndex], [field]: value }; + return { ...d, edit_greenfee: newRows }; + } + return d; + })); + }; + + const handleApprove = async () => { + const toApprove = drafts.filter(d => selectedIds.includes(d.id)).map(d => ({ + facility_id: d.id, + greenfee: d.edit_greenfee.map((row: any) => ({ + banenavn: row.banenavn || "", + priskategori: row.priskategori || "", + pris_voksne: Number(row.pris_voksne) || null, + pris_junior: Number(row.pris_junior) || null + })) + })); + + if (toApprove.length === 0) return alert("Velg minst ett anlegg å godkjenne."); + + setSaving(true); + try { + const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approvals: toApprove }) + }); + if (res.ok) { + alert(`${toApprove.length} anlegg oppdatert!`); + setSelectedIds([]); + fetchDrafts(); + } else { + alert("Noe gikk galt under lagring."); + } + } catch (e) { + alert("Nettverksfeil"); + } + setSaving(false); + }; + + if (loading) return
Laster utkast...
; + + return ( +
+
+
+
+ ← Tilbake til oversikten +

Greenfee-Vaskeriet

+

Sjekk at prisene gir mening før publisering.

+
+ +
+ + {drafts.length === 0 ? ( +
+ 🧹 +

Alt er rent og pent!

+
+ ) : ( +
+
+ 0} onChange={(e) => toggleSelectAll(e.target.checked)} /> + Velg Alle +
+ + {drafts.map(draft => ( +
+
+
toggleOne(draft.id)} />
+
+
+

{draft.name} ID: {draft.id}

+ Sjekk Nettside ↗ +
+ + {draft.greenfee_draft?.ai_begrunnelse && ( +
+ 🤖 AI Begrunnelse: {draft.greenfee_draft.ai_begrunnelse} +
+ )} + + {draft.greenfee_draft?.foreslatt_avtaleklubber?.length > 0 && ( +
+ 🤝 AI fant disse avtaleklubbene i teksten: {draft.greenfee_draft.foreslatt_avtaleklubber.join(', ')} +
+ )} + +
+
+

Slik ser det ut i databasen nå:

+
+ {draft.greenfee && draft.greenfee.length > 0 ? draft.greenfee.map((g: any, i: number) => ( +
+ {g.banenavn} - {g.priskategori} + V: {g.pris_voksne || '-'} | J: {g.pris_junior || '-'} +
+ )) : "Ingen priser registrert."} +
+
+ +
+

Nytt forslag å godkjenne:

+
+ {draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => ( +
+ updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" /> + updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" /> + updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" /> + updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" /> + +
+ ))} + +
+
+
+
+
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 808f333..3a69fb6 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,10 +1,6 @@ "use client"; /** - * TEE OFF ADMIN DASHBOARD v2.1 - MISSION CONTROL + INLINE EDIT - * --------------------------------------------------------------------------- - * FUNKSJON: "Mission Control" med faner for Banestatus og Medlemskap, - * og klikk-for-å-redigere URL-er direkte i tabellen! - * --------------------------------------------------------------------------- + * TEE OFF ADMIN DASHBOARD v3.0 - THE GRAND SLAM (Alle 4 skrapere) */ import { useState, useEffect, useMemo } from 'react'; @@ -12,7 +8,6 @@ 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 || ''); @@ -27,15 +22,7 @@ const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: n if (isEditing) { return (
-