From 9186b8c850233457d080ac598e9084df4984dc31 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 15 Apr 2026 23:21:51 +0200 Subject: [PATCH] feat: redesign reservations panel with unified toolbar and responsive grid - Unified toolbar with title, type filter pills (with count badges), and add button in one row - Cards redesigned: labeled fields in rounded boxes, status/type in header, edit/delete actions right-aligned - Responsive grid with max 3 columns, auto-filling full width - Type filters persist in sessionStorage per trip - Widen reservations tab container to match other tabs (1800px) --- .../components/Planner/ReservationsPanel.tsx | 531 +++++++++++------- client/src/pages/TripPlannerPage.tsx | 2 +- 2 files changed, 337 insertions(+), 196 deletions(-) diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 95942a9a..0c3402ac 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -50,6 +50,16 @@ function buildAssignmentLookup(days, assignments) { return map } +/* ── Shared field label style ── */ +const fieldLabelStyle: React.CSSProperties = { + fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', + color: 'var(--text-faint)', marginBottom: 5, +} +const fieldValueStyle: React.CSSProperties = { + fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', + padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, +} + interface ReservationCardProps { r: Reservation tripId: number @@ -93,175 +103,205 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) } + const hasDate = !!r.reservation_time + const hasTime = r.reservation_time?.includes('T') + const hasCode = !!r.confirmation_number + const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length + return ( -
- {/* Header bar */} -
-
- {canEdit ? ( - - ) : ( - +
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} + onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} + > + {/* Header */} +
+
+ + {confirmed ? t('reservations.confirmed') : t('reservations.pending')} + + + {t(typeInfo.labelKey)} + +
+
+ {r.title} + {canEdit && ( + + )} + {canEdit && ( + + )} +
+
+ + {/* Body */} +
+ {/* Date / Time / Code row */} + {(hasDate || hasCode) && ( +
1 ? `repeat(${dateCols}, 1fr)` : '1fr' }}> + {hasDate && ( +
+
{t('reservations.date')}
+
+ {fmtDate(r.reservation_time)} + {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && ( + <> – {fmtDate(r.reservation_end_time)} + )} +
+
+ )} + {hasTime && ( +
+
{t('reservations.time')}
+
+ {fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''} +
+
+ )} + {hasCode && ( +
+
{t('reservations.confirmationCode')}
+
blurCodes && setCodeRevealed(true)} + onMouseLeave={() => blurCodes && setCodeRevealed(false)} + onClick={() => blurCodes && setCodeRevealed(v => !v)} + style={{ + ...fieldValueStyle, textAlign: 'center', + fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 12.5, + filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none', + cursor: blurCodes ? 'pointer' : 'default', + transition: 'filter 0.2s', + }} + > + {r.confirmation_number} +
+
+ )} +
)} -
- - {t(typeInfo.labelKey)} - - {r.title} - {canEdit && ( - + + {/* Type-specific metadata */} + {(() => { + const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) + if (!meta || Object.keys(meta).length === 0) return null + const cells: { label: string; value: string }[] = [] + if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline }) + if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number }) + if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport }) + if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport }) + if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) + if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform }) + if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat }) + if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) }) + if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) }) + if (cells.length === 0) return null + return ( +
1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}> + {cells.map((c, i) => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+ ) + })()} + + {/* Location / Accommodation / Assignment */} + {r.location && ( +
+
{t('reservations.locationAddress')}
+
+ + {r.location} +
+
)} - {canEdit && ( - + {r.accommodation_name && ( +
+
{t('reservations.meta.linkAccommodation')}
+
+ + {r.accommodation_name} +
+
+ )} + {linked && ( +
+
{t('reservations.linkAssignment')}
+
+ + + {linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName} + {linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''} + +
+
+ )} + + {/* Notes */} + {r.notes && ( +
+
{t('reservations.notes')}
+
{r.notes}
+
+ )} + + {/* Files */} + {attachedFiles.length > 0 && ( + )}
- {/* Details */} - {(r.reservation_time || r.confirmation_number || r.location || linked || r.metadata) && ( -
- {/* Row 1: Date, Time, Code */} - {(r.reservation_time || r.confirmation_number) && ( -
- {r.reservation_time && ( -
-
{t('reservations.date')}
-
- {fmtDate(r.reservation_time)} - {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && ( - <> – {fmtDate(r.reservation_end_time)} - )} -
-
- )} - {r.reservation_time?.includes('T') && ( -
-
{t('reservations.time')}
-
- {fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''} -
-
- )} - {r.confirmation_number && ( -
-
{t('reservations.confirmationCode')}
-
blurCodes && setCodeRevealed(true)} - onMouseLeave={() => blurCodes && setCodeRevealed(false)} - onClick={() => blurCodes && setCodeRevealed(v => !v)} - style={{ - fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1, - filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none', - cursor: blurCodes ? 'pointer' : 'default', - transition: 'filter 0.2s', - }} - > - {r.confirmation_number} -
-
- )} -
- )} - {/* Row 1b: Type-specific metadata */} - {(() => { - const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) - if (!meta || Object.keys(meta).length === 0) return null - const cells: { label: string; value: string }[] = [] - if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline }) - if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number }) - if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport }) - if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport }) - if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) - if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform }) - if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat }) - if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) }) - if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) }) - if (cells.length === 0) return null - return ( -
- {cells.map((c, i) => ( -
-
{c.label}
-
{c.value}
-
- ))} -
- ) - })()} - {/* Row 2: Location + Assignment */} - {(r.location || linked || r.accommodation_name) && ( -
- {r.location && ( -
-
{t('reservations.locationAddress')}
-
- - {r.location} -
-
- )} - {r.accommodation_name && ( -
-
{t('reservations.meta.linkAccommodation')}
-
- - {r.accommodation_name} -
-
- )} - {linked && ( -
-
{t('reservations.linkAssignment')}
-
- - - {linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName} - {linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''} - -
-
- )} -
- )} -
- )} - - {/* Notes */} - {r.notes && ( -
-
{t('reservations.notes')}
-
- {r.notes} -
-
- )} - - {/* Files */} - {attachedFiles.length > 0 && ( - - )} - {/* Delete confirmation popup */} + {/* Delete confirmation */} {showDeleteConfirm && ReactDOM.createPortal(
+
- {open &&
{children}
} + {open && ( +
+ {children} +
+ )}
) } @@ -353,55 +398,151 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme const canEdit = can('reservation_edit', trip) const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint')) + const storageKey = `trek-reservation-filters-${tripId}` + const [typeFilters, setTypeFilters] = useState>(() => { + try { + const saved = sessionStorage.getItem(storageKey) + return saved ? new Set(JSON.parse(saved)) : new Set() + } catch { return new Set() } + }) + + const toggleTypeFilter = (type: string) => { + setTypeFilters(prev => { + const next = new Set(prev) + if (next.has(type)) next.delete(type); else next.add(type) + sessionStorage.setItem(storageKey, JSON.stringify([...next])) + return next + }) + } + const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments]) - const allPending = reservations.filter(r => r.status !== 'confirmed') - const allConfirmed = reservations.filter(r => r.status === 'confirmed') - const total = reservations.length + const filtered = useMemo(() => + typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)), + [reservations, typeFilters]) + + const allPending = filtered.filter(r => r.status !== 'confirmed') + const allConfirmed = filtered.filter(r => r.status === 'confirmed') + const total = filtered.length + + const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations]) + const typeCounts = useMemo(() => { + const counts: Record = {} + for (const r of reservations) counts[r.type] = (counts[r.type] || 0) + 1 + return counts + }, [reservations]) return (
- {/* Header */} -
-
-

{t('reservations.title')}

-

- {total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })} -

+ {/* Unified toolbar */} +
+
+

+ {t('reservations.title')} +

+ + {reservations.length > 0 && ( + <> +
+
+ + {TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => { + const active = typeFilters.has(opt.value) + const Icon = opt.Icon + return ( + + ) + })} +
+ + )} + + {canEdit && ( + + )}
- {canEdit && ( - - )}
{/* Content */} -
- {total === 0 ? ( +
+ {total === 0 && reservations.length === 0 ? (

{t('reservations.empty')}

{t('reservations.emptyHint')}

+ ) : total === 0 ? ( +
+

{t('places.noneFound')}

+
) : ( <> {allPending.length > 0 && (
-
- {allPending.map(r => )} -
+ {allPending.map(r => )}
)} {allConfirmed.length > 0 && (
-
- {allConfirmed.map(r => )} -
+ {allConfirmed.map(r => )}
)} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index b12c818f..07be4f66 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -910,7 +910,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )} {activeTab === 'buchungen' && ( -
+