Før sammentrukne bokser i admin

This commit is contained in:
Erol Haagenrud 2026-04-21 07:21:46 +02:00
parent 3d54313394
commit e2bcd7e21b
10 changed files with 530 additions and 99 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

BIN
2026-04-20_214654.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
2026-04-20_214712.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -569,6 +569,10 @@ class QuickEditRequest(BaseModel):
field: str
value: str
class FacilityVisibilityRequest(BaseModel):
is_published: bool
class GreenfeeApproval(BaseModel):
facility_id: int
greenfee: List[dict]
@ -893,6 +897,24 @@ def parse_optional_datetime(value: str | None) -> datetime | None:
raise HTTPException(status_code=400, detail=f"Ugyldig datoformat: {value}") from exc
def parse_optional_int(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
trimmed = str(value).strip()
if not trimmed:
return None
try:
return int(float(trimmed.replace(",", ".")))
except ValueError:
return None
def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
@ -1318,6 +1340,7 @@ async def ensure_facility_columns(conn):
"""Legger til nye facility-kolonner ved behov."""
await conn.execute("""
ALTER TABLE facilities
ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS footnote_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS golfamore_url TEXT,
ADD COLUMN IF NOT EXISTS golfpakker_url TEXT,
@ -2114,6 +2137,7 @@ async def get_facilities():
) w_data
) as weather_forecast
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
""")
return [format_row(row) for row in rows]
@ -2154,7 +2178,91 @@ async def get_facility(slug: str):
ORDER BY day_offset ASC
) w_data
) as weather_forecast
FROM facilities f WHERE f.slug = $1
FROM facilities f
WHERE f.slug = $1
AND COALESCE(f.is_published, TRUE) = TRUE
""", slug)
if not row:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
return format_row(row)
@app.get("/api/admin/facilities")
async def get_admin_facilities():
"""Henter alle golfanlegg for admin, også upubliserte."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
SELECT jsonb_agg(cs) FROM (
SELECT id, name, status FROM courses
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses, (
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
forecast_date,
day_offset,
dry_all_day,
dry_daylight,
precip_mm,
precip_probability_max,
daylight_precip_mm,
daylight_precip_probability_max,
confidence,
source_updated_at,
source_expires_at,
calculated_at
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
FROM facilities f
ORDER BY f.name ASC
""")
return [format_row(row) for row in rows]
@app.get("/api/admin/facilities/{slug}")
async def get_admin_facility(slug: str):
"""Henter full anleggsdetalj for admin, også når anlegget er upublisert."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT f.*, (
SELECT jsonb_agg(c_data) FROM (
SELECT c.*, (
SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC)
FROM (SELECT * FROM holes WHERE course_id = c.id) h_data
) as holes
FROM courses c
WHERE c.facility_id = f.id
ORDER BY c.is_main_course DESC, c.id ASC
) c_data
) as courses, (
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
forecast_date,
day_offset,
dry_all_day,
dry_daylight,
precip_mm,
precip_probability_max,
daylight_precip_mm,
daylight_precip_probability_max,
confidence,
source_updated_at,
source_expires_at,
calculated_at
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
FROM facilities f
WHERE f.slug = $1
""", slug)
if not row:
@ -2608,7 +2716,7 @@ async def update_facility_full(facility_id: int, request: Request):
'guest_requirements', 'scrape_method', 'scrape_status_url',
'social_links', 'footnote', 'cooperating_clubs', 'membership_draft', 'membership_updated_at',
'greenfee_url', 'golfpakker_url', 'greenfee_draft', 'greenfee_updated_at', 'scrape_status_selector',
'vtg_updated_at', 'vtg_draft', 'footnote_updated_at',
'vtg_updated_at', 'vtg_draft', 'footnote_updated_at', 'is_published',
'golfpakker_draft', 'golfpakker_updated_at'
]
@ -2682,49 +2790,116 @@ async def update_facility_full(facility_id: int, request: Request):
await conn.execute(query, *values)
# 2. OPPDATER BANER (COURSES) OG HULL (HOLES)
courses = data.get('courses') or []
for course in courses:
if not course:
continue
course_id = course.get('id')
if course_id:
# Rens datoformat for PostgreSQL (håndterer Next.js date input)
if 'courses' in data:
submitted_courses = [course for course in (data.get('courses') or []) if course]
normalized_courses: list[dict[str, Any]] = []
for index, course in enumerate(submitted_courses):
normalized_course = dict(course)
normalized_course['is_main_course'] = bool(course.get('is_main_course'))
normalized_courses.append(normalized_course)
if normalized_courses:
if not any(course['is_main_course'] for course in normalized_courses):
normalized_courses[0]['is_main_course'] = True
else:
main_assigned = False
for course in normalized_courses:
if course['is_main_course'] and not main_assigned:
main_assigned = True
else:
course['is_main_course'] = False
retained_course_ids: list[int] = []
for course in normalized_courses:
course_id = course.get('id')
holes = [hole for hole in (course.get('holes') or []) if hole]
hole_count = len(holes) or None
course_par = parse_optional_int(course.get('par'))
course_length_meters = parse_optional_int(course.get('length_meters'))
valid_until_str = course.get('slope_valid_until')
if valid_until_str == "" or valid_until_str is None:
valid_until = None
else:
# Gjør om strengen til et ekte date-objekt for asyncpg
try:
date_part = valid_until_str.split('T')[0]
date_part = str(valid_until_str).split('T')[0]
valid_until = datetime.strptime(date_part, "%Y-%m-%d").date()
except ValueError:
valid_until = None
await conn.execute("""
UPDATE courses
SET name=$1, par=$2, length_meters=$3, architect=$4,
status=$5, is_main_course=$6, tee_boxes=$7::jsonb,
slope_valid_until=$8
WHERE id=$9 AND facility_id=$10
""",
course.get('name'), course.get('par'), course.get('length_meters'),
course.get('architect'), course.get('status'), course.get('is_main_course'),
json.dumps(course.get('tee_boxes') or {}), valid_until, course_id, facility_id)
tee_boxes_json = json.dumps(course.get('tee_boxes') or {})
# 3. OPPDATER HULL PÅ BANEN (HOLES)
holes = course.get('holes') or []
if course_id:
await conn.execute("""
UPDATE courses
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5,
status=$6, is_main_course=$7, tee_boxes=$8::jsonb,
slope_valid_until=$9
WHERE id=$10 AND facility_id=$11
""",
course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until, course_id, facility_id)
else:
course_id = await conn.fetchval("""
INSERT INTO courses (
facility_id, name, holes, par, length_meters, architect,
status, is_main_course, tee_boxes, slope_valid_until
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING id
""",
facility_id, course.get('name'), hole_count, course_par, course_length_meters,
course.get('architect'), course.get('status'), course.get('is_main_course'),
tee_boxes_json, valid_until)
retained_course_ids.append(int(course_id))
retained_hole_ids: list[int] = []
for hole in holes:
if not hole:
continue
hole_id = hole.get('id')
hole_number = parse_optional_int(hole.get('hole_number'))
hole_par = parse_optional_int(hole.get('par'))
hole_hcp_index = parse_optional_int(hole.get('hcp_index'))
lengths_json = json.dumps(hole.get('lengths') or {})
if hole_id:
await conn.execute("""
UPDATE holes
SET par=$1, hcp_index=$2, lengths=$3::jsonb
WHERE id=$4 AND course_id=$5
SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb
WHERE id=$5 AND course_id=$6
""",
hole.get('par'), hole.get('hcp_index'),
json.dumps(hole.get('lengths') or {}), hole_id, course_id)
hole_number, hole_par, hole_hcp_index,
lengths_json, hole_id, course_id)
else:
hole_id = await conn.fetchval("""
INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id
""",
course_id, hole_number, hole_par, hole_hcp_index,
lengths_json)
retained_hole_ids.append(int(hole_id))
if retained_hole_ids:
await conn.execute(
"DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))",
course_id,
retained_hole_ids,
)
else:
await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id)
if retained_course_ids:
await conn.execute(
"DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))",
facility_id,
retained_course_ids,
)
else:
await conn.execute("DELETE FROM courses WHERE facility_id = $1", facility_id)
extra_paths = ["/golfbaner"]
if changed_field_names & membership_fields:
@ -2737,6 +2912,30 @@ async def update_facility_full(facility_id: int, request: Request):
)
return {"status": "success", "message": "Anlegg, baner og scorekort ble oppdatert."}
@app.delete("/api/admin/facilities/{facility_id}")
async def delete_facility(facility_id: int):
"""Sletter et anlegg permanent med tilhørende baner og hull."""
async with app.state.pool.acquire() as conn:
deleted = await conn.fetchrow(
"DELETE FROM facilities WHERE id = $1 RETURNING slug, name",
facility_id,
)
if not deleted:
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
deleted_slug = str(deleted["slug"] or "").strip()
schedule_indexnow_submission(
collect_facility_indexnow_urls([deleted_slug], extra_paths=["/golfbaner", "/medlemskap", "/vtg"]),
reason="facility delete",
)
return {
"status": "success",
"message": f"{deleted['name']} ble slettet.",
"slug": deleted_slug,
}
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
@app.get("/api/admin/scrape-jobs")
async def get_scrape_jobs(job_type: Optional[str] = Query(default=None), limit: int = Query(default=10, ge=1, le=50)):

View file

@ -191,7 +191,7 @@ export default function AdminArticlesPage() {
};
const loadFacilities = async () => {
const response = await fetch(`${API_URL}/facilities`, { credentials: "include" });
const response = await adminFetch(`${API_URL}/admin/facilities`, { credentials: "include" });
const data = await response.json();
const mapped = Array.isArray(data)
? data

View file

@ -336,7 +336,7 @@ export default function AdminDashboard() {
const [dismissedLatestJobKeys, setDismissedLatestJobKeys] = useState<Partial<Record<AdminTab, string>>>({});
const fetchFacilities = () => {
fetch(`${API_URL}/facilities`)
adminFetch(`${API_URL}/admin/facilities`)
.then(res => res.json())
.then(data => {
setFacilities(Array.isArray(data) ? data : []);
@ -1813,6 +1813,11 @@ export default function AdminDashboard() {
<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>
{f.is_published === false && (
<span className="rounded-xl bg-amber-100 px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-amber-800">
Skjult
</span>
)}
{isHighlighted && (
<span className="rounded-xl bg-[#8bc34a] px-3 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-white">
Trenger oppmerksomhet
@ -2163,7 +2168,14 @@ export default function AdminDashboard() {
<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="flex items-center gap-2">
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
{f.is_published === false && (
<span className="rounded-lg bg-amber-100 px-2 py-1 text-[10px] font-black uppercase tracking-widest text-amber-800">
Skjult
</span>
)}
</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>

View file

@ -157,17 +157,21 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any
const herrer = course.tee_boxes?.herrer || [];
const damer = course.tee_boxes?.damer || [];
const initialTees = { herrer: {} as any, damer: {} as any };
activeKeys.forEach((key, idx) => {
initialTees.herrer[key] = herrer[idx] || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
initialTees.damer[key] = damer[idx] || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
const herrerAreCompact = herrer.length > 0 && herrer.length < ALL_KEYS.length;
const damerAreCompact = damer.length > 0 && damer.length < ALL_KEYS.length;
ALL_KEYS.forEach((key, idx) => {
const activeIdx = activeKeys.indexOf(key);
initialTees.herrer[key] = (herrerAreCompact && activeIdx >= 0 ? herrer[activeIdx] : herrer[idx]) || { navn_utslag: '', baneverdi: '', slopeverdi: '' };
initialTees.damer[key] = (damerAreCompact && activeIdx >= 0 ? damer[activeIdx] : damer[idx]) || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' };
});
return initialTees;
});
const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => {
const updatedTeeBoxes = {
herrer: newKeys.map(k => newTees.herrer[k] || {}),
damer: newKeys.map(k => newTees.damer[k] || {})
herrer: ALL_KEYS.map(k => newTees.herrer[k] || {}),
damer: ALL_KEYS.map(k => newTees.damer[k] || {})
};
onChange({
...course,
@ -401,9 +405,14 @@ const getMediaFieldLabel = (field: string) => {
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
const router = useRouter();
const [formData, setFormData] = useState(initialData);
const [formData, setFormData] = useState({
...initialData,
is_published: initialData?.is_published !== false,
courses: Array.isArray(initialData?.courses) ? initialData.courses : [],
});
const [activeTab, setActiveTab] = useState('generelt');
const [saving, setSaving] = useState(false);
const [deletingFacility, setDeletingFacility] = useState(false);
const [mediaFeedback, setMediaFeedback] = useState("");
const [uploadingTarget, setUploadingTarget] = useState<string | null>(null);
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
@ -423,6 +432,60 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
setFormData((prev: any) => ({ ...prev, [field]: value }));
};
const updateCourses = (updater: (courses: any[]) => any[]) => {
const nextCourses = updater(Array.isArray(formData.courses) ? formData.courses : []);
handleChange('courses', nextCourses);
};
const createEmptyCourse = () => {
const existingCourses = Array.isArray(formData.courses) ? formData.courses : [];
return {
_clientId: `course-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: `Ny bane ${existingCourses.length + 1}`,
status: 'ukjent',
par: '',
length_meters: '',
architect: '',
is_main_course: existingCourses.length === 0,
slope_valid_until: '',
tee_boxes: { herrer: [], damer: [] },
holes: Array.from({ length: 18 }, (_, index) => ({
hole_number: index + 1,
par: '',
hcp_index: '',
lengths: {},
})),
};
};
const handleAddCourse = () => {
updateCourses((courses) => [...courses, createEmptyCourse()]);
};
const handleRemoveCourse = (index: number) => {
const courses = Array.isArray(formData.courses) ? formData.courses : [];
const course = courses[index];
const confirmed = window.confirm(`Slette banen "${course?.name || 'uten navn'}"?`);
if (!confirmed) return;
updateCourses((currentCourses) => {
const nextCourses = currentCourses.filter((_, courseIndex) => courseIndex !== index);
if (nextCourses.length > 0 && !nextCourses.some((entry) => entry?.is_main_course)) {
nextCourses[0] = { ...nextCourses[0], is_main_course: true };
}
return nextCourses;
});
};
const handleSetMainCourse = (index: number) => {
updateCourses((courses) =>
courses.map((course, courseIndex) => ({
...course,
is_main_course: courseIndex === index,
}))
);
};
const galleryImages = normalizeStringList(formData.gallery);
const setGalleryImages = (images: string[]) => {
@ -536,6 +599,30 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
setSaving(false);
};
const handleDeleteFacility = async () => {
const confirmed = window.confirm(`Slette anlegget "${initialData.name}" permanent? Dette fjerner også baner og hull.`);
if (!confirmed) return;
setDeletingFacility(true);
try {
const response = await adminFetch(`/api/admin/facilities/${initialData.id}`, {
method: 'DELETE',
});
if (!response.ok) {
alert("Noe gikk galt under sletting.");
return;
}
router.push('/admin');
router.refresh();
} catch {
alert("Nettverksfeil under sletting.");
} finally {
setDeletingFacility(false);
}
};
const tabs = [
{ id: 'generelt', label: 'Generelt' },
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
@ -561,24 +648,43 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<Link href="/admin" className="text-sm font-bold text-gray-500 hover:text-[#8bc34a] mb-2 block"> Tilbake til oversikten</Link>
<h1 className="text-4xl font-black text-[#11280f]">
Rediger:{" "}
<Link
href={`/golfbaner/${initialData.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-[#8bc34a]"
title="Åpne anleggssiden i ny fane"
>
{initialData.name}
</Link>
{formData.is_published ? (
<Link
href={`/golfbaner/${initialData.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-[#8bc34a]"
title="Åpne anleggssiden i ny fane"
>
{initialData.name}
</Link>
) : (
<span className="text-[#8bc34a]">{initialData.name}</span>
)}
</h1>
<p className="mt-3 flex flex-wrap items-center gap-3 text-xs font-black uppercase tracking-widest">
<span className={`rounded-xl px-3 py-2 ${formData.is_published ? 'bg-[#8bc34a] text-white' : 'bg-amber-100 text-amber-800'}`}>
{formData.is_published ? 'Publisert' : 'Skjult fra offentligheten'}
</span>
<span className="rounded-xl bg-gray-100 px-3 py-2 text-gray-500">Slug: {initialData.slug}</span>
</p>
</div>
<div className="flex w-full flex-col gap-3 md:w-auto md:flex-row">
<button
onClick={handleDeleteFacility}
disabled={deletingFacility}
className="btn btn-lg btn-danger w-full md:w-auto disabled:opacity-50"
>
{deletingFacility ? "Sletter..." : "Slett anlegg"}
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre endringer"}
</button>
</div>
<button
onClick={handleSave}
disabled={saving}
className="btn btn-lg btn-primary w-full md:w-auto disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre endringer"}
</button>
</div>
<div className="flex flex-col md:flex-row gap-10">
@ -599,6 +705,24 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="w-full md:w-3/4">
{activeTab === 'generelt' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="col-span-1 md:col-span-2 mb-8 rounded-[2rem] border border-gray-200 bg-gray-50 p-6 shadow-sm">
<p className="text-xs font-black uppercase tracking-widest text-gray-500">Publisering</p>
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-lg font-black text-[#11280f]">{formData.is_published ? 'Anlegget er publisert' : 'Anlegget er skjult'}</p>
<p className="mt-1 text-sm text-gray-500">Skjulte anlegg forsvinner fra offentlige lister og anleggssiden, men forblir tilgjengelige i admin.</p>
</div>
<label className="inline-flex items-center gap-3 rounded-2xl bg-white px-4 py-3 shadow-sm">
<input
type="checkbox"
checked={Boolean(formData.is_published)}
onChange={(e) => handleChange('is_published', e.target.checked)}
className="h-5 w-5 accent-[#8bc34a]"
/>
<span className="text-sm font-black uppercase tracking-widest text-[#11280f]">Publisert</span>
</label>
</div>
</div>
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Anleggsnavn</label>
<input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('name', 'text')} onChange={e => handleChange('name', e.target.value)} />
@ -1008,35 +1132,60 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
{activeTab === 'baner' && (
<div className="flex flex-col gap-8">
<div className="bg-[#f1f7ed] p-6 rounded-2xl border-2 border-[#7ca982] mb-4">
<h3 className="font-black text-[#11280f] text-lg uppercase tracking-widest mb-2">Baner og Scorekort</h3>
<p className="text-sm text-gray-800 font-medium">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag.</p>
<div className="flex flex-col gap-4 rounded-2xl border-2 border-[#7ca982] bg-[#f1f7ed] p-6">
<div>
<h3 className="mb-2 text-lg font-black uppercase tracking-widest text-[#11280f]">Baner og Scorekort</h3>
<p className="text-sm font-medium text-gray-800">Bruk det interaktive skjemaet under for å redigere lengder, par og utslag. Nye baner lagres sammen med anlegget og blir behandlet som egne baner i detaljvisningen.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="rounded-xl bg-white px-4 py-3 text-xs font-black uppercase tracking-widest text-gray-500 shadow-sm">{formData.courses?.length || 0} baner</span>
<button onClick={handleAddCourse} className="btn btn-md btn-secondary w-full sm:w-auto">+ Legg til bane</button>
</div>
</div>
{formData.courses?.map((course: any, cIdx: number) => (
<div key={course.id || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
<div key={course.id || course._clientId || cIdx} className="bg-gray-100 p-8 rounded-[2rem] border-2 border-gray-200 shadow-sm mb-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4 border-b-2 border-gray-200 pb-4">
<h4 className="text-2xl font-black text-black">{course.name}</h4>
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
</span>
<div className="flex flex-col gap-3">
<h4 className="text-2xl font-black text-black">{course.name}</h4>
<div className="flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-3 rounded-xl bg-white px-4 py-2 shadow-sm">
<input
type="radio"
name="main-course"
checked={Boolean(course.is_main_course)}
onChange={() => handleSetMainCourse(cIdx)}
className="h-4 w-4 accent-[#8bc34a]"
/>
<span className="text-xs font-black uppercase tracking-widest text-[#11280f]">Hovedbane</span>
</label>
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
</span>
</div>
</div>
<button onClick={() => handleRemoveCourse(cIdx)} className="btn btn-md btn-danger w-full md:w-auto">Slett bane</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Banenavn</label>
<input className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.name || ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, name: e.target.value};
handleChange('courses', newCourses);
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = {...course, name: e.target.value};
return nextCourses;
});
}} />
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Status</label>
<select className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.status || "ukjent"} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, status: e.target.value};
handleChange('courses', newCourses);
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = {...course, status: e.target.value};
return nextCourses;
});
}}>
<option value="aapen">🟢 Åpen</option>
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
@ -1049,17 +1198,21 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Total Par (Bane)</label>
<input type="number" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.par || ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, par: Number(e.target.value)};
handleChange('courses', newCourses);
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = {...course, par: Number(e.target.value)};
return nextCourses;
});
}} />
</div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {
const newCourses = [...formData.courses];
newCourses[cIdx] = {...course, slope_valid_until: e.target.value};
handleChange('courses', newCourses);
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = {...course, slope_valid_until: e.target.value};
return nextCourses;
});
}} />
</div>
</div>
@ -1068,9 +1221,11 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<ScorecardBuilder
course={course}
onChange={(updatedCourse) => {
const newCourses = [...formData.courses];
newCourses[cIdx] = updatedCourse;
handleChange('courses', newCourses);
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = updatedCourse;
return nextCourses;
});
}}
/>
</div>

View file

@ -1,15 +1,23 @@
import { cookies } from "next/headers";
import { API_URL } from "@/config/constants";
import EditFacilityClient from "./EditFacilityClient";
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const cookieHeader = (await cookies()).toString();
// Henter anlegget vi skal redigere
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
const res = await fetch(`${API_URL}/admin/facilities/${slug}`, {
cache: 'no-store',
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
});
const facility = await res.json();
// Henter ALLE anlegg slik at vi kan bygge lister for samarbeid og arkitekter
const allRes = await fetch(`${API_URL}/facilities`, { cache: 'no-store' });
const allRes = await fetch(`${API_URL}/admin/facilities`, {
cache: 'no-store',
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
});
const allFacilities = await allRes.json();
if (!facility || facility.error) {

View file

@ -2,6 +2,8 @@
import { useState } from 'react';
import { STATUS_MAP } from "@/config/constants";
type Gender = 'herrer' | 'damer';
// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene
const getTeeTheme = (label: string) => {
const name = label.toLowerCase();
@ -30,7 +32,7 @@ const getTeeTheme = (label: string) => {
export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) {
const [hcp, setHcp] = useState("15.0");
const [gender, setGender] = useState<'herrer' | 'damer'>('herrer');
const [gender, setGender] = useState<Gender>('herrer');
const [selectedTeeIndex, setSelectedTeeIndex] = useState(0);
const allHoles = course.holes || [];
@ -39,18 +41,44 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
const hasInHoles = holesIn.length > 0;
const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'];
const activeLengthKeys = lengthKeys.filter((key) => allHoles.some((h: any) => h.lengths?.[key]));
const availableTees = course.tee_boxes?.[gender] || [];
const fallbackTees = course.tee_boxes?.[gender === 'damer' ? 'herrer' : 'damer'] || [];
const activeColumns = lengthKeys
.filter(k => allHoles.some((h: any) => h.lengths?.[k]))
.map((key, idx) => {
const info = availableTees[idx];
const label = info?.navn_utslag || info?.navn_utslag_damer || key.toUpperCase();
return { key, label, theme: getTeeTheme(label) };
});
const getTeeForColumn = (tees: any[], teeIndex: number, activePosition: number) => {
if (!Array.isArray(tees) || tees.length === 0) {
return undefined;
}
// Older/broken saves may store only active tees in sequence instead of the full 6-slot layout.
if (tees.length < lengthKeys.length) {
return tees[activePosition];
}
return tees[teeIndex];
};
const getTeeLabel = (tee: any, fallbackTee: any, fallback: string) => {
const primaryLabel = gender === 'damer' ? tee?.navn_utslag_damer : tee?.navn_utslag;
const secondaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag : fallbackTee?.navn_utslag_damer;
const tertiaryLabel = gender === 'damer' ? tee?.navn_utslag : tee?.navn_utslag_damer;
const quaternaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag_damer : fallbackTee?.navn_utslag;
return String(primaryLabel || secondaryLabel || tertiaryLabel || quaternaryLabel || fallback).trim();
};
const activeColumns = activeLengthKeys.map((key, activePosition) => {
const teeIndex = lengthKeys.indexOf(key);
const tee = getTeeForColumn(availableTees, teeIndex, activePosition);
const fallbackTee = getTeeForColumn(fallbackTees, teeIndex, activePosition);
const label = getTeeLabel(tee, fallbackTee, key[0].toUpperCase() + key.slice(1));
return { key, teeIndex, activePosition, label, theme: getTeeTheme(label) };
});
// Kalkulering av SpH
const activeTee = availableTees[selectedTeeIndex];
const selectedColumn = activeColumns.find((column) => column.teeIndex === selectedTeeIndex) || activeColumns[0] || null;
const activeTee = selectedColumn
? getTeeForColumn(availableTees, selectedColumn.teeIndex, selectedColumn.activePosition)
: undefined;
let playingHandicap = 0;
if (activeTee && hcp) {
@ -69,8 +97,13 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0);
const selectedColumn = activeColumns[selectedTeeIndex] || activeColumns[0] || null;
const selectedTeeLabel = selectedColumn?.label || activeTee?.navn_utslag || activeTee?.navn_utslag_damer || 'Valgt utslag';
const selectedTeeLabel = activeTee
? getTeeLabel(
activeTee,
selectedColumn ? getTeeForColumn(fallbackTees, selectedColumn.teeIndex, selectedColumn.activePosition) : undefined,
selectedColumn?.label || 'Valgt utslag'
)
: 'Valgt utslag';
// Formater utløpsdato
const slopeExpiry = course.slope_valid_until
@ -147,13 +180,36 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
<div className="grid w-full min-w-0 max-w-full grid-cols-1 gap-3 rounded-[1.6rem] border border-gray-100 bg-gray-50 p-4 sm:grid-cols-2 md:flex md:w-auto md:max-w-none md:items-center md:gap-6 md:rounded-[2rem] md:p-6">
<div className="flex min-w-0 flex-col"><span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Kjønn</span>
<select value={gender} onChange={e => { setGender(e.target.value as any); setSelectedTeeIndex(0); }} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 text-[#11280f] font-black outline-none cursor-pointer">
<select value={gender} onChange={e => {
const nextGender = e.target.value as Gender;
const nextTees = course.tee_boxes?.[nextGender] || [];
const nextFallbackTees = course.tee_boxes?.[nextGender === 'damer' ? 'herrer' : 'damer'] || [];
const nextFirstTeeIndex = activeLengthKeys.findIndex((key, activePosition) =>
Boolean(
String(
nextGender === 'damer'
? getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
""
: getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
getTeeForColumn(nextTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag_damer ||
getTeeForColumn(nextFallbackTees, lengthKeys.indexOf(key), activePosition)?.navn_utslag ||
""
).trim()
)
);
setGender(nextGender);
setSelectedTeeIndex(nextFirstTeeIndex >= 0 ? lengthKeys.indexOf(activeLengthKeys[nextFirstTeeIndex]) : 0);
}} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 text-[#11280f] font-black outline-none cursor-pointer">
<option value="herrer">HERRER</option><option value="damer">DAMER</option>
</select>
</div>
<div className="flex min-w-0 flex-col"><span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Utslag</span>
<select value={selectedTeeIndex} onChange={e => setSelectedTeeIndex(Number(e.target.value))} className="w-full min-w-0 truncate border-b-2 border-[#7ca982]/30 bg-transparent pb-1 pr-6 text-[#11280f] font-black outline-none cursor-pointer">
{availableTees.map((t: any, i: number) => (<option key={i} value={i}>{t.navn_utslag || t.navn_utslag_damer}</option>))}
{activeColumns.map((col) => (<option key={col.teeIndex} value={col.teeIndex}>{col.label}</option>))}
</select>
</div>
<div className="flex min-w-0 flex-col"><span className="ml-1 text-[9px] font-black uppercase text-[#7ca982]">Ditt HCP</span>

View file

@ -496,6 +496,7 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
<div className="space-y-4">
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Drivingrange:</span><span className="text-right ml-4">{renderValue(amenities.drivingrange, 'Nei')}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Nærspill:</span><span className="text-right ml-4">{renderValue(amenities.treningsgreen, 'Ja')}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Pro:</span><span className="text-right ml-4">{renderValue(amenities.pro)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Proshop:</span><span className="text-right ml-4">{renderValue(amenities.proshop)}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Kølleutleie:</span><span className="text-right ml-4">{renderValue(amenities.kolleutleie, 'Ja')}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-2"><span className="text-gray-400">Bilutleie:</span><span className="text-right ml-4">{renderValue(amenities.bilutleie, 'Nei')}</span></div>