Codex ferdig med ymse frontend
This commit is contained in:
parent
eb0f7d2907
commit
e5b76a7477
13 changed files with 1025 additions and 96 deletions
3
.gitignore
vendored
Executable file
3
.gitignore
vendored
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
Binary file not shown.
119
backend/main.py
119
backend/main.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ python-dotenv
|
|||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
pyotp
|
||||
qrcode
|
||||
google-genai
|
||||
google-generativeai
|
||||
google-generativeai
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 på 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, så 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">Gå til Vaskeri</Link>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">Gå 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">Få 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
141
frontend/src/components/AdminMobileMenu.tsx
Executable file
141
frontend/src/components/AdminMobileMenu.tsx
Executable 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
frontend/src/config/adminFetch.ts
Executable file
10
frontend/src/config/adminFetch.ts
Executable 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;
|
||||
}
|
||||
Loading…
Reference in a new issue