diff --git a/logs/out-0.log b/logs/out-0.log index 3243740..2f0f465 100644 --- a/logs/out-0.log +++ b/logs/out-0.log @@ -13,3 +13,4 @@ 2025-10-30T19:58:29: > Ready on http://localhost:3000 2025-10-31T19:17:21: > Ready on http://localhost:3000 2025-10-31T20:05:46: > Ready on http://localhost:3000 +2025-10-31T20:42:18: > Ready on http://localhost:3000 diff --git a/src/app/admin/availability/page.tsx b/src/app/admin/availability/page.tsx index 5060d5c..f890e46 100644 --- a/src/app/admin/availability/page.tsx +++ b/src/app/admin/availability/page.tsx @@ -262,64 +262,64 @@ function AvailabilityAdminContent() { return (
-
+

Tilgængelighed & blokeringer

{loading ?

Indlæser...

: ( -
+
+ {/* Main calendar section */}
-

Kalender — klik for at skifte tilgængelighed

-

Klik på en dato for at markere den som ikke-tilgængelig (klik igen for at åbne).

-
-
- -
- {multiSelectMode ? ( +
{multiSelectMode ? ( new Date(b.block_date)), nonWorking: (day: Date) => !weekly.some(w => w.weekday === day.getDay()) }} + modifiersClassNames={{ + blocked: 'rdp-day_blocked', + nonWorking: 'rdp-day_nonWorking', + }} disabled={showOnlyBlocked ? ((day: Date) => !blocked.some(b => sameDay(new Date(b.block_date), day))) : undefined} /> ) : ( @@ -393,7 +397,9 @@ function AvailabilityAdminContent() { pending: pendingDates, }} modifiersClassNames={{ + blocked: 'rdp-day_blocked', pending: 'rdp-day_pending', + nonWorking: 'rdp-day_nonWorking', }} disabled={showOnlyBlocked ? ((day: Date) => !blocked.some(b => sameDay(new Date(b.block_date), day))) : undefined} /> diff --git a/src/app/admin/availability/page.tsx.backup b/src/app/admin/availability/page.tsx.backup new file mode 100644 index 0000000..f890e46 --- /dev/null +++ b/src/app/admin/availability/page.tsx.backup @@ -0,0 +1,492 @@ +"use client" + +import { useEffect, useState } from 'react' +import { DayPicker } from 'react-day-picker' +import { da } from 'date-fns/locale' +import { ToastProvider, useToast } from '@/components/ui/toast' + +type Weekly = { id: string; weekday: number; start_time: string; end_time: string; note?: string } +type Blocked = { id: string; block_date: string; note?: string } + +function BlockedList({ blocked, onDelete }: { blocked: Blocked[]; onDelete: (id: string) => void }) { + if (!blocked || blocked.length === 0) return

Ingen blokeringer

+ const sorted = blocked.slice().sort((a, b) => a.block_date.localeCompare(b.block_date)) + const fmt = (d: string) => { + try { + const dt = new Date(d) + return dt.toLocaleDateString('da-DK', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }) + } catch (e) { + return d + } + } + return ( +
    + {sorted.map(b => ( +
  • +
    +
    {fmt(b.block_date)}
    + {b.note &&
    {b.note}
    } +
    +
    + +
    +
  • + ))} +
+ ) +} + +export default function AvailabilityAdmin() { + return ( + + + + ) +} + +function AvailabilityAdminContent() { + const { addToast } = useToast() + const [weekly, setWeekly] = useState([]) + const [blocked, setBlocked] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + // form state + const [weekday, setWeekday] = useState(1) + const [startTime, setStartTime] = useState('09:00') + const [endTime, setEndTime] = useState('17:00') + const [note, setNote] = useState('') + const [editingId, setEditingId] = useState(null) + + const [blockDate, setBlockDate] = useState('') + const [blockNote, setBlockNote] = useState('') + const [multiSelectMode, setMultiSelectMode] = useState(false) + const [selectedDates, setSelectedDates] = useState(undefined) + const [showOnlyBlocked, setShowOnlyBlocked] = useState(false) + const [lastClickedDate, setLastClickedDate] = useState(null) + const [pendingDates, setPendingDates] = useState([]) // for Ctrl/Cmd+click accumulation + + useEffect(() => { fetchAll() }, []) + + async function fetchAll() { + setLoading(true) + const [wRes, bRes] = await Promise.all([ + fetch('/api/availability/weekly').then(r => r.json()), + fetch('/api/availability/blocked').then(r => r.json()) + ]) + setWeekly(wRes || []) + setBlocked(bRes || []) + setLoading(false) + } + + async function addWeekly(e: React.FormEvent) { + e.preventDefault() + e.currentTarget && (e.currentTarget as HTMLFormElement).querySelectorAll('button') + setSaving(true) + try { + if (editingId) { + // send PUT to update + await fetch('/api/availability/weekly', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id: editingId, weekday, start_time: startTime, end_time: endTime, note }) }) + addToast('Skema opdateret', 'success') + setEditingId(null) + } else { + await fetch('/api/availability/weekly', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ weekday, start_time: startTime, end_time: endTime, note }) }) + addToast('Skema tilføjet', 'success') + } + setNote('') + fetchAll() + } catch (err) { + addToast('Kunne ikke gemme skema', 'error') + } finally { + setSaving(false) + } + } + + async function deleteWeekly(id: string) { + if (!confirm('Slet dette skema?')) return + setSaving(true) + try { + await fetch('/api/availability/weekly', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }) + addToast('Skema slettet', 'success') + fetchAll() + } catch (err) { + addToast('Kunne ikke slette skema', 'error') + } finally { + setSaving(false) + } + } + + function startEdit(w: Weekly) { + setEditingId(w.id) + setWeekday(w.weekday) + setStartTime(w.start_time) + setEndTime(w.end_time) + setNote(w.note || '') + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + async function addBlocked(e: React.FormEvent) { + e.preventDefault() + if (!blockDate) { + addToast('Vælg en dato', 'warning') + return + } + setSaving(true) + try { + await fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: blockDate, note: blockNote }) }) + setBlockDate('') + setBlockNote('') + addToast('Dato blokeret', 'success') + fetchAll() + } catch (err) { + addToast('Kunne ikke blokere dato', 'error') + } finally { + setSaving(false) + } + } + + // Toggle a single date's blocked state when clicked in the calendar + async function toggleBlockedDate(d: Date) { + const iso = isoFromDate(d) + + const found = blocked.find(b => b.block_date === iso) + const weeklyForDay = weekly.find(w => w.weekday === d.getDay()) + // If this date falls on a working day, confirm user intention to block full day + if (!found && weeklyForDay) { + const proceed = confirm(`Der er allerede et arbejdsskema for denne ugedag (${['Søndag','Mandag','Tirsdag','Onsdag','Torsdag','Fredag','Lørdag'][weeklyForDay.weekday]} ${weeklyForDay.start_time}–${weeklyForDay.end_time}).\n\nVil du blokere hele dagen alligevel?`) + if (!proceed) return + } + if (found) { + // optimistic UI: remove locally first + setBlocked(prev => prev.filter(b => b.block_date !== iso)) + try { + await fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id: found.id }) }) + addToast('Blokering fjernet', 'success') + } catch (err) { + // revert on failure + fetchAll() + addToast('Kunne ikke fjerne blokering — prøv igen', 'error') + return + } + } else { + // optimistic UI: add temporary entry so calendar turns red immediately + setBlocked(prev => [...prev, { id: `tmp-${iso}`, block_date: iso }]) + try { + await fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: iso, note: '' }) }) + addToast('Dato blokeret', 'success') + // refresh to pick up real id + fetchAll() + return + } catch (err) { + // revert on failure + setBlocked(prev => prev.filter(b => b.block_date !== iso)) + addToast('Kunne ikke oprette blokering — prøv igen', 'error') + return + } + } + fetchAll() + } + + // Bulk delete of selected blocked dates (multi-select mode) + async function deleteSelectedBlocked() { + if (!selectedDates || selectedDates.length === 0) return + if (!confirm(`Slet ${selectedDates.length} valgte blokering(er)?`)) return + setSaving(true) + const isos = selectedDates.map(d => { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${dd}` + }) + // map isos to ids + const ids = blocked.filter(b => isos.includes(b.block_date)).map(b => b.id) + if (ids.length === 0) { + addToast('Ingen matcher blandt eksisterende blokeringer', 'warning') + setSaving(false) + return + } + // optimistic + setBlocked(prev => prev.filter(b => !isos.includes(b.block_date))) + try { + await Promise.all(ids.map(id => fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }))) + setSelectedDates(undefined) + setMultiSelectMode(false) + addToast(`${ids.length} blokering(er) slettet`, 'success') + } catch (err) { + addToast('Noget gik galt ved sletning — opdaterer listen', 'error') + fetchAll() + } finally { + setSaving(false) + } + } + + async function deleteBlocked(id: string) { + if (!confirm('Slet denne blokering?')) return + setSaving(true) + try { + await fetch('/api/availability/blocked', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ id }) }) + addToast('Blokering slettet', 'success') + fetchAll() + } catch (err) { + addToast('Kunne ikke slette blokering', 'error') + } finally { + setSaving(false) + } + } + + function sameDay(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() + } + + function isoFromDate(d: Date) { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${dd}` + } + + // Generate inclusive date range between a and b + function datesBetween(a: Date, b: Date) { + const start = new Date(a.getFullYear(), a.getMonth(), a.getDate()) + const end = new Date(b.getFullYear(), b.getMonth(), b.getDate()) + const out: Date[] = [] + const step = start <= end ? 1 : -1 + let cur = new Date(start) + while (true) { + out.push(new Date(cur)) + if (cur.getFullYear() === end.getFullYear() && cur.getMonth() === end.getMonth() && cur.getDate() === end.getDate()) break + cur.setDate(cur.getDate() + step) + } + return out + } + + return ( +
+
+

Tilgængelighed & blokeringer

+ + {loading ?

Indlæser...

: ( +
+ {/* Main calendar section */} +
+

Kalender — klik for at skifte tilgængelighed

+

Klik på en dato for at blokere/åbne den.

+ +
+ + + {multiSelectMode && ( + + )} + {!multiSelectMode && pendingDates.length > 0 && ( + + )} +
+ +
{multiSelectMode ? ( + setSelectedDates(d as Date[] | undefined)} + locale={da} + modifiers={{ + blocked: blocked.map(b => new Date(b.block_date)), + nonWorking: (day: Date) => !weekly.some(w => w.weekday === day.getDay()) + }} + modifiersClassNames={{ + blocked: 'rdp-day_blocked', + nonWorking: 'rdp-day_nonWorking', + }} + disabled={showOnlyBlocked ? ((day: Date) => !blocked.some(b => sameDay(new Date(b.block_date), day))) : undefined} + /> + ) : ( + { + if (!d) return + + // Ctrl/Cmd+click: accumulate dates for batch blocking + if (e && (e.ctrlKey || e.metaKey)) { + const iso = isoFromDate(d) + const alreadyPending = pendingDates.some(pd => isoFromDate(pd) === iso) + if (alreadyPending) { + setPendingDates(prev => prev.filter(pd => isoFromDate(pd) !== iso)) + } else { + setPendingDates(prev => [...prev, d]) + } + setLastClickedDate(d) + return + } + + // Shift+click range selection + if (e && e.shiftKey && lastClickedDate) { + setSaving(true) + const range = datesBetween(lastClickedDate, d) + // confirm if any date in range falls on a working weekly schedule + const hasWorking = range.some(rd => weekly.some(w => w.weekday === rd.getDay())) + if (hasWorking) { + const proceed = confirm('Nogle dage i det valgte interval har et arbejdsskema. Vil du blokere alle dagene alligevel?') + if (!proceed) { + setLastClickedDate(d) + setSaving(false) + return + } + } + // optimistic add + const isos = range.map(r => isoFromDate(r)) + setBlocked(prev => [...prev, ...isos.map(i => ({ id: `tmp-${i}`, block_date: i }))]) + try { + await Promise.all(range.map(r => fetch('/api/availability/blocked', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ block_date: isoFromDate(r), note: '' }) }))) + addToast(`${range.length} dage blokeret`, 'success') + } catch (err) { + addToast('Kunne ikke oprette alle blokeringer — opdaterer listen', 'error') + } finally { + fetchAll() + setSaving(false) + } + setLastClickedDate(d) + return + } + + // normal single toggle + await toggleBlockedDate(d) + setLastClickedDate(d) + }} + locale={da} + modifiers={{ + blocked: blocked.map(b => new Date(b.block_date)), + nonWorking: (day: Date) => !weekly.some(w => w.weekday === day.getDay()), + pending: pendingDates, + }} + modifiersClassNames={{ + blocked: 'rdp-day_blocked', + pending: 'rdp-day_pending', + nonWorking: 'rdp-day_nonWorking', + }} + disabled={showOnlyBlocked ? ((day: Date) => !blocked.some(b => sameDay(new Date(b.block_date), day))) : undefined} + /> + )} +
+ + + Tilgængelig + + + + Blokeret + + + + Valgt + + + + Venter (Ctrl+klik) + +
+ {!multiSelectMode && ( +
+

Tips: Hold Shift og klik for at vælge interval. Hold Ctrl/⌘ og klik for at vælge flere enkeltdage.

+
+ )} +
+ +
+
+

Ugentligt arbejdsskema

+
+ +
+ + +
+ setNote(e.target.value)} placeholder="Noter (valgfri)" className="border px-2 py-1 rounded" /> + +
+ +

Eksisterende skemaer

+
    + {weekly.map(w => ( +
  • +
    {['Søndag','Mandag','Tirsdag','Onsdag','Torsdag','Fredag','Lørdag'][w.weekday]} {w.start_time}–{w.end_time} {w.note ? `· ${w.note}` : ''}
    +
    + + +
    +
  • + ))} +
+
+ +
+

Blokerede dage

+
+ setBlockDate(e.target.value)} className="border px-2 py-1 rounded" /> + setBlockNote(e.target.value)} placeholder="Noter (fx ferie)" className="border px-2 py-1 rounded" /> + +
+

Eksisterende blokeringer

+ +
+
+ )} +
+
+ ) +}