feat(modal): rebuild reservation modal with touch UI, timeslot pills and tag badges

This commit is contained in:
DanielS
2026-08-19 00:33:53 +02:00
parent 769268a77e
commit d69a9b52a8

View File

@@ -2,273 +2,361 @@ import { useState } from 'react';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { Reservation } from '../types'; import { Reservation } from '../types';
const TAGS = ['Hochstuhl', 'Allergie', 'Fensterplatz', 'Geburtstag']; // ─── Config ────────────────────────────────────────────────────────────────
const GUEST_COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 10, 12]; const PERSON_COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const TIME_SLOTS = [
'17:00', '17:30', '18:00', '18:30', '19:00', '19:30',
'20:00', '20:30', '21:00', '21:30', '22:00', '22:30',
];
const DURATION_OPTIONS = [
{ label: '1 Std.', value: 60 },
{ label: '1,5 Std.', value: 90 },
{ label: '2 Std.', value: 120 },
{ label: '3 Std.', value: 180 },
];
const EXTRA_TAGS = [
{ id: 'Hochstuhl', icon: '🪑', label: 'Hochstuhl' },
{ id: 'Geburtstag', icon: '🎂', label: 'Geburtstag' },
{ id: 'Fensterplatz', icon: '🪟', label: 'Fensterplatz' },
{ id: 'Hund', icon: '🐕', label: 'Hund' },
{ id: 'Terrasse', icon: '☀️', label: 'Terrasse / Außen' },
{ id: 'Allergie', icon: '⚠️', label: 'Allergie' },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function generateId() { function generateId() {
return Math.random().toString(36).substring(2, 10); return Math.random().toString(36).slice(2, 10);
} }
function getTimeSlots() { function addMinutes(time: string, minutes: number): string {
const slots: string[] = []; const [h, m] = time.split(':').map(Number);
for (let h = 10; h < 24; h++) { const total = h * 60 + m + minutes;
for (const m of [0, 15, 30, 45]) { return `${String(Math.floor(total / 60) % 24).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
slots.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
}
return slots;
} }
const TIME_SLOTS = getTimeSlots(); function today(): string {
return new Date().toISOString().slice(0, 10);
}
export default function BookingModal() { // ─── Component ──────────────────────────────────────────────────────────────
const { tables, prefillTableId, prefillTime, prefillDate, closeBookingModal, addReservation } = useAppStore(); export default function ReservationModal() {
const { tables, prefillTableId, prefillTime, prefillDate, closeBookingModal, addReservation, setTableStatus } = useAppStore();
const today = prefillDate ?? new Date().toISOString().slice(0, 10);
// ── State ──
const [guestCount, setGuestCount] = useState(2); const [guestCount, setGuestCount] = useState(2);
const [date, setDate] = useState(today); const [customCount, setCustomCount] = useState('');
const [date, setDate] = useState(prefillDate ?? today());
const [startTime, setStartTime] = useState(prefillTime ?? '19:00'); const [startTime, setStartTime] = useState(prefillTime ?? '19:00');
const [endTime, setEndTime] = useState(() => { const [duration, setDuration] = useState(90);
const start = prefillTime ?? '19:00'; const [tags, setTags] = useState<string[]>([]);
const [h, m] = start.split(':').map(Number); const [notes, setNotes] = useState('');
const end = h + 1 + ':' + String(m).padStart(2, '0');
return end;
});
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [tableId, setTableId] = useState(prefillTableId ?? tables[0]?.id ?? ''); const [tableId, setTableId] = useState<string | 'auto'>(prefillTableId ?? 'auto');
const [notes, setNotes] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const suggestedTables = tables.filter((t) => t.maxCapacity >= guestCount && t.status === 'FREE'); // ── Derived ──
const effectiveCount = customCount ? parseInt(customCount) : guestCount;
const eligibleTables = tables.filter((t) => t.maxCapacity >= effectiveCount && t.minCapacity <= effectiveCount && t.status === 'FREE');
const resolvedTableId = tableId === 'auto' ? (eligibleTables[0]?.id ?? tables[0]?.id) : tableId;
const toggleTag = (tag: string) => // ── Handlers ──
setSelectedTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]); const toggleTag = (id: string) =>
setTags((prev) => prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]);
const handleSubmit = (e: React.FormEvent) => { const handleSave = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!firstName || !tableId) return; if (!firstName.trim()) return;
const endTime = addMinutes(startTime, duration);
const reservation: Reservation = { const reservation: Reservation = {
id: generateId(), id: generateId(),
guest: { id: generateId(), firstName, lastName, phone: phone || undefined, email: email || undefined }, guest: {
tableIds: [tableId], id: generateId(),
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim() || undefined,
email: email.trim() || undefined,
},
tableIds: resolvedTableId ? [resolvedTableId] : [],
date, date,
startTime, startTime,
endTime, endTime,
guestCount, guestCount: effectiveCount,
status: 'CONFIRMED', status: 'CONFIRMED',
notes, notes: notes.trim(),
tags: selectedTags, tags,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
addReservation(reservation); addReservation(reservation);
if (resolvedTableId) setTableStatus(resolvedTableId, 'RESERVED');
closeBookingModal(); closeBookingModal();
}; };
return ( return (
<div className="fixed inset-0 bg-slate-950/85 backdrop-blur-sm flex items-center justify-center p-4 z-50 overflow-y-auto"> <div className="fixed inset-0 bg-slate-950/90 backdrop-blur-md flex items-center justify-center z-50 p-3 overflow-y-auto">
<div className="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-lg shadow-2xl my-4"> <div className="w-full max-w-2xl bg-slate-900 border border-slate-700/80 rounded-3xl shadow-2xl shadow-black/60 my-4">
{/* Header */}
<div className="px-6 py-5 border-b border-slate-800 flex items-center justify-between"> {/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800">
<div> <div>
<h3 className="text-lg font-bold text-amber-400">Neue Reservierung</h3> <h2 className="text-xl font-bold text-white">Neue Reservierung</h2>
<p className="text-xs text-slate-400">Schnellbuchung / Walk-In</p> <p className="text-xs text-slate-400 mt-0.5">Schnellbuchung · Walk-In</p>
</div> </div>
<button <button
onClick={closeBookingModal} onClick={closeBookingModal}
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-slate-700 flex items-center justify-center text-slate-400 hover:text-white transition" className="w-9 h-9 rounded-full bg-slate-800 hover:bg-slate-700 flex items-center justify-center text-slate-400 hover:text-white transition text-sm"
> >
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-5"> <form onSubmit={handleSave}>
<div className="px-6 py-5 space-y-6">
{/* ── Section 1: Eckdaten ── */}
<section className="space-y-4">
<SectionTitle number="1" label="Eckdaten" />
{/* Person Count */} {/* Person Count */}
<div> <div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Personen</label> <FieldLabel>Personenanzahl</FieldLabel>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2 mt-2">
{GUEST_COUNTS.map((n) => ( {PERSON_COUNTS.map((n) => (
<button <TouchButton
key={n} key={n}
type="button" active={guestCount === n && !customCount}
onClick={() => setGuestCount(n)} onClick={() => { setGuestCount(n); setCustomCount(''); }}
className={`w-11 h-11 rounded-xl font-bold text-sm transition border ${ className="w-12 h-12 text-base"
guestCount === n
? 'bg-amber-500 border-amber-400 text-slate-950 shadow-lg shadow-amber-500/20'
: 'bg-slate-800/60 border-slate-700 text-slate-300 hover:border-amber-500/50'
}`}
> >
{n} {n}
</button> </TouchButton>
))} ))}
<button {/* 10+ */}
type="button" <div className={`w-16 h-12 rounded-2xl border-2 flex items-center overflow-hidden transition ${
onClick={() => setGuestCount(15)} customCount ? 'border-amber-500 bg-amber-500/10' : 'border-slate-700 bg-slate-800/60'
className={`px-3 h-11 rounded-xl font-bold text-sm transition border ${ }`}>
guestCount >= 13 <input
? 'bg-amber-500 border-amber-400 text-slate-950' type="number"
: 'bg-slate-800/60 border-slate-700 text-slate-300 hover:border-amber-500/50' min={11}
}`} placeholder="10+"
> value={customCount}
12+ onChange={(e) => { setCustomCount(e.target.value); setGuestCount(0); }}
</button> className="w-full h-full text-center text-sm font-bold bg-transparent text-slate-100 focus:outline-none placeholder-slate-600"
/>
</div> </div>
</div> </div>
{eligibleTables.length > 0 && (
<p className="text-xs text-emerald-400 mt-2">
{eligibleTables.length} freier Tisch{eligibleTables.length !== 1 ? 'e' : ''} verfügbar
</p>
)}
{eligibleTables.length === 0 && effectiveCount > 0 && (
<p className="text-xs text-rose-400 mt-2">
Kein freier Tisch für {effectiveCount} Personen
</p>
)}
</div>
{/* Date & Time */} {/* Date */}
<div className="grid grid-cols-3 gap-3"> <div>
<div className="col-span-3 sm:col-span-1"> <FieldLabel>Datum</FieldLabel>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Datum</label>
<input <input
type="date" type="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition" className="mt-2 bg-slate-800/60 border border-slate-700 rounded-2xl px-4 py-3 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition w-full sm:w-56"
/> />
</div> </div>
{/* Time Slots */}
<div> <div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Von</label> <FieldLabel>Uhrzeit</FieldLabel>
<select <div className="flex flex-wrap gap-2 mt-2">
value={startTime} {TIME_SLOTS.map((t) => (
onChange={(e) => setStartTime(e.target.value)} <TouchButton
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition" key={t}
active={startTime === t}
onClick={() => setStartTime(t)}
className="px-4 h-11 text-sm font-mono"
> >
{TIME_SLOTS.map((s) => <option key={s}>{s}</option>)} {t}
</select> </TouchButton>
</div> ))}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Bis</label>
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition"
>
{TIME_SLOTS.map((s) => <option key={s}>{s}</option>)}
</select>
</div> </div>
</div> </div>
{/* Guest Info */} {/* Duration */}
<div> <div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Gastdaten</label> <FieldLabel>Aufenthaltsdauer</FieldLabel>
<div className="grid grid-cols-2 gap-3"> <div className="flex flex-wrap gap-2 mt-2">
<input {DURATION_OPTIONS.map((d) => (
<TouchButton
key={d.value}
active={duration === d.value}
onClick={() => setDuration(d.value)}
className="px-4 h-11 text-sm"
>
{d.label}
</TouchButton>
))}
</div>
<p className="text-xs text-slate-500 mt-1.5">
Ende: <span className="text-slate-300 font-mono">{addMinutes(startTime, duration)}</span>
</p>
</div>
</section>
<Divider />
{/* ── Section 2: Extras & Tags ── */}
<section className="space-y-4">
<SectionTitle number="2" label="Extras & Gastwünsche" />
<div className="flex flex-wrap gap-2">
{EXTRA_TAGS.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-2xl border-2 text-sm font-semibold transition select-none ${
tags.includes(tag.id)
? 'bg-purple-500/25 border-purple-400/70 text-purple-200 shadow-md shadow-purple-500/10'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-300'
}`}
>
<span className="text-base">{tag.icon}</span>
<span>{tag.label}</span>
</button>
))}
</div>
<div>
<FieldLabel>Sonstige Wünsche / Notizen</FieldLabel>
<textarea
rows={2}
placeholder="Freitext für besondere Hinweise..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-2 w-full bg-slate-800/60 border border-slate-700 rounded-2xl px-4 py-3 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition resize-none placeholder-slate-600"
/>
</div>
</section>
<Divider />
{/* ── Section 3: Gastdaten ── */}
<section className="space-y-4">
<SectionTitle number="3" label="Gastdaten" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<FieldLabel>Vorname <span className="text-rose-400">*</span></FieldLabel>
<TextInput
required required
type="text" placeholder="z. B. Maria"
placeholder="Vorname *"
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setFirstName(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/> />
<input </div>
type="text" <div>
placeholder="Nachname" <FieldLabel>Nachname</FieldLabel>
<TextInput
placeholder="z. B. Müller"
value={lastName} value={lastName}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setLastName(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/> />
<input </div>
<div>
<FieldLabel>Telefonnummer</FieldLabel>
<TextInput
type="tel" type="tel"
placeholder="Telefon" placeholder="+49 170 ..."
value={phone} value={phone}
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/> />
<input </div>
<div>
<FieldLabel>E-Mail <span className="text-slate-600">(optional)</span></FieldLabel>
<TextInput
type="email" type="email"
placeholder="E-Mail (optional)" placeholder="gast@email.de"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600"
/> />
</div> </div>
</div> </div>
</section>
<Divider />
{/* ── Section 4: Tisch ── */}
<section className="space-y-3">
<SectionTitle number="4" label="Tisch-Zuweisung" />
{/* Table Selection */}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
Tisch zuweisen
{suggestedTables.length > 0 && (
<span className="ml-2 text-emerald-400 font-medium normal-case">
{suggestedTables.length} freie Tische verfügbar
</span>
)}
</label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{/* Auto */}
<button
type="button"
onClick={() => setTableId('auto')}
className={`px-4 py-2.5 rounded-2xl border-2 text-sm font-semibold transition ${
tableId === 'auto'
? 'bg-amber-500/20 border-amber-400/70 text-amber-300'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
Automatisch
{tableId === 'auto' && eligibleTables[0] && (
<span className="ml-2 opacity-70"> {eligibleTables[0].number}</span>
)}
</button>
{/* Manual */}
{tables.map((t) => { {tables.map((t) => {
const isSuggested = suggestedTables.some((s) => s.id === t.id); const isFree = t.status === 'FREE';
const fits = t.maxCapacity >= effectiveCount;
return ( return (
<button <button
key={t.id} key={t.id}
type="button" type="button"
onClick={() => setTableId(t.id)} onClick={() => setTableId(t.id)}
className={`px-3 py-2 rounded-xl text-xs font-semibold border transition ${ className={`px-4 py-2.5 rounded-2xl border-2 text-sm font-semibold transition ${
tableId === t.id tableId === t.id
? 'bg-amber-500 border-amber-400 text-slate-950' ? 'bg-amber-500 border-amber-400 text-slate-950'
: isSuggested : fits && isFree
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/25' ? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/25'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500' : 'bg-slate-800/40 border-slate-800 text-slate-600 hover:border-slate-700'
}`} }`}
> >
{t.number} ({t.maxCapacity}P) {t.number}
{isSuggested && tableId !== t.id && <span className="ml-1"></span>} <span className="ml-1 font-normal opacity-70">({t.maxCapacity}P)</span>
{!isFree && <span className="ml-1 text-rose-400 text-xs"></span>}
</button> </button>
); );
})} })}
</div> </div>
</section>
</div> </div>
{/* Tags */} {/* ── Footer ── */}
<div> <div className="px-6 pb-6 flex gap-3">
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Tags / Besonderheiten</label>
<div className="flex gap-2 flex-wrap">
{TAGS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition ${
selectedTags.includes(tag)
? 'bg-purple-500/30 border-purple-400/60 text-purple-300'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
}`}
>
{tag === 'Hochstuhl' ? '🪑' : tag === 'Allergie' ? '⚠️' : tag === 'Fensterplatz' ? '🪟' : '🎂'} {tag}
</button>
))}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Notizen</label>
<textarea
rows={2}
placeholder="Besondere Wünsche, Hinweise..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full bg-slate-800/60 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition resize-none placeholder-slate-600"
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-1">
<button <button
type="button" type="button"
onClick={closeBookingModal} onClick={closeBookingModal}
className="flex-1 py-3 rounded-2xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold transition text-sm" className="flex-1 py-4 rounded-2xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-sm transition"
> >
Abbrechen Abbrechen
</button> </button>
<button <button
type="submit" type="submit"
className="flex-1 py-3 rounded-2xl bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold transition text-sm active:scale-95 shadow-lg shadow-amber-500/25" className="flex-[2] py-4 rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-slate-950 font-bold text-sm transition active:scale-[0.98] shadow-xl shadow-amber-500/20"
> >
Reservierung bestätigen Reservierung bestätigen
</button> </button>
</div> </div>
</form> </form>
@@ -276,3 +364,55 @@ export default function BookingModal() {
</div> </div>
); );
} }
// ─── Sub-components ─────────────────────────────────────────────────────────
function SectionTitle({ number, label }: { number: string; label: string }) {
return (
<div className="flex items-center gap-3">
<span className="w-6 h-6 rounded-full bg-amber-500/20 border border-amber-500/40 text-amber-400 text-xs font-bold flex items-center justify-center">
{number}
</span>
<span className="text-sm font-bold text-slate-200 uppercase tracking-wider">{label}</span>
</div>
);
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="block text-xs font-semibold text-slate-500 mt-0.5">{children}</label>;
}
function Divider() {
return <div className="border-t border-slate-800/80" />;
}
function TouchButton({
active, onClick, children, className = '',
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-2xl border-2 font-bold transition flex items-center justify-center select-none ${className} ${
active
? 'bg-amber-500 border-amber-400 text-slate-950 shadow-md shadow-amber-500/20'
: 'bg-slate-800/60 border-slate-700 text-slate-300 hover:border-amber-500/40 hover:text-amber-300'
}`}
>
{children}
</button>
);
}
function TextInput({ className = '', ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={`mt-1.5 w-full bg-slate-800/60 border border-slate-700 rounded-2xl px-4 py-3 text-sm text-slate-100 focus:outline-none focus:border-amber-500 transition placeholder-slate-600 ${className}`}
/>
);
}