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:
Generated
-38
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user