Codex ferdig med ymse frontend

This commit is contained in:
Erol 2026-04-11 09:54:54 +02:00
parent eb0f7d2907
commit e5b76a7477
13 changed files with 1025 additions and 96 deletions

3
.gitignore vendored Executable file
View file

@ -0,0 +1,3 @@
__pycache__/
*.pyc
*.pyo

View file

@ -11,6 +11,7 @@ LOV: Aldri trunker eller slett logikk for "effektivitet".
from fastapi import FastAPI, HTTPException, Response, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
import asyncpg
import json
@ -20,6 +21,8 @@ from datetime import datetime, date, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
from dotenv import load_dotenv
import qrcode
import qrcode.image.svg
from pydantic import BaseModel
from typing import Optional, List, Any
@ -40,6 +43,17 @@ ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
async def validate_admin_session_token(token: str) -> str:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if not username:
raise JWTError()
return username
except JWTError as exc:
raise HTTPException(status_code=401, detail="Ugyldig eller utløpt admin-sesjon") from exc
# --- PYDANTIC MODELLER ---
class CourseStatusUpdate(BaseModel):
id: int
@ -84,6 +98,10 @@ class VtgApproval(BaseModel):
class BulkVtgRequest(BaseModel):
approvals: List[VtgApproval]
class AdminPasswordConfirm(BaseModel):
password: str
# --- FUNKSJONER ---
def format_row(row):
"""
@ -141,13 +159,23 @@ def format_row(row):
return d
async def queue_scrape_job(job_type: str, facility_ids: List[int]):
def generate_totp_qr_svg(provisioning_uri: str) -> str:
image = qrcode.make(
provisioning_uri,
image_factory=qrcode.image.svg.SvgPathImage,
box_size=8,
border=3,
)
return image.to_string(encoding="unicode")
async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None):
if job_type not in SCRAPE_JOB_TYPES:
raise HTTPException(status_code=400, detail=f"Ugyldig jobbtype: {job_type}")
if not facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
job, was_created = await enqueue_scrape_job(app.state.pool, job_type, facility_ids)
job, was_created = await enqueue_scrape_job(app.state.pool, job_type, facility_ids, requested_by=requested_by)
status = "queued" if was_created else "already_queued"
message = (
f"{job_type.capitalize()}-skraping for {len(job['facility_ids'])} anlegg ble lagt i kø."
@ -197,6 +225,23 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def require_admin_session_for_admin_routes(request: Request, call_next):
if request.url.path.startswith("/api/admin"):
token = request.cookies.get("admin_session")
if not token:
return JSONResponse(status_code=401, content={"detail": "Admin-innlogging kreves"})
try:
username = await validate_admin_session_token(token)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
request.state.admin_username = username
return await call_next(request)
# --- AUTH ENDPOINTS ---
@app.post("/api/auth/login")
@ -268,6 +313,17 @@ async def verify_2fa(data: dict, response: Response):
)
return {"status": "success"}
@app.post("/api/auth/logout")
async def logout(response: Response):
"""Logger ut admin ved å slette sesjonscookien."""
response.delete_cookie(
key="admin_session",
httponly=True,
samesite="lax",
secure=False,
)
return {"status": "success"}
# --- DATA ENDPOINTS ---
@app.get("/api/facilities")
@ -464,18 +520,61 @@ async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit:
return await list_scrape_jobs(app.state.pool, job_type=job_type, limit=limit)
@app.post("/api/admin/2fa/setup")
async def get_admin_2fa_setup(request: AdminPasswordConfirm, http_request: Request):
"""Verifiserer passord på nytt og returnerer TOTP-oppsett for 1Password/Authenticator."""
username = getattr(http_request.state, "admin_username", None)
if not username:
raise HTTPException(status_code=401, detail="Admin-innlogging kreves")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT username, email, password_hash, otp_secret FROM admins WHERE username = $1",
username,
)
if not admin:
raise HTTPException(status_code=404, detail="Admin-bruker ble ikke funnet")
try:
password_is_valid = pwd_context.verify(request.password, admin["password_hash"])
except Exception as exc:
raise HTTPException(status_code=500, detail="Kunne ikke verifisere passordet") from exc
if not password_is_valid:
raise HTTPException(status_code=401, detail="Feil passord")
if not admin["otp_secret"]:
raise HTTPException(status_code=400, detail="Denne kontoen har ikke 2FA-nøkkel ennå")
issuer_name = "TeeOff.no"
account_name = admin["email"] or admin["username"]
provisioning_uri = pyotp.TOTP(admin["otp_secret"]).provisioning_uri(
name=account_name,
issuer_name=issuer_name,
)
return {
"issuer": issuer_name,
"account_name": account_name,
"otp_secret": admin["otp_secret"],
"provisioning_uri": provisioning_uri,
"qr_svg": generate_totp_qr_svg(provisioning_uri),
}
@app.post("/api/admin/run-scraper")
async def run_scraper_endpoint(request: ScrapeRunRequest):
async def run_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Legger banestatus-skraping i en persistent jobbkø."""
print(f"📡 API mottok forespørsel om å kjøre banestatus-skraping for IDer: {request.facility_ids}")
return await queue_scrape_job("banestatus", request.facility_ids)
return await queue_scrape_job("banestatus", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
@app.post("/api/admin/run-membership-scraper")
async def run_membership_scraper_endpoint(request: ScrapeRunRequest):
async def run_membership_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Tar imot IDer for medlemskapsskraping og legger jobben i kø."""
print(f"📡 API mottok forespørsel om medlemskapsskraping for IDer: {request.facility_ids}")
return await queue_scrape_job("medlemskap", request.facility_ids)
return await queue_scrape_job("medlemskap", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
@app.get("/api/health")
async def health_check():
@ -578,10 +677,10 @@ async def approve_greenfee_bulk(request: BulkGreenfeeRequest):
return {"status": "success"}
@app.post("/api/admin/run-greenfee-scraper")
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest):
async def run_greenfee_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Tar imot IDer for greenfeeskraping og legger jobben i kø."""
print(f"📡 API mottok forespørsel om greenfee-skraping for IDer: {request.facility_ids}")
return await queue_scrape_job("greenfee", request.facility_ids)
return await queue_scrape_job("greenfee", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
# --- VEIEN TIL GOLF (VTG) "VASKERI" ENDEPUNKTER ---
@ -617,10 +716,10 @@ async def approve_vtg_bulk(request: BulkVtgRequest):
return {"status": "success"}
@app.post("/api/admin/run-vtg-scraper")
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest):
async def run_vtg_scraper_endpoint(request: ScrapeRunRequest, http_request: Request):
"""Tar imot IDer for VTG-skraping og legger jobben i kø."""
print(f"📡 API mottok forespørsel om VTG-skraping for IDer: {request.facility_ids}")
return await queue_scrape_job("vtg", request.facility_ids)
return await queue_scrape_job("vtg", request.facility_ids, requested_by=getattr(http_request.state, "admin_username", None))
if __name__ == "__main__":
import uvicorn

View file

@ -11,5 +11,6 @@ python-dotenv
python-jose[cryptography]
passlib[bcrypt]
pyotp
qrcode
google-genai
google-generativeai
google-generativeai

View file

@ -1,6 +1,8 @@
"use client";
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import Link from 'next/link';
export default function GreenfeeWasher() {
@ -11,7 +13,7 @@ export default function GreenfeeWasher() {
const fetchDrafts = () => {
setLoading(true);
fetch(`${API_URL}/admin/greenfee/drafts`)
adminFetch(`${API_URL}/admin/greenfee/drafts`)
.then(res => res.json())
.then(data => {
const editableDrafts = data.map((f: any) => {
@ -87,7 +89,7 @@ export default function GreenfeeWasher() {
setSaving(true);
try {
const res = await fetch(`${API_URL}/admin/greenfee/approve-bulk`, {
const res = await adminFetch(`${API_URL}/admin/greenfee/approve-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals: toApprove })
@ -110,13 +112,14 @@ export default function GreenfeeWasher() {
return (
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
<div className="max-w-[1400px] mx-auto">
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
<AdminMobileMenu />
<div className="mb-10 flex flex-col gap-5 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black">Greenfee-Vaskeriet</h1>
<p className="text-sm text-gray-600 mt-2">Sjekk at prisene gir mening før publisering.</p>
</div>
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="w-full rounded-xl bg-[#8bc34a] px-8 py-4 font-black uppercase tracking-widest text-white shadow-lg transition-all hover:scale-105 disabled:opacity-50 md:w-auto">
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
</div>
@ -133,14 +136,14 @@ export default function GreenfeeWasher() {
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
</div>
{drafts.map(draft => (
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
{drafts.map((draft, index) => (
<div key={draft.id} className={`p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/10 ring-2 ring-[#8bc34a]/20' : index % 2 === 0 ? 'border-[#e3edd7] bg-white' : 'border-[#dbe7f5] bg-[#f8fbff]'}`}>
<div className="flex gap-6 items-start">
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
<div className="flex-grow space-y-4">
<div className="flex justify-between items-center border-b pb-4">
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
<a href={draft.greenfee_url?.split(',')[0]} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside </a>
<a href={draft.greenfee_url?.split(',')[0]} target="_blank" className="inline-flex w-full items-center justify-center rounded-lg bg-blue-50 px-4 py-3 text-xs font-bold text-blue-600 hover:underline md:w-auto">Sjekk Nettside </a>
</div>
{draft.greenfee_draft?.ai_begrunnelse && (
@ -172,12 +175,12 @@ export default function GreenfeeWasher() {
<h4 className="text-xs font-black uppercase tracking-widest text-green-600 mb-2">Nytt forslag å godkjenne:</h4>
<div className="space-y-2">
{draft.edit_greenfee && draft.edit_greenfee.map((row: any, idx: number) => (
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.banenavn || ''} onChange={e => updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" />
<input className="w-1/3 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none" value={row.priskategori || ''} onChange={e => updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" type="number" value={row.pris_voksne || ''} onChange={e => updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
<input className="w-16 p-2 rounded border border-gray-100 text-xs text-center focus:border-[#8bc34a] outline-none" type="number" value={row.pris_junior || ''} onChange={e => updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" />
<button onClick={() => removeRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett rad"></button>
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)_110px_110px_auto] sm:items-center">
<input className="w-full rounded border border-gray-100 p-2 text-xs font-bold outline-none focus:border-[#8bc34a]" value={row.banenavn || ''} onChange={e => updateField(draft.id, idx, 'banenavn', e.target.value)} placeholder="Bane" />
<input className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a]" value={row.priskategori || ''} onChange={e => updateField(draft.id, idx, 'priskategori', e.target.value)} placeholder="Kategori" />
<input className="w-full rounded border border-gray-100 p-2 text-center text-xs outline-none focus:border-[#8bc34a]" type="number" value={row.pris_voksne || ''} onChange={e => updateField(draft.id, idx, 'pris_voksne', e.target.value)} placeholder="Voksen" />
<input className="w-full rounded border border-gray-100 p-2 text-center text-xs outline-none focus:border-[#8bc34a]" type="number" value={row.pris_junior || ''} onChange={e => updateField(draft.id, idx, 'pris_junior', e.target.value)} placeholder="Junior" />
<button onClick={() => removeRow(draft.id, idx)} className="px-2 text-left text-red-400 transition-colors hover:text-red-600 sm:text-center sm:opacity-0 sm:group-hover:opacity-100" title="Slett rad"></button>
</div>
))}
<button onClick={() => {
@ -200,4 +203,4 @@ export default function GreenfeeWasher() {
</div>
</div>
);
}
}

View file

@ -1,6 +1,8 @@
"use client";
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import Link from 'next/link';
export default function MembershipWasher() {
@ -11,7 +13,7 @@ export default function MembershipWasher() {
const fetchDrafts = () => {
setLoading(true);
fetch(`${API_URL}/admin/membership/drafts`)
adminFetch(`${API_URL}/admin/membership/drafts`)
.then(res => res.json())
.then(data => {
// Konverter innkommende drafts til editerbare felter lokalt
@ -61,7 +63,7 @@ export default function MembershipWasher() {
setSaving(true);
try {
const res = await fetch(`${API_URL}/admin/membership/approve-bulk`, {
const res = await adminFetch(`${API_URL}/admin/membership/approve-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals: toApprove })
@ -84,7 +86,8 @@ export default function MembershipWasher() {
return (
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
<div className="max-w-[1400px] mx-auto">
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
<AdminMobileMenu />
<div className="mb-10 flex flex-col gap-5 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black">Medlemskaps-Vaskeriet</h1>
@ -93,7 +96,7 @@ export default function MembershipWasher() {
<button
onClick={handleApprove}
disabled={saving || selectedIds.length === 0}
className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50 disabled:scale-100"
className="w-full rounded-xl bg-[#8bc34a] px-8 py-4 font-black uppercase tracking-widest text-white shadow-lg transition-all hover:scale-105 disabled:scale-100 disabled:opacity-50 md:w-auto"
>
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
@ -117,8 +120,8 @@ export default function MembershipWasher() {
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
</div>
{drafts.map(draft => (
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
{drafts.map((draft, index) => (
<div key={draft.id} className={`p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/10 ring-2 ring-[#8bc34a]/20' : index % 2 === 0 ? 'border-[#e3edd7] bg-white' : 'border-[#dbe7f5] bg-[#f8fbff]'}`}>
<div className="flex gap-6 items-start">
<div className="pt-2">
<input
@ -131,12 +134,12 @@ export default function MembershipWasher() {
<div className="flex-grow space-y-4">
{/* OPPDATERT: Navn + ID Badge */}
<div className="flex justify-between items-center border-b pb-4">
<h3 className="text-2xl font-black flex items-center gap-3">
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black flex flex-wrap items-center gap-3">
{draft.name}
<span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span>
</h3>
<a href={draft.medlemskap_url} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Klubbens Nettside </a>
<a href={draft.medlemskap_url} target="_blank" className="inline-flex w-full items-center justify-center rounded-lg bg-blue-50 px-4 py-3 text-xs font-bold text-blue-600 hover:underline md:w-auto">Sjekk Klubbens Nettside </a>
</div>
{draft.membership_draft?.ai_begrunnelse && (
@ -149,9 +152,9 @@ export default function MembershipWasher() {
{/* Standard */}
<div className="space-y-3">
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Standard Medlemskap (Ubegrenset)</h4>
<div className="flex gap-2">
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
<div className="grid gap-2 sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<input className="w-full p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_standard_navn} onChange={e => updateDraftField(draft.id, 'edit_standard_navn', e.target.value)} placeholder="Navn (eks. Hovedmedlem)" />
<input className="w-full p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_standard_pris} onChange={e => updateDraftField(draft.id, 'edit_standard_pris', e.target.value)} placeholder="Pris" />
</div>
<input className="w-full p-3 rounded-xl border border-gray-200 text-sm focus:border-[#8bc34a] outline-none" value={draft.edit_standard_kommentar} onChange={e => updateDraftField(draft.id, 'edit_standard_kommentar', e.target.value)} placeholder="Kommentar (F.eks: Inkluderer ikke treningsavgift)" />
<p className="text-[10px] text-gray-400">Gammel pris var: {draft.standard_medlemskap ? `kr ${draft.standard_medlemskap} (${draft.navn_standard_medlemskap})` : 'Ikke registrert'}</p>
@ -160,9 +163,9 @@ export default function MembershipWasher() {
{/* Rimeligste */}
<div className="space-y-3">
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Rimeligste (Betaler Greenfee)</h4>
<div className="flex gap-2">
<input className="w-2/3 p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
<input className="w-1/3 p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
<div className="grid gap-2 sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<input className="w-full p-3 rounded-xl border border-gray-200 font-bold focus:border-[#8bc34a] outline-none" value={draft.edit_rimeligste_navn} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_navn', e.target.value)} placeholder="Navn (eks. Greenfeemedlem)" />
<input className="w-full p-3 rounded-xl border border-gray-200 font-bold text-right focus:border-[#8bc34a] outline-none" type="number" value={draft.edit_rimeligste_pris} onChange={e => updateDraftField(draft.id, 'edit_rimeligste_pris', e.target.value)} placeholder="Pris" />
</div>
<p className="text-[10px] text-gray-400 mt-2">Gammel pris var: {draft.rimeligste_alternativ ? `kr ${draft.rimeligste_alternativ} (${draft.navn_rimeligste_alternativ})` : 'Ikke registrert'}</p>
</div>
@ -176,4 +179,4 @@ export default function MembershipWasher() {
</div>
</div>
);
}
}

View file

@ -5,6 +5,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
import Link from 'next/link';
@ -25,6 +26,14 @@ type ScrapeJob = {
finished_at?: string | null;
};
type TwoFactorSetupResponse = {
issuer: string;
account_name: string;
otp_secret: string;
provisioning_uri: string;
qr_svg: string;
};
const JOB_LABELS: Record<AdminTab, string> = {
banestatus: 'Banestatus',
medlemskap: 'Medlemskap',
@ -79,12 +88,19 @@ export default function AdminDashboard() {
const [scrapeJobs, setScrapeJobs] = useState<ScrapeJob[]>([]);
const [isQueueing, setIsQueueing] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false);
const [editingFacility, setEditingFacility] = useState<any | null>(null);
const [activeTab, setActiveTab] = useState<AdminTab>('banestatus');
const [statusFilter, setStatusFilter] = useState('alle');
const [editForm, setEditForm] = useState({ scrape_status_url: '', scrape_status_selector: '', scrape_method: '', ai_instruction: '', courses: [] as any[] });
const [isSaving, setIsSaving] = useState(false);
const latestJobStateRef = useRef<string | null>(null);
const [showTwoFactorModal, setShowTwoFactorModal] = useState(false);
const [twoFactorPassword, setTwoFactorPassword] = useState('');
const [twoFactorError, setTwoFactorError] = useState('');
const [isLoadingTwoFactor, setIsLoadingTwoFactor] = useState(false);
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResponse | null>(null);
const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null);
const fetchFacilities = () => {
fetch(`${API_URL}/facilities`)
@ -97,7 +113,7 @@ export default function AdminDashboard() {
};
const fetchScrapeJobs = (tab: AdminTab = activeTab) => {
fetch(`${API_URL}/admin/scrape-jobs?job_type=${tab}&limit=5`)
adminFetch(`${API_URL}/admin/scrape-jobs?job_type=${tab}&limit=5`)
.then(res => res.json())
.then(data => {
setScrapeJobs(Array.isArray(data) ? data : []);
@ -149,6 +165,32 @@ export default function AdminDashboard() {
}
}, [activeTab, latestJob]);
useEffect(() => {
if (!showTwoFactorModal) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeTwoFactorModal();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showTwoFactorModal]);
useEffect(() => {
if (!showMobileAdminMenu) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setShowMobileAdminMenu(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showMobileAdminMenu]);
const filteredFacilities = useMemo(() => {
if (statusFilter === 'alle') return facilities;
return facilities.map(facility => {
@ -187,7 +229,7 @@ export default function AdminDashboard() {
const handleQuickEdit = async (id: number, field: string, value: string) => {
setFacilities(facilities.map(f => f.id === id ? { ...f, [field]: value } : f));
try {
const res = await fetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
const res = await adminFetch(`${API_URL}/admin/facilities/${id}/quick-edit`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field, value })
@ -207,7 +249,7 @@ export default function AdminDashboard() {
activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' :
'/admin/run-vtg-scraper';
try {
const response = await fetch(`${API_URL}${endpoint}`, {
const response = await adminFetch(`${API_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ facility_ids: selectedFacilities })
@ -237,7 +279,7 @@ export default function AdminDashboard() {
const handleSaveEdit = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
const response = await adminFetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
@ -250,10 +292,72 @@ export default function AdminDashboard() {
} finally { setIsSaving(false); }
};
const openTwoFactorModal = () => {
setShowTwoFactorModal(true);
setTwoFactorPassword('');
setTwoFactorError('');
setTwoFactorSetup(null);
setCopiedTwoFactorField(null);
};
const closeTwoFactorModal = () => {
setShowTwoFactorModal(false);
setTwoFactorPassword('');
setTwoFactorError('');
setTwoFactorSetup(null);
setCopiedTwoFactorField(null);
};
const handleLoadTwoFactorSetup = async (event: React.FormEvent) => {
event.preventDefault();
setTwoFactorError('');
setIsLoadingTwoFactor(true);
setCopiedTwoFactorField(null);
try {
const response = await adminFetch(`${API_URL}/admin/2fa/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: twoFactorPassword })
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente 2FA-oppsett');
setTwoFactorSetup(data);
setTwoFactorPassword('');
} catch (error) {
if (error instanceof Error && error.message === 'ADMIN_UNAUTHORIZED') return;
setTwoFactorSetup(null);
setTwoFactorError(error instanceof Error ? error.message : 'Kunne ikke hente 2FA-oppsett');
} finally {
setIsLoadingTwoFactor(false);
}
};
const copyTwoFactorValue = async (field: 'secret' | 'uri', value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedTwoFactorField(field);
window.setTimeout(() => setCopiedTwoFactorField(null), 2000);
} catch {
setCopiedTwoFactorField(null);
}
};
const handleLogout = async () => {
try {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} finally {
window.location.href = '/';
}
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER KONTROLLPANEL...</div>;
return (
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-x-hidden">
{/* REDIGER-MODAL FOR BANESTATUS */}
{editingFacility && (
@ -323,6 +427,216 @@ export default function AdminDashboard() {
</div>
)}
{showTwoFactorModal && (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-black/60 p-3 md:p-4"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeTwoFactorModal();
}
}}
>
<div className="relative mx-auto my-4 flex w-full max-w-5xl flex-col overflow-hidden rounded-[2rem] bg-white shadow-2xl">
<button
onClick={closeTwoFactorModal}
className="absolute right-7 top-7 z-30 inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/12 text-lg font-black text-white backdrop-blur transition-colors hover:bg-white/20"
aria-label="Lukk 2FA-vindu"
title="Lukk"
>
×
</button>
<div className="sticky top-0 z-20 flex items-start justify-between gap-4 bg-[#11280f] p-6 pr-20 text-white">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">1Password og TOTP</p>
<h3 className="mt-2 text-2xl font-black tracking-tight">Sett opp 2FA i 1Password</h3>
<p className="mt-2 max-w-2xl text-sm text-white/80">
Bekreft passordet ditt nytt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan brukes i 1Password.
</p>
</div>
<button onClick={closeTwoFactorModal} className="hidden rounded-xl bg-white/10 px-4 py-2 text-xs font-black uppercase tracking-widest text-white hover:bg-white/20 md:block">
Lukk
</button>
</div>
<div className="grid gap-0 lg:grid-cols-[20rem_minmax(0,1fr)]">
<aside className="border-b border-gray-100 bg-[#f8fbf4] p-6 lg:border-b-0 lg:border-r">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Før du starter</p>
<div className="mt-5 space-y-4 text-sm leading-relaxed text-gray-600">
<p>1Password støtter samme TOTP-standard som systemet allerede bruker.</p>
<p>Dette viser eksisterende 2FA-oppsett. Vi regenererer ikke nøkkelen, vanlig login fortsetter å virke som før.</p>
<p>Hvis du vil kan du skanne QR-koden eller kopiere den manuelle nøkkelen direkte inn i 1Password.</p>
</div>
</aside>
<div className="space-y-6 p-5 md:p-6">
<section className="space-y-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Sikker bekreftelse</p>
<h4 className="mt-2 text-xl font-black tracking-tight text-[#11280f]">Vis QR-kode</h4>
</div>
<form onSubmit={handleLoadTwoFactorSetup} className="space-y-4 rounded-[1.75rem] border border-gray-100 bg-gray-50 p-5">
<label className="block">
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-gray-500">Bekreft passord</span>
<input
type="password"
value={twoFactorPassword}
onChange={(e) => setTwoFactorPassword(e.target.value)}
className="w-full rounded-2xl border-2 border-gray-200 bg-white px-5 py-4 text-base font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
placeholder="Skriv inn admin-passordet ditt"
required
/>
</label>
{twoFactorError && (
<div className="rounded-2xl border border-red-100 bg-red-50 px-4 py-3 text-sm font-bold text-red-600">
{twoFactorError}
</div>
)}
<button
type="submit"
disabled={isLoadingTwoFactor || twoFactorPassword.trim().length === 0}
className="w-full rounded-2xl bg-[#8bc34a] px-6 py-4 text-xs font-black uppercase tracking-[0.2em] text-white shadow-lg transition-all hover:scale-[1.01] disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
>
{isLoadingTwoFactor ? 'Henter oppsett...' : 'Vis oppsett for 1Password'}
</button>
</form>
</section>
<section className="space-y-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">2FA-oppsett</p>
<h4 className="mt-2 text-xl font-black tracking-tight text-[#11280f]">QR-kode og nøkkel</h4>
</div>
{!twoFactorSetup ? (
<div className="flex min-h-[360px] items-center justify-center rounded-[1.75rem] border border-dashed border-gray-200 bg-white p-8 text-center">
<div className="max-w-md">
<p className="text-lg font-black text-[#11280f]">Ingen QR-kode vist ennå</p>
<p className="mt-3 text-sm text-gray-500">
Bekreft passordet ditt for å vise QR-koden og den manuelle oppsettsnøkkelen som kan legges inn i 1Password.
</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="rounded-[1.75rem] border border-gray-100 bg-[#f8fbf4] p-5 shadow-sm">
<div className="mx-auto w-full max-w-[18rem] rounded-[1.5rem] bg-white p-4 shadow-inner" dangerouslySetInnerHTML={{ __html: twoFactorSetup.qr_svg }} />
<p className="mt-4 text-center text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">
Skann i 1Password
</p>
</div>
<div className="space-y-4 rounded-[1.75rem] border border-gray-100 bg-gray-50 p-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Konto</p>
<p className="mt-2 break-words text-lg font-black text-[#11280f]">{twoFactorSetup.account_name}</p>
<p className="text-sm text-gray-500">Issuer: {twoFactorSetup.issuer}</p>
</div>
<div className="rounded-2xl bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Manuell oppsettsnøkkel</p>
<p className="mt-3 break-all font-mono text-sm font-bold text-[#11280f]">{twoFactorSetup.otp_secret}</p>
<button
type="button"
onClick={() => copyTwoFactorValue('secret', twoFactorSetup.otp_secret)}
className="mt-4 w-full rounded-xl border border-gray-200 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-gray-500 transition-colors hover:border-[#8bc34a] hover:text-[#11280f] sm:w-auto"
>
{copiedTwoFactorField === 'secret' ? 'Kopiert' : 'Kopier nøkkel'}
</button>
</div>
<div className="rounded-2xl bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Avansert URI</p>
<p className="mt-3 break-all font-mono text-xs font-bold text-gray-600">{twoFactorSetup.provisioning_uri}</p>
<button
type="button"
onClick={() => copyTwoFactorValue('uri', twoFactorSetup.provisioning_uri)}
className="mt-4 w-full rounded-xl border border-gray-200 px-4 py-3 text-[10px] font-black uppercase tracking-[0.18em] text-gray-500 transition-colors hover:border-[#8bc34a] hover:text-[#11280f] sm:w-auto"
>
{copiedTwoFactorField === 'uri' ? 'Kopiert' : 'Kopier URI'}
</button>
</div>
</div>
</div>
)}
</section>
</div>
</div>
</div>
</div>
)}
{showMobileAdminMenu && (
<div
className="fixed inset-0 z-50 bg-black/50 md:hidden"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setShowMobileAdminMenu(false);
}
}}
>
<div className="h-full w-[88%] max-w-[22rem] bg-[#11280f] p-6 text-white shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Adminmeny</p>
<h3 className="mt-2 text-2xl font-black tracking-tight">TeeOff Admin</h3>
</div>
<button
onClick={() => setShowMobileAdminMenu(false)}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10 text-xl font-black text-white hover:bg-white/20"
aria-label="Lukk adminmeny"
>
×
</button>
</div>
<nav className="mt-6 space-y-6 text-[11px] font-black uppercase tracking-[0.2em] text-[#7ca982]">
<Link href="/admin" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl border border-[#8bc34a]/30 bg-white/5 px-4 py-4 text-white">
Kontrollpanel
</Link>
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Datavask</div>
<Link href="/admin/medlemskap" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Medlemskap
</Link>
<Link href="/admin/greenfee" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Greenfee
</Link>
<Link href="/admin/vtg" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
VTG
</Link>
</div>
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
<button
onClick={() => {
setShowMobileAdminMenu(false);
openTwoFactorModal();
}}
className="block w-full rounded-2xl px-4 py-3 text-left hover:bg-white/5 hover:text-white"
>
2FA / 1Password
</button>
</div>
</nav>
<div className="mt-8 border-t border-white/10 pt-6">
<button
onClick={handleLogout}
className="w-full rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-4 text-left text-[11px] font-black uppercase tracking-[0.2em] text-red-300 hover:bg-red-500/20"
>
Logg ut
</button>
</div>
</div>
</div>
)}
{/* SIDEBAR */}
<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'}`}>
@ -346,32 +660,60 @@ export default function AdminDashboard() {
{isSidebarCollapsed ? 'V' : 'VTG'}
</Link>
</div>
<div className="space-y-2 mt-6">
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Konto</div>
<button
onClick={openTwoFactorModal}
className={`block w-full text-left hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`}
title="2FA i 1Password"
>
{isSidebarCollapsed ? '2F' : '2FA / 1Password'}
</button>
</div>
</nav>
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
<button onClick={() => window.location.href='/'} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
<button onClick={handleLogout} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
</button>
</div>
</aside>
{/* HOVEDINNHOLD */}
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-auto">
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
<div className="mb-6 flex md:hidden">
<button
onClick={() => setShowMobileAdminMenu(true)}
className="inline-flex items-center gap-3 rounded-2xl bg-[#11280f] px-5 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-white shadow-lg"
>
<span className="text-lg leading-none"></span>
Adminmeny
</button>
</div>
<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">Kontrollpanel</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Oversikt over {filteredFacilities.length} anlegg</p>
</div>
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isQueueing}
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
${isQueueing ? 'bg-yellow-500 animate-pulse' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'}`}
>
{isQueueing ? 'Legger i kø...' : isScraping ? `Legg ${activeTab}-skraping i kø (${selectedFacilities.length})` : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
</button>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={openTwoFactorModal}
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#8bc34a] hover:text-[#11280f]"
>
2FA / 1Password
</button>
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isQueueing}
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
${isQueueing ? 'bg-yellow-500 animate-pulse' : 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'}`}
>
{isQueueing ? 'Legger i kø...' : isScraping ? `Legg ${activeTab}-skraping i kø (${selectedFacilities.length})` : `Kjør ${activeTab}-skrapere (${selectedFacilities.length})`}
</button>
</div>
</header>
{latestJob && latestJob.job_type === activeTab && (
@ -424,7 +766,7 @@ export default function AdminDashboard() {
)}
{/* VELDIG SYNLIGE FANER */}
<div className="flex gap-2 mb-8 border-b-2 border-gray-100 pb-0 overflow-x-auto hide-scrollbar">
<div className="mb-8 flex flex-wrap gap-2 border-b-2 border-gray-100 pb-3">
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>
<button onClick={() => setActiveTab('medlemskap')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'medlemskap' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Medlemskap</button>
<button onClick={() => setActiveTab('greenfee')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'greenfee' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Greenfee</button>
@ -444,13 +786,263 @@ export default function AdminDashboard() {
</div>
)}
<div className="overflow-x-auto pb-4">
<table className="w-full text-left border-collapse min-w-[900px]">
<div className="mb-6 flex flex-col gap-4 rounded-[1.75rem] border border-gray-100 bg-[#f8fbf4] p-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Arbeidsvisning</p>
<p className="text-sm font-bold text-[#11280f]">
Hvert anlegg vises som et eget arbeidskort, slik at du ser innhold, status og handlinger samlet uten sideveis scrolling.
</p>
</div>
<label className="inline-flex items-center gap-3 rounded-2xl bg-white px-4 py-3 text-xs font-black uppercase tracking-widest text-gray-500 shadow-sm">
<input
type="checkbox"
className="h-5 w-5 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.length === filteredFacilities.length && filteredFacilities.length > 0}
onChange={handleSelectAll}
/>
Velg alle i visningen
</label>
</div>
<div className="space-y-5 pb-4">
{filteredFacilities.map((f: any, index: number) => {
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
const hasGfDraft = f.greenfee_draft && Object.keys(f.greenfee_draft).length > 0;
const hasVtgDraft = f.vtg_draft && Object.keys(f.vtg_draft).length > 0;
const isHighlighted = (activeTab === 'medlemskap' && hasMemDraft) || (activeTab === 'greenfee' && hasGfDraft) || (activeTab === 'vtg' && hasVtgDraft);
const accentStyles = [
'bg-white border-gray-100',
'bg-[#fbfdf8] border-[#e3edd7]',
'bg-[#f8fbff] border-[#dbe7f5]',
];
const accentStyle = isHighlighted ? 'bg-[#f3f9ea] border-[#b9d88d]' : accentStyles[index % accentStyles.length];
return (
<article
key={f.id}
className={`rounded-[1.9rem] border p-5 shadow-sm transition-all md:p-6 ${accentStyle} ${selectedFacilities.includes(f.id) ? 'ring-2 ring-[#8bc34a]/35 shadow-lg' : ''}`}
>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-4">
<input
type="checkbox"
className="mt-1 h-5 w-5 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.includes(f.id)}
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
/>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-2xl font-black tracking-tight text-[#11280f]">{f.name}</h3>
<span className="rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-gray-400 shadow-sm">ID {f.id}</span>
{isHighlighted && (
<span className="rounded-xl bg-[#8bc34a] px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-white">
Trenger oppmerksomhet
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs font-bold uppercase tracking-[0.18em] text-[#7ca982]">
<span>{f.city || 'Ukjent sted'}</span>
{activeTab === 'banestatus' && (
<span>{f.status_updated_at ? `Sjekket ${new Date(f.status_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri sjekket'}</span>
)}
{activeTab === 'medlemskap' && (
<span>{f.membership_updated_at ? `Vasket ${new Date(f.membership_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)}
{activeTab === 'greenfee' && (
<span>{f.greenfee_updated_at ? `Vasket ${new Date(f.greenfee_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)}
{activeTab === 'vtg' && (
<span>{f.vtg_updated_at ? `Vasket ${new Date(f.vtg_updated_at).toLocaleDateString('nb-NO')}` : 'Aldri vasket'}</span>
)}
</div>
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:min-w-[180px]">
{activeTab === 'banestatus' && (
<button onClick={() => openEditModal(f)} className="rounded-2xl bg-white px-4 py-3 text-[10px] font-black uppercase tracking-widest text-[#11280f] shadow-sm transition-colors hover:bg-gray-100">
Innstillinger
</button>
)}
{activeTab === 'medlemskap' && hasMemDraft && (
<Link href="/admin/medlemskap" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
Til vaskeri
</Link>
)}
{activeTab === 'greenfee' && hasGfDraft && (
<Link href="/admin/greenfee" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
Til vaskeri
</Link>
)}
{activeTab === 'vtg' && hasVtgDraft && (
<Link href="/admin/vtg" className="rounded-2xl border border-yellow-200 bg-yellow-100 px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-yellow-800 transition-colors hover:bg-yellow-200">
Til vaskeri
</Link>
)}
<Link href={`/admin/rediger/${f.slug}`} className="rounded-2xl bg-[#11280f] px-4 py-3 text-center text-[10px] font-black uppercase tracking-widest text-white transition-colors hover:bg-[#8bc34a]">
Rediger alt
</Link>
</div>
</div>
{activeTab === 'banestatus' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde og metode</p>
<div className="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<InlineEdit facilityId={f.id} field="scrape_status_url" initialValue={f.scrape_status_url} onSave={handleQuickEdit} />
<p className="break-all text-[10px] font-mono text-gray-400">{f.scrape_status_selector || 'Ingen selector lagret'}</p>
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Metode</p>
<ScrapeMethodSelect facility={f} />
</div>
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Banestatus</p>
<div className="mt-4 space-y-2">
{f.course_statuses && f.course_statuses.length > 0 ? f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
return (
<div key={idx} className="flex items-center justify-between gap-3 rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="truncate text-xs font-black uppercase tracking-[0.18em] text-gray-500">{cs.name}</span>
<span className={`rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${badgeColor}`}>{cs.status || 'UKJENT'}</span>
</div>
);
}) : (
<p className="text-sm text-gray-500">Ingen baner registrert.</p>
)}
</div>
</section>
</div>
)}
{activeTab === 'medlemskap' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_220px]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
<div className="mt-4">
<InlineEdit facilityId={f.id} field="medlemskap_url" initialValue={f.medlemskap_url} onSave={handleQuickEdit} />
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Aktuelle priser</p>
<div className="mt-4 space-y-2 text-sm">
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Standard</span>
<span className="mt-1 block font-black text-[#11280f]">{f.standard_medlemskap ? `${f.standard_medlemskap},-` : 'Ikke registrert'}</span>
</div>
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Rimeligste</span>
<span className="mt-1 block font-black text-[#11280f]">{f.rimeligste_alternativ ? `${f.rimeligste_alternativ},-` : 'Ikke registrert'}</span>
</div>
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
<div className="mt-4 space-y-3">
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasMemDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasMemDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
</span>
</div>
</section>
</div>
)}
{activeTab === 'greenfee' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_220px]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
<div className="mt-4">
<InlineEdit facilityId={f.id} field="greenfee_url" initialValue={f.greenfee_url} onSave={handleQuickEdit} />
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Aktive priser</p>
<div className="mt-4 space-y-2">
{f.greenfee && f.greenfee.length > 0 ? f.greenfee.map((g: any, i: number) => (
<div key={i} className="grid gap-2 rounded-2xl bg-[#f8fbf4] px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div>
<p className="text-xs font-black text-[#11280f]">{g.banenavn || 'Uten banenavn'}</p>
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-gray-400">{g.priskategori || 'Standard'}</p>
</div>
<p className="text-xs font-black text-gray-600">V: {g.pris_voksne || '-'} J: {g.pris_junior || '-'}</p>
</div>
)) : (
<p className="text-sm text-gray-500">Ingen priser registrert.</p>
)}
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
<div className="mt-4 space-y-3">
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasGfDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasGfDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
</span>
</div>
</section>
</div>
)}
{activeTab === 'vtg' && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_220px]">
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Kilde</p>
<div className="mt-4">
<InlineEdit facilityId={f.id} field="vtg_lenke" initialValue={f.vtg_lenke} onSave={handleQuickEdit} />
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Registrert informasjon</p>
<div className="mt-4 space-y-3">
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Pris</span>
<span className="mt-1 block font-black text-[#8bc34a]">{f.vtg_pris ? `${f.vtg_pris},-` : 'Ikke registrert'}</span>
</div>
<div className="rounded-2xl bg-[#f8fbf4] px-4 py-3">
<span className="block text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Beskrivelse</span>
<span className="mt-1 block text-sm text-gray-600">{f.vtg_beskrivelse || 'Ingen beskrivelse registrert.'}</span>
</div>
<div className="inline-flex rounded-xl bg-white px-3 py-2 text-[10px] font-black uppercase tracking-widest text-[#11280f] shadow-sm">
{f.vtg_datoer && f.vtg_datoer.length > 0 ? `${f.vtg_datoer.length} kursdatoer` : 'Ingen kursdatoer'}
</div>
</div>
</section>
<section className="rounded-[1.5rem] bg-white/80 p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-400">Status</p>
<div className="mt-4 space-y-3">
<span className={`inline-flex rounded-xl px-3 py-2 text-[10px] font-black uppercase tracking-widest ${hasVtgDraft ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'}`}>
{hasVtgDraft ? 'Nytt utkast klart' : 'Ingen nye utkast'}
</span>
</div>
</section>
</div>
)}
</div>
</article>
);
})}
</div>
<div className="hidden mb-4 items-center justify-between gap-4 text-[10px] font-black uppercase tracking-widest text-gray-400">
<span>Scroll sidelengs for flere kolonner</span>
<span className="text-[#7ca982]">Venstre og høyre kant er låst</span>
</div>
<div className="hidden overflow-x-auto overflow-y-visible pb-4 rounded-[1.5rem] border border-gray-100 bg-white">
<table className="w-max min-w-full text-left border-collapse min-w-[1100px]">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-400 border-b border-gray-100">
<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} /></th>
<th className="pb-4 w-12 text-center">ID</th>
<th className="pb-4 pr-6">Anlegg</th>
<th className="pb-4 pl-4 w-10 sticky left-0 z-20 bg-white"><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 sticky left-[56px] z-20 bg-white">ID</th>
<th className="pb-4 pr-6 sticky left-[104px] z-20 bg-white min-w-[220px]">Anlegg</th>
{activeTab === 'banestatus' && (
<>
@ -484,10 +1076,10 @@ export default function AdminDashboard() {
<th className="pb-4">Sist Vasket</th>
</>
)}
<th className="pb-4 text-right pr-4">Handling</th>
<th className="pb-4 text-right pr-4 sticky right-0 z-20 bg-white min-w-[150px]">Handling</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{filteredFacilities.map((f: any) => {
const hasMemDraft = f.membership_draft && Object.keys(f.membership_draft).length > 0;
@ -497,9 +1089,9 @@ export default function AdminDashboard() {
return (
<tr key={f.id} className={`border-b border-gray-50 group transition-colors ${isHighlighted ? 'bg-[#8bc34a]/10' : '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)} /></td>
<td className="py-6 text-center text-xs font-mono text-gray-400">#{f.id}</td>
<td className="py-6 pr-6">
<td className={`py-6 pl-4 w-10 sticky left-0 z-10 ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}><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 sticky left-[56px] z-10 ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>#{f.id}</td>
<td className={`py-6 pr-6 sticky left-[104px] z-10 min-w-[220px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
<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>
@ -580,7 +1172,7 @@ export default function AdminDashboard() {
</>
)}
<td className="py-6 text-right pr-4">
<td className={`py-6 text-right pr-4 sticky right-0 z-10 min-w-[150px] ${isHighlighted ? 'bg-[#edf6e3]' : 'bg-white group-hover:bg-gray-50/50'}`}>
<div className="flex flex-col gap-2 items-end">
{activeTab === 'banestatus' && <button onClick={() => openEditModal(f)} className="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">Innstillinger</button>}
{activeTab === 'medlemskap' && hasMemDraft && <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 shadow-sm border border-yellow-200"> til Vaskeri</Link>}

View file

@ -2,6 +2,8 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { adminFetch } from "@/config/adminFetch";
import AdminMobileMenu from "@/components/AdminMobileMenu";
// KOMPONENT 1: MultiSelect for samarbeidende klubber
const MultiSelect = ({ label, options, selected, onChange }: { label: string, options: any[], selected: string[], onChange: (s: string[]) => void }) => {
@ -60,9 +62,9 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any,
<label className="text-sm font-black uppercase tracking-widest text-[#11280f]">{label}</label>
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k} className="flex gap-3 items-center">
<div key={k} className="grid gap-3 md:grid-cols-[minmax(0,0.35fr)_minmax(0,1fr)_auto] md:items-center">
<input
className="w-1/3 p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
className="w-full p-4 rounded-xl border-2 border-gray-300 text-sm font-bold text-black bg-white focus:border-[#8bc34a] outline-none shadow-sm"
placeholder="Nøkkel (f.eks proshop)"
defaultValue={k.startsWith('ny_rad_') ? '' : k}
onBlur={e => updateKey(k, e.target.value, v)}
@ -73,7 +75,7 @@ const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any,
value={String(v)}
onChange={e => updateVal(k, e.target.value)}
/>
<button onClick={() => removeKey(k)} className="p-4 bg-red-100 text-red-700 hover:bg-red-200 hover:text-red-900 rounded-xl font-black text-lg transition-colors border border-red-200"></button>
<button onClick={() => removeKey(k)} className="w-full rounded-xl border border-red-200 bg-red-100 p-4 text-left text-lg font-black text-red-700 transition-colors hover:bg-red-200 hover:text-red-900 md:w-auto md:text-center"></button>
</div>
))}
</div>
@ -234,7 +236,79 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any
))}
</div>
<div className="overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
<div className="grid gap-6 lg:grid-cols-2">
<section className="rounded-[1.75rem] border-2 border-blue-100 bg-blue-50/60 p-5 shadow-sm">
<p className="text-xs font-black uppercase tracking-widest text-blue-900">Herrer</p>
<div className="mt-4 space-y-3">
{activeKeys.map(k => (
<div key={k} className="rounded-2xl bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">{k}</p>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<input placeholder="Eks: Gul" className="w-full rounded-xl border border-blue-200 bg-white p-3 text-sm font-bold text-black outline-none focus:border-blue-500" value={tees.herrer[k]?.navn_utslag || ''} onChange={e => updateTee('herrer', k, 'navn_utslag', e.target.value)} />
<input placeholder="CR" className="w-full rounded-xl border border-blue-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-blue-500" value={tees.herrer[k]?.baneverdi || ''} onChange={e => updateTee('herrer', k, 'baneverdi', e.target.value)} />
<input placeholder="Slope" className="w-full rounded-xl border border-blue-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-blue-500" value={tees.herrer[k]?.slopeverdi || ''} onChange={e => updateTee('herrer', k, 'slopeverdi', e.target.value)} />
</div>
</div>
))}
</div>
</section>
<section className="rounded-[1.75rem] border-2 border-red-100 bg-red-50/60 p-5 shadow-sm">
<p className="text-xs font-black uppercase tracking-widest text-red-900">Damer</p>
<div className="mt-4 space-y-3">
{activeKeys.map(k => (
<div key={k} className="rounded-2xl bg-white p-4 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">{k}</p>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<input placeholder="Eks: Rod" className="w-full rounded-xl border border-red-200 bg-white p-3 text-sm font-bold text-black outline-none focus:border-red-500" value={tees.damer[k]?.navn_utslag_damer || ''} onChange={e => updateTee('damer', k, 'navn_utslag_damer', e.target.value)} />
<input placeholder="CR" className="w-full rounded-xl border border-red-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-red-500" value={tees.damer[k]?.baneverdi_damer || ''} onChange={e => updateTee('damer', k, 'baneverdi_damer', e.target.value)} />
<input placeholder="Slope" className="w-full rounded-xl border border-red-200 bg-white p-3 text-sm text-center text-black outline-none focus:border-red-500" value={tees.damer[k]?.slopeverdi_damer || ''} onChange={e => updateTee('damer', k, 'slopeverdi_damer', e.target.value)} />
</div>
</div>
))}
</div>
</section>
</div>
<section className="rounded-[1.75rem] border-2 border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-black uppercase tracking-widest text-gray-400">Hull for hull</p>
<p className="mt-1 text-sm text-gray-500">Hvert hull er et eget kort med par, hcp og lengder per aktiv utslagskolonne.</p>
</div>
<span className="rounded-xl bg-[#f1f7ed] px-3 py-2 text-[10px] font-black uppercase tracking-widest text-[#11280f]">{holes.length} hull</span>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
{holes.map((h, idx) => (
<div key={idx} className="rounded-[1.5rem] border border-gray-200 bg-[#fbfdf8] p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<p className="text-lg font-black text-[#11280f]">Hull {h.hole_number}</p>
<span className="rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-400 shadow-sm">{activeKeys.length} utslag</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<label className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">Par</label>
<input type="number" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-bold text-black outline-none focus:border-[#8bc34a]" value={h.par || ''} onChange={e => updateHole(idx, 'par', e.target.value)} />
</div>
<div>
<label className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">HCP</label>
<input type="number" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-bold text-black outline-none focus:border-[#8bc34a]" value={h.hcp_index || ''} onChange={e => updateHole(idx, 'hcp_index', e.target.value)} />
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{activeKeys.map(k => (
<div key={k} className="rounded-2xl bg-white p-3 shadow-sm">
<label className="text-[10px] font-black uppercase tracking-[0.18em] text-gray-400">{k}</label>
<input type="number" placeholder="Lengde" className="mt-2 w-full rounded-xl border-2 border-gray-200 bg-white p-3 text-center font-mono font-bold text-black outline-none focus:border-[#8bc34a]" value={h.lengths?.[k] || ''} onChange={e => updateHole(idx, 'lengths', e.target.value, k)} />
</div>
))}
</div>
</div>
))}
</div>
</section>
<div className="hidden overflow-x-auto rounded-2xl border-2 border-gray-300 shadow-sm bg-white pb-2">
<table className="w-full text-center text-sm min-w-[800px] border-collapse">
<thead>
<tr className="bg-gray-100 text-gray-700 text-xs font-black uppercase tracking-widest border-b-2 border-gray-300">
@ -295,9 +369,9 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any
</table>
</div>
<div className="flex gap-4 px-2">
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-2 border-2 border-[#8bc34a] rounded-xl">+ Legg til hull</button>
<button onClick={removeLastHole} className="text-sm font-black text-red-500 hover:text-red-700 px-4 py-2 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
<div className="flex flex-col gap-4 px-2 sm:flex-row">
<button onClick={addHole} className="text-sm font-black text-[#8bc34a] hover:text-[#11280f] px-4 py-3 border-2 border-[#8bc34a] rounded-xl">+ Legg til hull</button>
<button onClick={removeLastHole} className="text-sm font-black text-red-500 hover:text-red-700 px-4 py-3 border-2 border-red-500 rounded-xl">- Slett siste hull</button>
</div>
</div>
);
@ -326,7 +400,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/admin/facilities/${initialData.id}/full`, {
const res = await adminFetch(`/api/admin/facilities/${initialData.id}/full`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
@ -363,6 +437,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
return (
<div className="max-w-[1400px] mx-auto p-4 md:p-8 relative z-40 bg-white min-h-screen">
<AdminMobileMenu />
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 pb-6 border-b border-gray-200 gap-6">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
@ -634,4 +709,4 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
</div>
</div>
);
}
}

View file

@ -1,6 +1,8 @@
"use client";
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
import AdminMobileMenu from "@/components/AdminMobileMenu";
import Link from 'next/link';
export default function VtgWasher() {
@ -11,7 +13,7 @@ export default function VtgWasher() {
const fetchDrafts = () => {
setLoading(true);
fetch(`${API_URL}/admin/vtg/drafts`)
adminFetch(`${API_URL}/admin/vtg/drafts`)
.then(res => res.json())
.then(data => {
const editableDrafts = data.map((f: any) => {
@ -94,7 +96,7 @@ export default function VtgWasher() {
setSaving(true);
try {
const res = await fetch(`${API_URL}/admin/vtg/approve-bulk`, {
const res = await adminFetch(`${API_URL}/admin/vtg/approve-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvals: toApprove })
@ -117,13 +119,14 @@ export default function VtgWasher() {
return (
<div className="min-h-screen bg-[#f1f7ed] p-8 text-[#11280f]">
<div className="max-w-[1400px] mx-auto">
<div className="flex justify-between items-end mb-10 border-b border-gray-200 pb-6">
<AdminMobileMenu />
<div className="mb-10 flex flex-col gap-5 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
<div>
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black">VTG-Vaskeriet</h1>
<p className="text-sm text-gray-600 mt-2"> gjennom og godkjenn kursinformasjon for Veien til Golf.</p>
</div>
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="bg-[#8bc34a] text-white px-8 py-4 rounded-xl font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
<button onClick={handleApprove} disabled={saving || selectedIds.length === 0} className="w-full rounded-xl bg-[#8bc34a] px-8 py-4 font-black uppercase tracking-widest text-white shadow-lg transition-all hover:scale-105 disabled:opacity-50 md:w-auto">
{saving ? 'Lagrer...' : `Godkjenn Valgte (${selectedIds.length})`}
</button>
</div>
@ -140,14 +143,14 @@ export default function VtgWasher() {
<span className="font-black uppercase tracking-widest text-xs text-gray-500">Velg Alle</span>
</div>
{drafts.map(draft => (
<div key={draft.id} className={`bg-white p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/5' : 'border-transparent'}`}>
{drafts.map((draft, index) => (
<div key={draft.id} className={`p-6 rounded-3xl shadow-sm border-2 transition-all ${selectedIds.includes(draft.id) ? 'border-[#8bc34a] bg-[#8bc34a]/10 ring-2 ring-[#8bc34a]/20' : index % 2 === 0 ? 'border-[#e3edd7] bg-white' : 'border-[#dbe7f5] bg-[#f8fbff]'}`}>
<div className="flex gap-6 items-start">
<div className="pt-2"><input type="checkbox" className="w-6 h-6 accent-[#8bc34a] cursor-pointer" checked={selectedIds.includes(draft.id)} onChange={() => toggleOne(draft.id)} /></div>
<div className="flex-grow space-y-4">
<div className="flex justify-between items-center border-b pb-4">
<div className="flex flex-col gap-3 border-b pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-2xl font-black">{draft.name} <span className="text-xs font-mono font-bold bg-gray-100 text-gray-400 px-2 py-1 rounded-md">ID: {draft.id}</span></h3>
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="text-xs font-bold text-blue-600 hover:underline bg-blue-50 px-4 py-2 rounded-lg">Sjekk Nettside </a>
<a href={draft.vtg_lenke?.split(',')[0]} target="_blank" className="inline-flex w-full items-center justify-center rounded-lg bg-blue-50 px-4 py-3 text-xs font-bold text-blue-600 hover:underline md:w-auto">Sjekk Nettside </a>
</div>
{draft.vtg_draft?.ai_begrunnelse && (
@ -178,15 +181,15 @@ export default function VtgWasher() {
<div className="p-4 bg-gray-50 rounded-xl text-sm text-gray-500 italic">Fant ingen spesifikke kursdatoer.</div>
) : (
draft.edit_datoer.map((row: any, idx: number) => (
<div key={idx} className="flex gap-2 items-center bg-white border border-gray-200 p-2 rounded-lg relative group">
<input className="flex-grow p-2 rounded border border-gray-100 text-xs font-bold focus:border-[#8bc34a] outline-none" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
<select className="w-32 p-2 rounded border border-gray-100 text-xs focus:border-[#8bc34a] outline-none bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
<div key={idx} className="grid gap-2 rounded-lg border border-gray-200 bg-white p-3 relative group sm:grid-cols-[minmax(0,1fr)_150px_auto] sm:items-center">
<input className="w-full rounded border border-gray-100 p-2 text-xs font-bold outline-none focus:border-[#8bc34a]" value={row.dato} onChange={e => updateDateRow(draft.id, idx, 'dato', e.target.value)} placeholder="F.eks: 12.-14. mai" />
<select className="w-full rounded border border-gray-100 p-2 text-xs outline-none focus:border-[#8bc34a] bg-white" value={row.status} onChange={e => updateDateRow(draft.id, idx, 'status', e.target.value)}>
<option value="Ledig">Ledig</option>
<option value="Fulltegnet">Fulltegnet</option>
<option value="Venteliste">Venteliste</option>
<option value="Få plasser"> plasser</option>
</select>
<button onClick={() => removeDateRow(draft.id, idx)} className="text-red-400 hover:text-red-600 px-2 opacity-0 group-hover:opacity-100 transition-opacity" title="Slett dato"></button>
<button onClick={() => removeDateRow(draft.id, idx)} className="px-2 text-left text-red-400 transition-colors hover:text-red-600 sm:text-center sm:opacity-0 sm:group-hover:opacity-100" title="Slett dato"></button>
</div>
))
)}
@ -205,4 +208,4 @@ export default function VtgWasher() {
</div>
</div>
);
}
}

View file

@ -0,0 +1,141 @@
"use client";
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { API_URL } from "@/config/constants";
type AdminMobileMenuProps = {
onOpenTwoFactor?: () => void;
};
const NAV_ITEMS = [
{ href: '/admin', label: 'Kontrollpanel', match: (pathname: string) => pathname === '/admin' },
{ href: '/admin/medlemskap', label: 'Medlemskap', match: (pathname: string) => pathname.startsWith('/admin/medlemskap') },
{ href: '/admin/greenfee', label: 'Greenfee', match: (pathname: string) => pathname.startsWith('/admin/greenfee') },
{ href: '/admin/vtg', label: 'VTG', match: (pathname: string) => pathname.startsWith('/admin/vtg') },
];
export default function AdminMobileMenu({ onOpenTwoFactor }: AdminMobileMenuProps) {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
const handleLogout = async () => {
try {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} finally {
window.location.href = '/';
}
};
return (
<>
<div className="mb-6 flex md:hidden">
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center gap-3 rounded-2xl bg-[#11280f] px-5 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-white shadow-lg"
>
<span className="text-lg leading-none"></span>
Adminmeny
</button>
</div>
{isOpen && (
<div
className="fixed inset-0 z-50 bg-black/50 md:hidden"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setIsOpen(false);
}
}}
>
<div className="h-full w-[88%] max-w-[22rem] bg-[#11280f] p-6 text-white shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 pb-5">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Adminmeny</p>
<h3 className="mt-2 text-2xl font-black tracking-tight">TeeOff Admin</h3>
</div>
<button
onClick={() => setIsOpen(false)}
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-white/10 text-xl font-black text-white hover:bg-white/20"
aria-label="Lukk adminmeny"
>
×
</button>
</div>
<nav className="mt-6 space-y-6 text-[11px] font-black uppercase tracking-[0.2em] text-[#7ca982]">
<div className="space-y-2">
{NAV_ITEMS.map((item) => {
const isActive = item.match(pathname);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setIsOpen(false)}
className={`block rounded-2xl px-4 py-4 transition-colors ${
isActive
? 'border border-[#8bc34a]/30 bg-white/5 text-white'
: 'hover:bg-white/5 hover:text-white'
}`}
>
{item.label}
</Link>
);
})}
</div>
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
{onOpenTwoFactor ? (
<button
onClick={() => {
setIsOpen(false);
onOpenTwoFactor();
}}
className="block w-full rounded-2xl px-4 py-3 text-left hover:bg-white/5 hover:text-white"
>
2FA / 1Password
</button>
) : (
<Link
href="/admin"
onClick={() => setIsOpen(false)}
className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white"
>
2FA / 1Password
</Link>
)}
</div>
</nav>
<div className="mt-8 border-t border-white/10 pt-6">
<button
onClick={handleLogout}
className="w-full rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-4 text-left text-[11px] font-black uppercase tracking-[0.2em] text-red-300 hover:bg-red-500/20"
>
Logg ut
</button>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -20,7 +20,6 @@ export default function Header() {
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
</nav>
{/* HAMBURGER (Mobil) */}
@ -37,9 +36,8 @@ export default function Header() {
<Link onClick={() => setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem</Link>
<Link onClick={() => setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane</Link>
<Link onClick={() => setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap</Link>
<Link onClick={() => setIsOpen(false)} href="/logg-inn" className="text-[#ff5722] font-black uppercase">Admin Logg inn</Link>
</div>
)}
</header>
);
}
}

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from 'react';
import { adminFetch } from "@/config/adminFetch";
// Tilpass interface til de dataene du allerede har i frontend
interface Facility {
@ -23,7 +24,7 @@ export default function ScrapeMethodSelect({ facility }: { facility: Facility })
try {
// Husk å endre URL-en hvis API-et ditt ligger på et annet domene
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, {
const response = await adminFetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
@ -68,4 +69,4 @@ export default function ScrapeMethodSelect({ facility }: { facility: Facility })
<option value="">Ingen (Avslått)</option>
</select>
);
}
}

View file

@ -0,0 +1,10 @@
export async function adminFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const response = await fetch(input, init);
if (response.status === 401 && typeof window !== 'undefined') {
window.location.href = '/admin/login';
throw new Error('ADMIN_UNAUTHORIZED');
}
return response;
}