feat: Outlook-style week calendar replacing month calendar

- New WeekCalendar component with time grid (Mo-Fr, 08:00-16:00)
- Slots rendered as positioned blocks in day columns
- Amber = available, red = fully booked, grey = no slot
- Week navigation (prev/next)
- SlotsPage uses WeekCalendar + detail modal on slot click
- BookingPage uses WeekCalendar with side panel booking form
- Removed react-day-picker dependency
- Fixed CSS nesting bug in index.css
This commit is contained in:
2026-05-08 23:43:26 +02:00
parent 634b0d672e
commit 32835341ea
8 changed files with 292 additions and 478 deletions
-38
View File
@@ -12,9 +12,7 @@
"@tanstack/react-query": "^5.100.9",
"axios": "^1.16.0",
"better-auth": "^1.6.9",
"date-fns": "^4.1.0",
"react": "^19.2.5",
"react-day-picker": "^10.0.0",
"react-dom": "^19.2.5",
"react-hook-form": "^7.75.0",
"react-router-dom": "^7.15.0",
@@ -409,12 +407,6 @@
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz",
"integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -2032,16 +2024,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3398,26 +3380,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.0.tgz",
"integrity": "sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
-2
View File
@@ -14,9 +14,7 @@
"@tanstack/react-query": "^5.100.9",
"axios": "^1.16.0",
"better-auth": "^1.6.9",
"date-fns": "^4.1.0",
"react": "^19.2.5",
"react-day-picker": "^10.0.0",
"react-dom": "^19.2.5",
"react-hook-form": "^7.75.0",
"react-router-dom": "^7.15.0",
-55
View File
@@ -1,55 +0,0 @@
import { DayPicker } from 'react-day-picker';
import { de } from 'date-fns/locale';
import type { Matcher } from 'react-day-picker';
interface SlotCalendarProps {
selected?: Date;
onSelect: (date: Date | undefined) => void;
modifiers?: Record<string, Matcher | Matcher[]>;
modifiersClassNames?: Record<string, string>;
className?: string;
}
export function SlotCalendar({
selected,
onSelect,
modifiers,
modifiersClassNames,
className = '',
}: SlotCalendarProps) {
return (
<div className={className}>
<DayPicker
mode="single"
selected={selected}
onSelect={onSelect}
locale={de}
weekStartsOn={1}
modifiers={modifiers as Record<string, Matcher | Matcher[] | undefined>}
modifiersClassNames={modifiersClassNames}
classNames={{
months: "flex flex-col",
month: "space-y-3",
month_caption: "flex items-center justify-between pt-1 relative",
caption_label: "text-sm font-medium text-white/80",
nav: "flex items-center gap-1",
button_next:
"size-7 flex items-center justify-center rounded-md text-white/40 hover:text-white/80 hover:bg-white/[0.06] transition-colors",
button_previous:
"size-7 flex items-center justify-center rounded-md text-white/40 hover:text-white/80 hover:bg-white/[0.06] transition-colors",
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday: "w-9 h-8 text-xs font-medium text-white/30 flex items-center justify-center",
week: "flex w-full mt-0.5",
day: "size-9 text-sm rounded-lg flex items-center justify-center transition-all duration-150 cursor-pointer",
day_button: "size-9 text-sm rounded-lg flex items-center justify-center transition-all duration-150 cursor-pointer",
disabled: "text-white/[0.08] cursor-not-allowed",
outside: "text-white/[0.08]",
today: "font-semibold text-white/90",
selected: "bg-amber-500/20 text-amber-400 font-medium ring-1 ring-amber-500/30",
hidden: "invisible",
}}
/>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useMemo } from 'react';
interface SlotData {
id: number;
title: string;
date: string;
start_time: string;
end_time: string;
max_bookings: number;
total_booked: number;
available?: boolean;
}
interface WeekCalendarProps {
slots: SlotData[];
currentWeekStart: Date;
onPrevWeek: () => void;
onNextWeek: () => void;
onSlotClick?: (slot: SlotData) => void;
mode?: 'admin' | 'customer';
}
const HOUR_LABELS = ['08:00','09:00','10:00','11:00','12:00','13:00','14:00','15:00','16:00'];
const HOURS = 9;
const START_HOUR = 8;
const TOTAL_PX = 540;
const ROW_H = TOTAL_PX / HOURS;
function getWeekDays(start: Date): Date[] {
return Array.from({length: 5}, (_, i) => {
const d = new Date(start);
d.setDate(d.getDate() + i);
return d;
});
}
function fmtDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function parseTime(t: string): number {
const [h, m] = t.split(':').map(Number);
return h + m / 60;
}
function getWeekNumber(d: Date): number {
const start = new Date(d.getFullYear(), 0, 1);
return Math.ceil(((d.getTime() - start.getTime()) / 86400000 + start.getDay() + 1) / 7);
}
const DAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
export function WeekCalendar({
slots,
currentWeekStart,
onPrevWeek,
onNextWeek,
onSlotClick,
mode = 'admin',
}: WeekCalendarProps) {
const days = useMemo(() => getWeekDays(currentWeekStart), [currentWeekStart]);
const weekNumber = getWeekNumber(currentWeekStart);
const slotsByDay = useMemo(() => {
const map: Record<string, SlotData[]> = {};
for (const s of slots) {
if (!map[s.date]) map[s.date] = [];
map[s.date].push(s);
}
return map;
}, [slots]);
const startMonth = days[0];
const endMonth = days[4];
const dateStr = `${fmtDate(startMonth).slice(0, 7) === fmtDate(endMonth).slice(0, 7) ? startMonth.toLocaleDateString('de-DE', { month: 'long' }) : `${startMonth.toLocaleDateString('de-DE', { month: 'short' })} ${endMonth.toLocaleDateString('de-DE', { month: 'short' })}`}`;
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button
onClick={onPrevWeek}
className="size-7 flex items-center justify-center rounded-md text-white/40 hover:text-white/80 hover:bg-white/[0.06] transition-colors cursor-pointer"
>
</button>
<span className="text-sm font-medium text-white/80 min-w-[240px] text-center">
KW {weekNumber} {days[0].getDate()}. {days[4].getDate()}. {dateStr} {days[0].getFullYear()}
</span>
<button
onClick={onNextWeek}
className="size-7 flex items-center justify-center rounded-md text-white/40 hover:text-white/80 hover:bg-white/[0.06] transition-colors cursor-pointer"
>
</button>
</div>
</div>
</div>
<div className="border border-white/[0.06] rounded-xl overflow-hidden">
<div
className="grid"
style={{
gridTemplateColumns: '56px repeat(5, 1fr)',
gridTemplateRows: `36px ${TOTAL_PX}px`,
}}
>
<div className="border-b border-r border-white/[0.06]" />
{days.map((d, i) => (
<div
key={d.toISOString()}
className="border-b border-l border-white/[0.06] flex items-center justify-center text-xs text-white/50 font-medium"
>
{DAY_LABELS[i]} {String(d.getDate()).padStart(2,'0')}.
</div>
))}
<div
className="border-r border-white/[0.06] flex flex-col"
style={{ height: TOTAL_PX }}
>
{HOUR_LABELS.map(h => (
<div
key={h}
className="flex items-start justify-center pt-0.5 text-[11px] text-white/25 font-mono flex-1 border-b border-white/[0.03] last:border-b-0"
>
{h}
</div>
))}
</div>
{days.map(day => {
const ds = fmtDate(day);
const daySlots = slotsByDay[ds] || [];
return (
<div
key={ds}
className="relative border-l border-white/[0.06]"
style={{
height: TOTAL_PX,
background: `repeating-linear-gradient(to bottom, transparent, transparent ${ROW_H - 1}px, rgba(255,255,255,0.03) ${ROW_H - 1}px, rgba(255,255,255,0.03) ${ROW_H}px)`,
}}
>
{daySlots.map(slot => {
const startH = parseTime(slot.start_time);
const endH = parseTime(slot.end_time);
const top = ((startH - START_HOUR) / HOURS) * TOTAL_PX;
const h = ((endH - startH) / HOURS) * TOTAL_PX;
const isBooked = slot.total_booked >= slot.max_bookings;
const isCustomerMode = mode === 'customer';
return (
<div
key={slot.id}
onClick={() => {
if (isCustomerMode && isBooked) return;
onSlotClick?.(slot);
}}
className={`
absolute left-1 right-1 rounded-md px-2 py-1
text-xs transition-all duration-150 overflow-hidden
${isCustomerMode && isBooked
? 'bg-white/[0.04] text-white/20 cursor-not-allowed border border-white/[0.04]'
: isBooked
? 'bg-red-500/10 text-red-300 border border-red-500/15 cursor-pointer hover:bg-red-500/15'
: 'bg-amber-500/10 text-amber-300 border border-amber-500/15 cursor-pointer hover:bg-amber-500/15 hover:border-amber-500/25'
}
${isCustomerMode && !isBooked ? 'cursor-pointer' : ''}
`}
style={{ top: `${top}px`, height: `${h}px` }}
>
<div className="font-medium truncate leading-tight">
{slot.title}
</div>
<div className="text-[10px] opacity-60 leading-tight mt-0.5">
{slot.start_time}{slot.end_time}
</div>
<div className="text-[10px] opacity-40 leading-tight">
{slot.total_booked}/{slot.max_bookings}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
);
}
+62 -199
View File
@@ -7,9 +7,7 @@ import { z } from 'zod';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { SlotCalendar } from '@/components/ui/SlotCalendar';
import type { PublicSlot } from '@/types';
import type { Matcher } from 'react-day-picker';
import { WeekCalendar } from '@/components/ui/WeekCalendar';
const bookingSchema = z.object({
customer_name: z.string().min(1, 'Name erforderlich'),
@@ -20,17 +18,19 @@ const bookingSchema = z.object({
type BookingForm = z.infer<typeof bookingSchema>;
function fmtDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
date.setDate(diff);
date.setHours(0, 0, 0, 0);
return date;
}
export function BookingPage() {
const { token } = useParams<{ token: string }>();
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
const [selectedSlot, setSelectedSlot] = useState<number | null>(null);
const [weekStart, setWeekStart] = useState(() => getMonday(new Date()));
const [selectedSlot, setSelectedSlot] = useState<any | null>(null);
const [successBooking, setSuccessBooking] = useState<any>(null);
const { data, isLoading } = useQuery({
@@ -45,59 +45,33 @@ export function BookingPage() {
const bookingMutation = useMutation({
mutationFn: (formData: BookingForm) =>
api.public.createBooking({ ...formData, slot_id: selectedSlot, token }),
api.public.createBooking({ ...formData, slot_id: selectedSlot.id, token }),
onSuccess: (data) => setSuccessBooking(data),
});
const { slotsForDay, datesWithSlots } = useMemo(() => {
const arr = data?.slots || [];
const selStr = selectedDate ? fmtDate(selectedDate) : '';
const daySlots = arr.filter((s: any) => s.date === selStr);
const withSlotsMatcher: Matcher = (day: Date) => {
const ds = fmtDate(day);
return arr.some((s: any) => s.date === ds);
};
return { slotsForDay: daySlots, datesWithSlots: withSlotsMatcher };
}, [data, selectedDate]);
const weekSlots = useMemo(() => {
if (!data?.slots) return [];
const mon = fmtDate(weekStart);
const sun = fmtDate(new Date(weekStart.getTime() + 4 * 86400000));
return data.slots.filter((s: any) => s.date >= mon && s.date <= sun);
}, [data, weekStart]);
if (successBooking) {
return (
<div
className="min-h-screen flex items-center justify-center p-4"
style={{ background: 'var(--bg-primary)' }}
>
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 max-w-md text-center animate-fade-in">
<div className="w-14 h-14 rounded-full bg-green-500/10 border border-green-500/20 flex items-center justify-center mx-auto mb-4">
<svg
className="w-6 h-6 text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m4.5 12.75 6 6 9-13.5"
/>
<svg className="w-6 h-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h2 className="text-lg font-semibold text-white/90 mb-2">
Buchung eingegangen
</h2>
<h2 className="text-lg font-semibold text-white/90 mb-2">Buchung eingegangen</h2>
<p className="text-sm text-white/50 mb-4">
Ihre Buchung für{' '}
<strong className="text-white/70">
{successBooking.slot_title}
</strong>{' '}
am {successBooking.slot_date} ({successBooking.start_time} {' '}
{successBooking.end_time}) wurde registriert.
Ihre Buchung für <strong className="text-white/70">{successBooking.slot_title}</strong>{' '}
am {successBooking.slot_date} ({successBooking.start_time} {successBooking.end_time}) wurde registriert.
</p>
<p className="text-xs text-white/30">
Status:{' '}
<span className="text-amber-400 font-medium">Ausstehend</span>
Status: <span className="text-amber-400 font-medium">Ausstehend</span>
</p>
</div>
</div>
@@ -106,14 +80,10 @@ export function BookingPage() {
return (
<div className="min-h-screen p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="max-w-2xl mx-auto pt-12 animate-fade-in">
<div className="max-w-5xl mx-auto pt-12 animate-fade-in">
<div className="mb-8">
<h1 className="text-xl font-semibold text-white/90">
Update-Termin buchen
</h1>
<p className="text-sm text-white/40 mt-1">
Wählen Sie Datum und Slot
</p>
<h1 className="text-xl font-semibold text-white/90">Update-Termin buchen</h1>
<p className="text-sm text-white/40 mt-1">Klicken Sie auf einen freien Slot zur Buchung</p>
</div>
{isLoading ? (
@@ -122,159 +92,48 @@ export function BookingPage() {
</div>
) : (
<div className="flex flex-col lg:flex-row gap-6">
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-4 w-fit">
<SlotCalendar
selected={selectedDate}
onSelect={(d) => {
setSelectedDate(d);
setSelectedSlot(null);
}}
modifiers={{ hasSlots: datesWithSlots }}
modifiersClassNames={{
hasSlots:
'text-amber-400 font-medium relative after:content-[""] after:block after:w-1 after:h-1 after:rounded-full after:bg-amber-400 after:mx-auto after:mt-0.5',
}}
<div className="flex-1 min-w-0">
<WeekCalendar
slots={weekSlots}
currentWeekStart={weekStart}
onPrevWeek={() => setWeekStart(prev => new Date(prev.getTime() - 7 * 86400000))}
onNextWeek={() => setWeekStart(prev => new Date(prev.getTime() + 7 * 86400000))}
onSlotClick={(slot) => setSelectedSlot(slot)}
mode="customer"
/>
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-white/[0.06] text-xs text-white/30">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-amber-400" />
Verfügbar
</span>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-white/[0.15]" />
Keine Termine
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-medium text-white/70 mb-3">
{selectedDate
? selectedDate.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
: 'Bitte Datum wählen'}
</h2>
{slotsForDay.length === 0 ? (
<div className="bg-white/[0.02] border border-dashed border-white/[0.06] rounded-xl p-8 text-center">
<p className="text-sm text-white/30">
Keine Termine an diesem Tag
</p>
</div>
) : (
<div className="grid gap-2">
{slotsForDay.map((slot: PublicSlot) => (
<div
key={slot.id}
className={`
bg-white/[0.03] border rounded-xl p-4 transition-all duration-200 cursor-pointer
${
selectedSlot === slot.id
? 'border-amber-500/30 bg-amber-500/5'
: slot.available
? 'border-white/[0.06] hover:border-white/[0.12]'
: 'border-white/[0.04] opacity-40 cursor-not-allowed'
}
`}
onClick={() =>
slot.available && setSelectedSlot(slot.id)
}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-500/5 border border-amber-500/10 flex items-center justify-center flex-shrink-0">
<svg
className="w-4 h-4 text-amber-400/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div>
<h3 className="text-sm font-medium text-white/90">
{slot.title}
</h3>
<p className="text-xs font-mono text-white/40 mt-0.5">
{slot.start_time} {slot.end_time}
</p>
</div>
</div>
<span
className={`text-xs font-mono px-2.5 py-1 rounded-md border ${
slot.available
? 'text-green-400 border-green-500/20 bg-green-500/5'
: 'text-red-400 border-red-500/20 bg-red-500/5'
}`}
>
{slot.available
? `${slot.max_bookings - slot.total_booked} frei`
: 'Ausgebucht'}
</span>
</div>
</div>
))}
</div>
)}
{selectedSlot && (
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-6 mt-4 animate-fade-in">
<h3 className="text-sm font-medium text-white/90 mb-4">
Ihre Angaben
</h3>
<form
onSubmit={handleSubmit((formData) =>
bookingMutation.mutate(formData)
)}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-3">
<Input
label="Name *"
{...register('customer_name')}
error={errors.customer_name?.message}
/>
<Input
label="Email *"
type="email"
{...register('customer_email')}
error={errors.customer_email?.message}
/>
</div>
<Input
label="Unternehmen / Krankenhaus"
{...register('customer_company')}
/>
<Input
label="Standort"
{...register('customer_location')}
/>
<div className="lg:w-80 flex-shrink-0">
{selectedSlot ? (
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5 animate-fade-in sticky top-8">
<div className="flex items-center gap-2 mb-4 pb-3 border-b border-white/[0.06]">
<span className="text-xs font-mono text-amber-400/80 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/15">
{selectedSlot.start_time}{selectedSlot.end_time}
</span>
<span className="text-xs text-white/40 font-mono">
{new Date(selectedSlot.date).toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'numeric' })}
</span>
</div>
<h3 className="text-sm font-medium text-white/90 mb-4">{selectedSlot.title}</h3>
<form onSubmit={handleSubmit(data => bookingMutation.mutate(data))} className="flex flex-col gap-4">
<Input label="Name *" {...register('customer_name')} error={errors.customer_name?.message} />
<Input label="Email *" type="email" {...register('customer_email')} error={errors.customer_email?.message} />
<Input label="Unternehmen / Krankenhaus" {...register('customer_company')} />
<Input label="Standort" {...register('customer_location')} />
{bookingMutation.error && (
<p className="text-sm text-red-400/90 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{(bookingMutation.error as Error).message}
</p>
)}
<Button
type="submit"
disabled={bookingMutation.isPending}
className="w-full mt-1"
>
{bookingMutation.isPending
? 'Wird gebucht...'
: 'Termin buchen'}
<Button type="submit" disabled={bookingMutation.isPending} className="w-full">
{bookingMutation.isPending ? 'Wird gebucht...' : 'Termin buchen'}
</Button>
</form>
</div>
) : (
<div className="bg-white/[0.02] border border-dashed border-white/[0.06] rounded-xl p-6 text-center">
<p className="text-sm text-white/30">Wählen Sie einen freien Slot im Kalender</p>
</div>
)}
</div>
</div>
@@ -283,3 +142,7 @@ export function BookingPage() {
</div>
);
}
function fmtDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
+36 -144
View File
@@ -3,27 +3,23 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { SlotCalendar } from '@/components/ui/SlotCalendar';
import { WeekCalendar } from '@/components/ui/WeekCalendar';
import { SlotForm } from './SlotForm';
import type { Slot } from '@/types';
import type { Matcher } from 'react-day-picker';
function dateFromSlot(s: any): Date {
const p = (s.date as string).split('-').map(Number);
return new Date(p[0], p[1] - 1, p[2]);
}
function fmtDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getDay();
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
date.setDate(diff);
date.setHours(0, 0, 0, 0);
return date;
}
export function SlotsPage() {
const [modalOpen, setModalOpen] = useState(false);
const [editingSlot, setEditingSlot] = useState<Slot | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date());
const [weekStart, setWeekStart] = useState(() => getMonday(new Date()));
const queryClient = useQueryClient();
const { data: slots, isLoading } = useQuery({
@@ -36,34 +32,14 @@ export function SlotsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['slots'] }),
});
const { slotsForDay, hasSlotsMatcher } = useMemo(() => {
const arr = slots || [];
const selectedStr = selectedDate ? fmtDate(selectedDate) : '';
const weekSlots = useMemo(() => {
if (!slots) return [];
const mon = fmtDate(weekStart);
const sun = fmtDate(new Date(weekStart.getTime() + 4 * 86400000));
return slots.filter((s: any) => s.date >= mon && s.date <= sun);
}, [slots, weekStart]);
const daySlots = arr.filter((s: any) => s.date === selectedStr);
const hasSlotsDates = arr.map((s: any) => dateFromSlot(s));
const isFullyBookedDates = arr
.filter((s: any) => s.total_booked >= s.max_bookings)
.map((s: any) => dateFromSlot(s));
const hasSlotsMatcher: Matcher = (day: Date) => {
const ds = fmtDate(day);
return hasSlotsDates.some((d: Date) => fmtDate(d) === ds);
};
return { slotsForDay: daySlots, hasSlotsMatcher };
}, [slots, selectedDate]);
const openCreate = useCallback(() => {
setEditingSlot(null);
setModalOpen(true);
}, []);
const openEdit = useCallback((slot: Slot) => {
setEditingSlot(slot);
setModalOpen(true);
}, []);
const selectedSlot = editingSlot;
return (
<div className="animate-fade-in">
@@ -72,7 +48,9 @@ export function SlotsPage() {
<h1 className="text-xl font-semibold text-white/90">Slots</h1>
<p className="text-sm text-white/40 mt-0.5">Update-Termine verwalten</p>
</div>
<Button onClick={openCreate}>+ Slot erstellen</Button>
<Button onClick={() => { setEditingSlot(null); setModalOpen(true); }}>
+ Slot erstellen
</Button>
</div>
{isLoading ? (
@@ -80,116 +58,26 @@ export function SlotsPage() {
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
</div>
) : (
<div className="flex flex-col lg:flex-row gap-6">
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-4 w-fit">
<SlotCalendar
selected={selectedDate}
onSelect={setSelectedDate}
modifiers={{ hasSlots: hasSlotsMatcher }}
modifiersClassNames={{
hasSlots:
'text-amber-400 font-medium relative after:content-[""] after:block after:w-1 after:h-1 after:rounded-full after:bg-amber-400 after:mx-auto after:mt-0.5',
}}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-sm font-medium text-white/70">
{selectedDate
? selectedDate.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
: 'Kein Datum ausgewählt'}
</h2>
<span className="text-xs text-white/30 font-mono">
({slotsForDay.length} Slot{slotsForDay.length !== 1 ? 's' : ''})
</span>
</div>
{slotsForDay.length === 0 ? (
<div className="bg-white/[0.02] border border-dashed border-white/[0.06] rounded-xl p-8 text-center">
<p className="text-sm text-white/30 mb-3">Keine Slots an diesem Tag</p>
<Button size="sm" onClick={openCreate}>
Slot erstellen
</Button>
</div>
) : (
<div className="grid gap-2">
{slotsForDay.map((slot: any) => (
<div
key={slot.id}
className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-4 hover:border-white/[0.1] transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-500/5 border border-amber-500/10 flex items-center justify-center flex-shrink-0">
<svg
className="w-4 h-4 text-amber-400/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div>
<h3 className="text-sm font-medium text-white/90">
{slot.title}
</h3>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs font-mono text-white/40">
{slot.start_time} {slot.end_time}
</span>
<span className="text-white/20">·</span>
<span
className={`text-xs font-mono ${
slot.total_booked >= slot.max_bookings
? 'text-red-400'
: 'text-white/50'
}`}
>
{slot.total_booked}/{slot.max_bookings} belegt
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => openEdit(slot)}>
Bearbeiten
</Button>
<Button
variant="danger"
size="sm"
onClick={() => deleteMutation.mutate(slot.id)}
>
Löschen
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<WeekCalendar
slots={weekSlots}
currentWeekStart={weekStart}
onPrevWeek={() => setWeekStart(prev => new Date(prev.getTime() - 7 * 86400000))}
onNextWeek={() => setWeekStart(prev => new Date(prev.getTime() + 7 * 86400000))}
onSlotClick={(slot) => {
setEditingSlot(slot as Slot);
setModalOpen(true);
}}
mode="admin"
/>
)}
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editingSlot ? 'Slot bearbeiten' : 'Slot erstellen'}
title={selectedSlot ? 'Slot bearbeiten' : 'Slot erstellen'}
>
<SlotForm
slot={editingSlot}
slot={selectedSlot}
onClose={() => {
setModalOpen(false);
queryClient.invalidateQueries({ queryKey: ['slots'] });
@@ -199,3 +87,7 @@ export function SlotsPage() {
</div>
);
}
function fmtDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
-39
View File
@@ -1,5 +1,4 @@
@import "tailwindcss";
@import "react-day-picker/src/style.css";
@theme {
--font-sans: 'DM Sans', sans-serif;
@@ -38,44 +37,6 @@
--red-dim: rgba(239, 68, 68, 0.15);
--radius: 12px;
--radius-sm: 8px;
--rdp-accent-color: #f59e0b;
--rdp-accent-background-color: rgba(245, 158, 11, 0.12);
--rdp-day-height: 38px;
--rdp-day-width: 38px;
--rdp-day_button-height: 36px;
--rdp-day_button-width: 36px;
--rdp-day_button-border-radius: 8px;
--rdp-selected-border: 1px solid rgba(245, 158, 11, 0.4);
--rdp-disabled-opacity: 0.2;
--rdp-outside-opacity: 0.15;
--rdp-today-color: #f59e0b;
--rdp-nav_button-height: 28px;
--rdp-nav_button-width: 28px;
--rdp-nav-height: 32px;
}
.rdp-root {
width: 100%;
}
.rdp-root:where([data-mode="single"]) .rdp-day:where([data-selected]) {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.rdp-root .rdp-day:hover {
background: rgba(255, 255, 255, 0.05);
}
.rdp-root .rdp-day[data-disabled] {
color: rgba(255, 255, 255, 0.1);
cursor: not-allowed;
}
.rdp-root .rdp-day[data-today] {
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
* {
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/SlotCalendar.tsx","./src/features/auth/LoginPage.tsx","./src/features/auth/ProtectedRoute.tsx","./src/features/bookings/BookingsPage.tsx","./src/features/customer/BookingConfirmation.tsx","./src/features/customer/BookingPage.tsx","./src/features/dashboard/DashboardLayout.tsx","./src/features/settings/SettingsPage.tsx","./src/features/slots/SlotForm.tsx","./src/features/slots/SlotsPage.tsx","./src/lib/api.ts","./src/lib/auth-client.ts","./src/types/index.ts"],"version":"6.0.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/WeekCalendar.tsx","./src/features/auth/LoginPage.tsx","./src/features/auth/ProtectedRoute.tsx","./src/features/bookings/BookingsPage.tsx","./src/features/customer/BookingConfirmation.tsx","./src/features/customer/BookingPage.tsx","./src/features/dashboard/DashboardLayout.tsx","./src/features/settings/SettingsPage.tsx","./src/features/slots/SlotForm.tsx","./src/features/slots/SlotsPage.tsx","./src/lib/api.ts","./src/lib/auth-client.ts","./src/types/index.ts"],"version":"6.0.3"}