From e38c5fed44135a41e00c425faee6cf2842f1637f Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 15 Apr 2026 22:54:23 +0200 Subject: [PATCH 1/9] feat: add uncategorized filter option to category dropdown Add a "No Category" option to the category filter dropdown in the places sidebar, allowing users to filter for places without an assigned category. The filter is synced with the map view. Closes #607 --- .../src/components/Planner/PlacesSidebar.tsx | 31 +++++++++++++++++-- client/src/pages/TripPlannerPage.tsx | 6 +++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 79f27b10..8fa69663 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -147,7 +147,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const filtered = useMemo(() => places.filter(p => { if (filter === 'unplanned' && plannedIds.has(p.id)) return false - if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false + if (categoryFilters.size > 0) { + if (p.category_id == null) { + if (!categoryFilters.has('uncategorized')) return false + } else if (!categoryFilters.has(String(p.category_id))) return false + } if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !(p.address || '').toLowerCase().includes(search.toLowerCase())) return false return true @@ -257,7 +261,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const label = categoryFilters.size === 0 ? t('places.allCategories') : categoryFilters.size === 1 - ? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories') + ? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')) : `${categoryFilters.size} ${t('places.categoriesSelected')}` return (
@@ -300,6 +304,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ ) })} + {places.some(p => p.category_id == null) && (() => { + const active = categoryFilters.has('uncategorized') + return ( + + ) + })()} {categoryFilters.size > 0 && ( - ) : ( - +
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' && ( -
+
Date: Wed, 15 Apr 2026 23:26:49 +0200 Subject: [PATCH 3/9] fix: reservations panel mobile responsiveness - Hide type filter pills on mobile (< md breakpoint) - Move add button right-aligned on mobile - Separate booking code into its own row below date/time - Hide weekday in date on mobile for space - Reduce padding on mobile --- .../components/Planner/ReservationsPanel.tsx | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 0c3402ac..399409b1 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -94,9 +94,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) } } + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const fmtDate = (str) => { const dateOnly = str.includes('T') ? str.split('T')[0] : str - return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) + return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) } const fmtTime = (str) => { const d = new Date(str) @@ -174,20 +175,18 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo {/* 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)} - )} -
+ {/* Date / Time row */} + {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')}
@@ -196,25 +195,26 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)} - {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} -
-
- )} +
+ )} + {/* Booking code */} + {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} +
)} @@ -435,7 +435,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme return (
{/* Unified toolbar */} -
+
0 && ( <> -
-
+
+
{/* Content */} -
+
{total === 0 && reservations.length === 0 ? (
From 099255761c785a04c735a41014557318280b60a0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 15 Apr 2026 23:53:16 +0200 Subject: [PATCH 4/9] feat: collab sub-feature toggles and provider icons - Add admin toggles for individual collab sections (Chat, Notes, Polls, What's Next) stored in app_settings - CollabPanel adapts layout dynamically: chat always fixed 380px, remaining panels share space equally - Mobile: disabled tabs are hidden - Add Immich and Synology Photos SVG icons to photo provider toggles - Add Luggage icon to bag tracking sub-toggle - API: GET/PUT /admin/collab-features endpoints - i18n: all 15 languages updated Closes #604 --- client/src/api/client.ts | 2 + client/src/components/Admin/AddonManager.tsx | 73 ++++++++- client/src/components/Collab/CollabPanel.tsx | 159 ++++++++++++++----- client/src/i18n/translations/ar.ts | 8 + client/src/i18n/translations/br.ts | 8 + client/src/i18n/translations/cs.ts | 8 + client/src/i18n/translations/de.ts | 8 + client/src/i18n/translations/en.ts | 8 + client/src/i18n/translations/es.ts | 8 + client/src/i18n/translations/fr.ts | 8 + client/src/i18n/translations/hu.ts | 8 + client/src/i18n/translations/id.ts | 8 + client/src/i18n/translations/it.ts | 8 + client/src/i18n/translations/nl.ts | 8 + client/src/i18n/translations/pl.ts | 8 + client/src/i18n/translations/ru.ts | 8 + client/src/i18n/translations/zh.ts | 8 + client/src/i18n/translations/zhTw.ts | 8 + client/src/pages/AdminPage.tsx | 8 + client/src/pages/TripPlannerPage.tsx | 4 +- server/src/app.ts | 2 + server/src/routes/admin.ts | 18 +++ server/src/services/adminService.ts | 25 +++ 23 files changed, 370 insertions(+), 41 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 48e6889d..b5916819 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -272,6 +272,8 @@ export const adminApi = { checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), + getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), + updateCollabFeatures: (features: Record) => apiClient.put('/admin/collab-features', features).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data), createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data), diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 8a564381..c2db2218 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,12 +4,33 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react' const ICON_MAP = { ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, } +function ImmichIcon({ size = 14 }: { size?: number }) { + return ( + + + + ) +} + +function SynologyIcon({ size = 14 }: { size?: number }) { + return ( + + + + ) +} + +const PROVIDER_ICONS: Record> = { + immich: ImmichIcon, + synologyphotos: SynologyIcon, +} + interface Addon { id: string name: string @@ -38,7 +59,16 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) { return } -export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) { +interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean } + +const COLLAB_SUB_FEATURES = [ + { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' }, + { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' }, + { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' }, + { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' }, +] as const + +export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) { const { t } = useTranslation() const dm = useSettingsStore(s => s.settings.dark_mode) const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -156,6 +186,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
+
{t('admin.bagTracking.title')}
{t('admin.bagTracking.subtitle')}
@@ -173,6 +204,36 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
)} + {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && ( +
+
+ {COLLAB_SUB_FEATURES.map(feat => { + const enabled = collabFeatures[feat.key] + const Icon = feat.icon + return ( +
+ +
+
{t(feat.titleKey)}
+
{t(feat.subtitleKey)}
+
+
+ + {enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ) + })} +
+
+ )}
))}
@@ -194,8 +255,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } {addon.id === 'journey' && providerOptions.length > 0 && (
- {providerOptions.map(provider => ( + {providerOptions.map(provider => { + const ProviderIcon = PROVIDER_ICONS[provider.key] + return (
+ {ProviderIcon && }
{provider.label}
{provider.description}
@@ -214,7 +278,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
- ))} + ) + })}
)} diff --git a/client/src/components/Collab/CollabPanel.tsx b/client/src/components/Collab/CollabPanel.tsx index e67dd825..55582f82 100644 --- a/client/src/components/Collab/CollabPanel.tsx +++ b/client/src/components/Collab/CollabPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react' @@ -29,54 +29,142 @@ interface TripMember { avatar_url?: string | null } +interface CollabFeatures { + chat: boolean + notes: boolean + polls: boolean + whatsnext: boolean +} + interface CollabPanelProps { tripId: number tripMembers?: TripMember[] + collabFeatures?: CollabFeatures } -export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) { +const ALL_TABS = [ + { id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle }, + { id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote }, + { id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 }, + { id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles }, +] + +export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) { const { user } = useAuthStore() const { t } = useTranslation() - const [mobileTab, setMobileTab] = useState('chat') const isDesktop = useIsDesktop() - const tabs = [ - { id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle }, - { id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote }, - { id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 }, - { id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles }, - ] + const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true } + + const tabs = useMemo(() => + ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({ + ...tab, + label: t(tab.labelKey) || tab.fallback, + })), + [features, t]) + + const [mobileTab, setMobileTab] = useState(() => tabs[0]?.id || 'chat') + + // If active tab gets disabled, switch to first available + useEffect(() => { + if (tabs.length > 0 && !tabs.some(t => t.id === mobileTab)) { + setMobileTab(tabs[0].id) + } + }, [tabs, mobileTab]) + + const chatOn = features.chat + const rightPanels = [ + features.notes && 'notes', + features.polls && 'polls', + features.whatsnext && 'whatsnext', + ].filter(Boolean) as string[] + + if (tabs.length === 0) return null if (isDesktop) { + // Chat always 380px fixed when on. Right panels share remaining space. + // If chat off, all panels share full width equally. + if (chatOn && rightPanels.length === 0) { + // Only chat + return ( +
+
+ +
+
+ ) + } + + if (chatOn) { + // Chat left (380px) + right panels + return ( +
+
+ +
+
+ {rightPanels.length === 1 && ( +
+ {rightPanels[0] === 'notes' && } + {rightPanels[0] === 'polls' && } + {rightPanels[0] === 'whatsnext' && } +
+ )} + {rightPanels.length === 2 && rightPanels.map(p => ( +
+ {p === 'notes' && } + {p === 'polls' && } + {p === 'whatsnext' && } +
+ ))} + {rightPanels.length === 3 && ( + <> +
+ +
+
+
+ +
+
+ +
+
+ + )} +
+
+ ) + } + + // Chat off — remaining panels share full width + const panels = rightPanels + if (panels.length === 1) { + return ( +
+
+ {panels[0] === 'notes' && } + {panels[0] === 'polls' && } + {panels[0] === 'whatsnext' && } +
+
+ ) + } + return (
- {/* Chat — left, fixed width */} -
- -
- - {/* Right column: Notes top, Polls + What's Next bottom */} -
- {/* Notes — top */} -
- + {panels.map(p => ( +
+ {p === 'notes' && } + {p === 'polls' && } + {p === 'whatsnext' && }
- - {/* Polls + What's Next — bottom row */} -
-
- -
-
- -
-
-
+ ))}
) } - // Mobile: tab bar + single panel + // Mobile: tab bar + single panel (only enabled tabs) return (
{tabs.map(tab => { - const Icon = tab.icon const active = mobileTab === tab.id return (
- {mobileTab === 'chat' && } - {mobileTab === 'notes' && } - {mobileTab === 'polls' && } - {mobileTab === 'next' && } + {mobileTab === 'chat' && features.chat && } + {mobileTab === 'notes' && features.notes && } + {mobileTab === 'polls' && features.polls && } + {mobileTab === 'next' && features.whatsnext && }
) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 82e28bfa..121f431f 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -584,6 +584,14 @@ const ar: Record = { // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'تتبع الأمتعة', 'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر', + 'admin.collab.chat.title': 'الدردشة', + 'admin.collab.chat.subtitle': 'المراسلة في الوقت الفعلي للتعاون', + 'admin.collab.notes.title': 'الملاحظات', + 'admin.collab.notes.subtitle': 'ملاحظات ومستندات مشتركة', + 'admin.collab.polls.title': 'الاستطلاعات', + 'admin.collab.polls.subtitle': 'استطلاعات وتصويت جماعي', + 'admin.collab.whatsnext.title': 'ما التالي', + 'admin.collab.whatsnext.subtitle': 'اقتراحات الأنشطة والخطوات التالية', 'admin.packingTemplates.title': 'قوالب التعبئة', 'admin.packingTemplates.subtitle': 'إنشاء قوائم تعبئة قابلة لإعادة الاستخدام', 'admin.packingTemplates.create': 'قالب جديد', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 7e914f25..6d5f2363 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -548,6 +548,14 @@ const br: Record = { // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Rastreamento de malas', 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Mensagens em tempo real para colaboração', + 'admin.collab.notes.title': 'Notas', + 'admin.collab.notes.subtitle': 'Notas e documentos compartilhados', + 'admin.collab.polls.title': 'Enquetes', + 'admin.collab.polls.subtitle': 'Enquetes e votações em grupo', + 'admin.collab.whatsnext.title': 'Próximos passos', + 'admin.collab.whatsnext.subtitle': 'Sugestões de atividades e próximos passos', 'admin.tabs.config': 'Personalização', 'admin.tabs.templates': 'Modelos de mala', 'admin.packingTemplates.title': 'Modelos de mala', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 97ac79ac..0b6bb15f 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -548,6 +548,14 @@ const cs: Record = { // Šablony balení (Packing Templates) 'admin.bagTracking.title': 'Sledování zavazadel', 'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Zasílání zpráv v reálném čase', + 'admin.collab.notes.title': 'Poznámky', + 'admin.collab.notes.subtitle': 'Sdílené poznámky a dokumenty', + 'admin.collab.polls.title': 'Ankety', + 'admin.collab.polls.subtitle': 'Skupinové ankety a hlasování', + 'admin.collab.whatsnext.title': 'Co dál', + 'admin.collab.whatsnext.subtitle': 'Návrhy aktivit a další kroky', 'admin.tabs.config': 'Personalizace', 'admin.tabs.templates': 'Šablony seznamů', 'admin.packingTemplates.title': 'Šablony pro balení', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 84668eb0..3f7990be 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -552,6 +552,14 @@ const de: Record = { // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Gepäck-Tracking', 'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Echtzeit-Nachrichten für die Reiseplanung', + 'admin.collab.notes.title': 'Notizen', + 'admin.collab.notes.subtitle': 'Gemeinsame Notizen und Dokumente', + 'admin.collab.polls.title': 'Umfragen', + 'admin.collab.polls.subtitle': 'Gruppen-Umfragen und Abstimmungen', + 'admin.collab.whatsnext.title': 'Was kommt als Nächstes', + 'admin.collab.whatsnext.subtitle': 'Aktivitätsvorschläge und nächste Schritte', 'admin.tabs.config': 'Personalisierung', 'admin.tabs.templates': 'Packvorlagen', 'admin.packingTemplates.title': 'Packvorlagen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 3dcae414..377b9d6a 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -608,6 +608,14 @@ const en: Record = { // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Bag Tracking', 'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Real-time messaging for trip collaboration', + 'admin.collab.notes.title': 'Notes', + 'admin.collab.notes.subtitle': 'Shared notes and documents', + 'admin.collab.polls.title': 'Polls', + 'admin.collab.polls.subtitle': 'Group polls and voting', + 'admin.collab.whatsnext.title': "What's Next", + 'admin.collab.whatsnext.subtitle': 'Activity suggestions and next steps', 'admin.tabs.config': 'Personalization', 'admin.tabs.templates': 'Packing Templates', 'admin.packingTemplates.title': 'Packing Templates', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index d3e193c6..12afa1c3 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -543,6 +543,14 @@ const es: Record = { 'admin.bagTracking.title': 'Seguimiento de equipaje', 'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Mensajería en tiempo real para la colaboración', + 'admin.collab.notes.title': 'Notas', + 'admin.collab.notes.subtitle': 'Notas y documentos compartidos', + 'admin.collab.polls.title': 'Encuestas', + 'admin.collab.polls.subtitle': 'Encuestas y votaciones grupales', + 'admin.collab.whatsnext.title': 'Qué sigue', + 'admin.collab.whatsnext.subtitle': 'Sugerencias de actividades y próximos pasos', 'admin.tabs.config': 'Personalización', 'admin.tabs.templates': 'Plantillas de equipaje', 'admin.packingTemplates.title': 'Plantillas de equipaje', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index da78b5b2..bea2019d 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -547,6 +547,14 @@ const fr: Record = { 'admin.bagTracking.title': 'Suivi des bagages', 'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Messagerie en temps réel pour la collaboration', + 'admin.collab.notes.title': 'Notes', + 'admin.collab.notes.subtitle': 'Notes et documents partagés', + 'admin.collab.polls.title': 'Sondages', + 'admin.collab.polls.subtitle': 'Sondages et votes de groupe', + 'admin.collab.whatsnext.title': 'Et ensuite', + 'admin.collab.whatsnext.subtitle': "Suggestions d'activités et prochaines étapes", 'admin.tabs.config': 'Personnalisation', 'admin.tabs.templates': 'Modèles de bagages', 'admin.packingTemplates.title': 'Modèles de bagages', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 4b03d7a7..df9930db 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -548,6 +548,14 @@ const hu: Record = { // Csomagolási sablonok és poggyászkövetés 'admin.bagTracking.title': 'Poggyászkövetés', 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Valós idejű üzenetküldés az együttműködéshez', + 'admin.collab.notes.title': 'Jegyzetek', + 'admin.collab.notes.subtitle': 'Megosztott jegyzetek és dokumentumok', + 'admin.collab.polls.title': 'Szavazások', + 'admin.collab.polls.subtitle': 'Csoportos szavazások', + 'admin.collab.whatsnext.title': 'Mi következik', + 'admin.collab.whatsnext.subtitle': 'Tevékenységjavaslatok és következő lépések', 'admin.tabs.config': 'Személyre szabás', 'admin.tabs.templates': 'Csomagolási sablonok', 'admin.packingTemplates.title': 'Csomagolási sablonok', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index a6d4f08f..97ea0e6d 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -608,6 +608,14 @@ const id: Record = { // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Pelacak Tas', 'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Pesan real-time untuk kolaborasi', + 'admin.collab.notes.title': 'Catatan', + 'admin.collab.notes.subtitle': 'Catatan dan dokumen bersama', + 'admin.collab.polls.title': 'Jajak Pendapat', + 'admin.collab.polls.subtitle': 'Jajak pendapat dan voting grup', + 'admin.collab.whatsnext.title': 'Selanjutnya', + 'admin.collab.whatsnext.subtitle': 'Saran aktivitas dan langkah selanjutnya', 'admin.tabs.config': 'Personalisasi', 'admin.tabs.templates': 'Template Packing', 'admin.packingTemplates.title': 'Template Packing', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index b1340b1a..fc0236d1 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -547,6 +547,14 @@ const it: Record = { // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Tracciamento valigia', 'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Messaggistica in tempo reale per la collaborazione', + 'admin.collab.notes.title': 'Note', + 'admin.collab.notes.subtitle': 'Note e documenti condivisi', + 'admin.collab.polls.title': 'Sondaggi', + 'admin.collab.polls.subtitle': 'Sondaggi e votazioni di gruppo', + 'admin.collab.whatsnext.title': 'Prossimi passi', + 'admin.collab.whatsnext.subtitle': 'Suggerimenti attività e prossimi passi', 'admin.tabs.config': 'Personalizzazione', 'admin.tabs.templates': 'Modelli lista valigia', 'admin.packingTemplates.title': 'Modelli lista valigia', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index ca216397..5b601888 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -548,6 +548,14 @@ const nl: Record = { 'admin.bagTracking.title': 'Bagagetracking', 'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems', + 'admin.collab.chat.title': 'Chat', + 'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking', + 'admin.collab.notes.title': 'Notities', + 'admin.collab.notes.subtitle': 'Gedeelde notities en documenten', + 'admin.collab.polls.title': 'Peilingen', + 'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen', + 'admin.collab.whatsnext.title': 'Wat nu', + 'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen', 'admin.tabs.config': 'Personalisatie', 'admin.tabs.templates': 'Paksjablonen', 'admin.packingTemplates.title': 'Paksjablonen', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 3f6e96ba..38a30885 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -520,6 +520,14 @@ const pl: Record = { // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Kontrola bagażu', 'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania', + 'admin.collab.chat.title': 'Czat', + 'admin.collab.chat.subtitle': 'Wiadomości w czasie rzeczywistym', + 'admin.collab.notes.title': 'Notatki', + 'admin.collab.notes.subtitle': 'Wspólne notatki i dokumenty', + 'admin.collab.polls.title': 'Ankiety', + 'admin.collab.polls.subtitle': 'Ankiety grupowe i głosowania', + 'admin.collab.whatsnext.title': 'Co dalej', + 'admin.collab.whatsnext.subtitle': 'Sugestie aktywności i następne kroki', 'admin.tabs.config': 'Personalizacja', 'admin.tabs.templates': 'Szablony pakowania', 'admin.packingTemplates.title': 'Szablony pakowania', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 8786fc52..693081a3 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -548,6 +548,14 @@ const ru: Record = { 'admin.bagTracking.title': 'Отслеживание багажа', 'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей', + 'admin.collab.chat.title': 'Чат', + 'admin.collab.chat.subtitle': 'Обмен сообщениями для совместной работы', + 'admin.collab.notes.title': 'Заметки', + 'admin.collab.notes.subtitle': 'Общие заметки и документы', + 'admin.collab.polls.title': 'Опросы', + 'admin.collab.polls.subtitle': 'Групповые опросы и голосования', + 'admin.collab.whatsnext.title': 'Что дальше', + 'admin.collab.whatsnext.subtitle': 'Предложения активностей и следующие шаги', 'admin.tabs.config': 'Персонализация', 'admin.tabs.templates': 'Шаблоны упаковки', 'admin.packingTemplates.title': 'Шаблоны упаковки', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 18012afe..2ec0d9f8 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -548,6 +548,14 @@ const zh: Record = { 'admin.bagTracking.title': '行李追踪', 'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配', + 'admin.collab.chat.title': '聊天', + 'admin.collab.chat.subtitle': '实时消息协作', + 'admin.collab.notes.title': '笔记', + 'admin.collab.notes.subtitle': '共享笔记和文档', + 'admin.collab.polls.title': '投票', + 'admin.collab.polls.subtitle': '群组投票和表决', + 'admin.collab.whatsnext.title': '下一步', + 'admin.collab.whatsnext.subtitle': '活动建议和后续步骤', 'admin.tabs.config': '个性化', 'admin.tabs.templates': '打包模板', 'admin.packingTemplates.title': '打包模板', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index b54e23f6..708c1156 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -604,6 +604,14 @@ const zhTw: Record = { 'admin.bagTracking.title': '行李追蹤', 'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配', + 'admin.collab.chat.title': '聊天', + 'admin.collab.chat.subtitle': '即時訊息協作', + 'admin.collab.notes.title': '筆記', + 'admin.collab.notes.subtitle': '共享筆記和文件', + 'admin.collab.polls.title': '投票', + 'admin.collab.polls.subtitle': '群組投票和表決', + 'admin.collab.whatsnext.title': '下一步', + 'admin.collab.whatsnext.subtitle': '活動建議和後續步驟', 'admin.tabs.config': '配置', 'admin.tabs.templates': '打包模板', 'admin.packingTemplates.title': '打包模板', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 38a9728c..cf31f802 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -192,6 +192,10 @@ export default function AdminPage(): React.ReactElement { const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false) useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, []) + // Collab features + const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) + useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, []) + // OIDC config const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' }) const [savingOidc, setSavingOidc] = useState(false) @@ -797,6 +801,10 @@ export default function AdminPage(): React.ReactElement { const next = !bagTrackingEnabled setBagTrackingEnabled(next) try { await adminApi.updateBagTracking(next) } catch { setBagTrackingEnabled(!next) } + }} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => { + const next = { ...collabFeatures, [key]: !collabFeatures[key] } + setCollabFeatures(next) + try { await adminApi.updateCollabFeatures({ [key]: next[key] }) } catch { setCollabFeatures(collabFeatures) } }} />
)} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 07be4f66..0f152392 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -100,6 +100,7 @@ export default function TripPlannerPage(): React.ReactElement | null { }, [undo, lastActionLabel, toast]) const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true, collab: false }) + const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) const [tripAccommodations, setTripAccommodations] = useState([]) const [allowedFileTypes, setAllowedFileTypes] = useState(null) const [tripMembers, setTripMembers] = useState([]) @@ -116,6 +117,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const map = {} data.addons.forEach(a => { map[a.id] = true }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab }) + if (data.collabFeatures) setCollabFeatures(data.collabFeatures) }).catch(() => {}) authApi.getAppConfig().then(config => { if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) @@ -956,7 +958,7 @@ export default function TripPlannerPage(): React.ReactElement | null { {activeTab === 'collab' && (
- +
)}
diff --git a/server/src/app.ts b/server/src/app.ts index f4c91791..8f590b72 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -45,6 +45,7 @@ import publicConfigRoutes from './routes/publicConfig'; import { mcpHandler } from './mcp'; import { Addon } from './types'; import { getPhotoProviderConfig } from './services/memories/helpersService'; +import { getCollabFeatures } from './services/adminService'; export function createApp(): express.Application { const app = express(); @@ -236,6 +237,7 @@ export function createApp(): express.Application { } res.json({ + collabFeatures: getCollabFeatures(), addons: [ ...addons.map(a => ({ ...a, enabled: !!a.enabled })), ...providers.map(p => ({ diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 5184d4c6..67a4fa2e 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -200,6 +200,24 @@ router.put('/bag-tracking', (req: Request, res: Response) => { res.json(result); }); +// ── Collab Features ─────────────────────────────────────────────────────── + +router.get('/collab-features', (_req: Request, res: Response) => { + res.json(svc.getCollabFeatures()); +}); + +router.put('/collab-features', (req: Request, res: Response) => { + const result = svc.updateCollabFeatures(req.body); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.collab_features', + ip: getClientIp(req), + details: result, + }); + res.json(result); +}); + // ── Packing Templates ────────────────────────────────────────────────────── router.get('/packing-templates', (_req: Request, res: Response) => { diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index eec43ecd..81674b54 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -459,6 +459,31 @@ export function updateBagTracking(enabled: boolean) { return { enabled: !!enabled }; } +// ── Collab Features ─────────────────────────────────────────────────────── + +const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const; + +export function getCollabFeatures() { + const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[]; + const map: Record = {}; + for (const r of rows) map[r.key] = r.value; + return { + chat: map['collab_chat_enabled'] !== 'false', + notes: map['collab_notes_enabled'] !== 'false', + polls: map['collab_polls_enabled'] !== 'false', + whatsnext: map['collab_whatsnext_enabled'] !== 'false', + }; +} + +export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) { + const mapping: Record = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' }; + const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); + for (const [feat, key] of Object.entries(mapping)) { + if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false'); + } + return getCollabFeatures(); +} + // ── Packing Templates ────────────────────────────────────────────────────── export function listPackingTemplates() { From 7befb7d5550beb66a37412fdbccff84001ef1883 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 15 Apr 2026 23:57:09 +0200 Subject: [PATCH 5/9] feat: enable naver list import by default, remove addon toggle - Remove addon check from naver import endpoint - Naver import always available alongside Google list import - Migration 101: auto-enable naver_list_import for existing installs - Remove unused isAddonEnabled import from places route - Remove unused useAddonStore import from PlacesSidebar --- client/src/components/Planner/PlacesSidebar.tsx | 3 +-- server/src/db/migrations.ts | 5 +++++ server/src/db/seeds.ts | 2 +- server/src/routes/places.ts | 5 ----- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 8fa69663..23438fc0 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -10,7 +10,6 @@ import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' -import { useAddonStore } from '../../store/addonStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' import FileImportModal from './FileImportModal' @@ -44,7 +43,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) - const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import')) + const isNaverListImportEnabled = true const [fileImportOpen, setFileImportOpen] = useState(false) const [sidebarDropFile, setSidebarDropFile] = useState(null) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index d7202469..a63c5756 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1605,6 +1605,11 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at); `); }, + + // Migration 101: Enable naver_list_import by default + () => { + db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run(); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 9245d79c..d6e01d54 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -92,7 +92,7 @@ function seedAddons(db: Database.Database): void { { id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 }, { id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 }, { id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 }, - { id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 }, + { id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 }, { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, { id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 }, ]; diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 72becb7c..be0d8f1f 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -5,7 +5,6 @@ import { requireTripAccess } from '../middleware/tripAccess'; import { broadcast } from '../websocket'; import { validateStringLengths } from '../middleware/validate'; import { checkPermission } from '../services/permissions'; -import { isAddonEnabled } from '../services/adminService'; import { AuthRequest } from '../types'; import { listPlaces, @@ -135,10 +134,6 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R const authReq = req as AuthRequest; if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - if (!isAddonEnabled('naver_list_import')) { - return res.status(403).json({ error: 'Naver list import addon is disabled' }); - } - const { tripId } = req.params; const { url } = req.body; if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' }); From 975846c2363d753b4c259785fd2c5d07f1d39416 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 16 Apr 2026 00:04:14 +0200 Subject: [PATCH 6/9] fix: update tests for naver always-on and reservations redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove server test for naver addon disabled (addon check removed) - Update PlacesSidebar tests: "Google List" → "Import List" (both providers always shown) - Update ReservationsPanel tests: status is always a span (no toggle), remove click-to-toggle test, update summary test --- .../components/Planner/PlacesSidebar.test.tsx | 8 +++--- .../Planner/ReservationsPanel.test.tsx | 27 +++++-------------- server/tests/integration/places.test.ts | 15 ----------- 3 files changed, 11 insertions(+), 39 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index dc25a418..c72d63f4 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -473,14 +473,14 @@ describe('Google Maps list import', () => { it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Google List/i)); + await user.click(screen.getByText(/Import List/i)); expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Google List/i)); + await user.click(screen.getByText(/Import List/i)); await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); const importBtn = screen.getByRole('button', { name: /^Import$/i }); expect(importBtn).toBeDisabled(); @@ -498,7 +498,7 @@ describe('Google Maps list import', () => { (window as any).__addToast = addToast; const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Google List/i)); + await user.click(screen.getByText(/Import List/i)); const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); await user.type(urlInput, 'https://maps.app.goo.gl/abc123'); await user.click(screen.getByRole('button', { name: /^Import$/i })); @@ -527,7 +527,7 @@ describe('Google Maps list import', () => { (window as any).__addToast = addToast; const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Google List/i)); + await user.click(screen.getByText(/Import List/i)); const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}'); await waitFor(() => { diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index 235e3acb..f461251e 100644 --- a/client/src/components/Planner/ReservationsPanel.test.tsx +++ b/client/src/components/Planner/ReservationsPanel.test.tsx @@ -91,12 +91,12 @@ describe('ReservationsPanel', () => { expect(els.length).toBeGreaterThan(0); }); - it('FE-COMP-RES-010: shows summary text with confirmed and pending counts', () => { + it('FE-COMP-RES-010: shows reservations title and cards', () => { const r1 = buildReservation({ title: 'Flight', type: 'flight', status: 'confirmed' }); const r2 = buildReservation({ title: 'Hotel', type: 'hotel', status: 'pending' }); render(); - // reservations.summary = "{confirmed} confirmed, {pending} pending" - expect(screen.getByText(/1 confirmed, 1 pending/i)).toBeInTheDocument(); + expect(screen.getByText('Flight')).toBeInTheDocument(); + expect(screen.getByText('Hotel')).toBeInTheDocument(); }); it('FE-COMP-RES-011: hotel reservation renders', () => { @@ -288,27 +288,14 @@ describe('ReservationsPanel', () => { // ── Status toggle (canEdit=true) ──────────────────────────────────────────── - it('FE-PLANNER-RESP-030: status label is a button when canEdit=true', () => { - // Default: permissions empty → canEdit=true + it('FE-PLANNER-RESP-030: status label is always a span (not clickable)', () => { const res = buildReservation({ title: 'My Booking', status: 'pending' }); render(); - // Status badge in card header is a button const pendingEls = screen.getAllByText('Pending'); + const statusSpan = pendingEls.find(el => el.tagName === 'SPAN'); + expect(statusSpan).toBeDefined(); const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON'); - expect(statusBtn).toBeDefined(); - }); - - it('FE-PLANNER-RESP-031: clicking status button calls toggleReservationStatus', async () => { - const user = userEvent.setup(); - const toggleReservationStatus = vi.fn().mockResolvedValue(undefined); - // Seed the store with a mock toggleReservationStatus function - useTripStore.setState({ toggleReservationStatus } as any); - const res = buildReservation({ id: 42, title: 'Toggle Me', status: 'pending' }); - render(); - const pendingEls = screen.getAllByText('Pending'); - const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON'); - await user.click(statusBtn!); - await waitFor(() => expect(toggleReservationStatus).toHaveBeenCalledWith(1, 42)); + expect(statusBtn).toBeUndefined(); }); // ── Status (canEdit=false) ────────────────────────────────────────────────── diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index 00832125..75af1cf0 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -525,21 +525,6 @@ describe('Naver list import', () => { vi.unstubAllGlobals(); }); - it('POST /import/naver-list returns 403 when addon is disabled', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - - testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'naver_list_import'").run(); - - const res = await request(app) - .post(`/api/trips/${trip.id}/places/import/naver-list`) - .set('Cookie', authCookie(user.id)) - .send({ url: 'https://naver.me/GYDpx3Wv' }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain('addon is disabled'); - }); - it('POST /import/naver-list resolves shortlink, paginates, and creates places', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); From 125436fa87b54bbbc875c654cf8bddeda46fac3c Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 16 Apr 2026 00:12:06 +0200 Subject: [PATCH 7/9] fix: correct test matchers for list import and reservations - PlacesSidebar: match "List Import" (actual i18n value) not "Import List" - ReservationsPanel: use unique titles to avoid matching filter buttons --- client/src/components/Planner/PlacesSidebar.test.tsx | 8 ++++---- client/src/components/Planner/ReservationsPanel.test.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index c72d63f4..7d7221da 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -473,14 +473,14 @@ describe('Google Maps list import', () => { it('FE-PLANNER-SIDEBAR-040: "Google List" button opens the URL dialog', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Import List/i)); + await user.click(screen.getByText(/List Import/i)); expect(await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i)).toBeInTheDocument(); }); it('FE-PLANNER-SIDEBAR-041: import button disabled when URL input is empty', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Import List/i)); + await user.click(screen.getByText(/List Import/i)); await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); const importBtn = screen.getByRole('button', { name: /^Import$/i }); expect(importBtn).toBeDisabled(); @@ -498,7 +498,7 @@ describe('Google Maps list import', () => { (window as any).__addToast = addToast; const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Import List/i)); + await user.click(screen.getByText(/List Import/i)); const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); await user.type(urlInput, 'https://maps.app.goo.gl/abc123'); await user.click(screen.getByRole('button', { name: /^Import$/i })); @@ -527,7 +527,7 @@ describe('Google Maps list import', () => { (window as any).__addToast = addToast; const user = userEvent.setup(); render(); - await user.click(screen.getByText(/Import List/i)); + await user.click(screen.getByText(/List Import/i)); const urlInput = await screen.findByPlaceholderText(/maps\.app\.goo\.gl/i); await user.type(urlInput, 'https://maps.app.goo.gl/xyz{Enter}'); await waitFor(() => { diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index f461251e..2dcd9c86 100644 --- a/client/src/components/Planner/ReservationsPanel.test.tsx +++ b/client/src/components/Planner/ReservationsPanel.test.tsx @@ -92,11 +92,11 @@ describe('ReservationsPanel', () => { }); it('FE-COMP-RES-010: shows reservations title and cards', () => { - const r1 = buildReservation({ title: 'Flight', type: 'flight', status: 'confirmed' }); - const r2 = buildReservation({ title: 'Hotel', type: 'hotel', status: 'pending' }); + const r1 = buildReservation({ title: 'My Flight Booking', type: 'flight', status: 'confirmed' }); + const r2 = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'pending' }); render(); - expect(screen.getByText('Flight')).toBeInTheDocument(); - expect(screen.getByText('Hotel')).toBeInTheDocument(); + expect(screen.getByText('My Flight Booking')).toBeInTheDocument(); + expect(screen.getByText('Grand Hotel')).toBeInTheDocument(); }); it('FE-COMP-RES-011: hotel reservation renders', () => { From 409a63633c46bbf4abca78b98dbd1e353e7c7ff1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 16 Apr 2026 00:23:00 +0200 Subject: [PATCH 8/9] feat: support check-in time ranges for hotel accommodations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add check_in_end column to day_accommodations (Migration 102) - Server: create/update accommodation accepts check_in_end - Bidirectional sync: check_in_end synced between accommodation and linked reservation metadata (check_in_end_time) - DayDetailPanel: shows check-in range (e.g. "14:00 – 22:00"), new "Until" time picker in hotel form - ReservationModal: new check-in-until field for hotel bookings - ReservationsPanel: displays check-in range in metadata cells - i18n: checkInUntil keys in all 15 languages Closes #366 --- .../src/components/Planner/DayDetailPanel.tsx | 22 +++++++++++++------ .../components/Planner/ReservationModal.tsx | 13 ++++++++--- .../components/Planner/ReservationsPanel.tsx | 2 +- client/src/i18n/translations/ar.ts | 2 ++ client/src/i18n/translations/br.ts | 2 ++ client/src/i18n/translations/cs.ts | 2 ++ client/src/i18n/translations/de.ts | 2 ++ client/src/i18n/translations/en.ts | 2 ++ client/src/i18n/translations/es.ts | 2 ++ client/src/i18n/translations/fr.ts | 2 ++ client/src/i18n/translations/hu.ts | 2 ++ client/src/i18n/translations/id.ts | 2 ++ client/src/i18n/translations/it.ts | 2 ++ client/src/i18n/translations/nl.ts | 2 ++ client/src/i18n/translations/pl.ts | 2 ++ client/src/i18n/translations/ru.ts | 2 ++ client/src/i18n/translations/zh.ts | 2 ++ client/src/i18n/translations/zhTw.ts | 2 ++ client/src/types.ts | 1 + server/src/db/migrations.ts | 5 +++++ server/src/db/schema.ts | 1 + server/src/routes/days.ts | 8 +++---- server/src/services/dayService.ts | 17 +++++++++----- server/src/services/reservationService.ts | 12 +++++----- 24 files changed, 84 insertions(+), 27 deletions(-) diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index c1a4acc3..3ff8b102 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -78,7 +78,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const [showHotelPicker, setShowHotelPicker] = useState(false) const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id }) const [hotelCategoryFilter, setHotelCategoryFilter] = useState('') - const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null }) + const [hotelForm, setHotelForm] = useState({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) useEffect(() => { if (!day?.date || !lat || !lng) { setWeather(null); return } @@ -117,6 +117,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri start_day_id: hotelDayRange.start, end_day_id: hotelDayRange.end, check_in: hotelForm.check_in || null, + check_in_end: hotelForm.check_in_end || null, check_out: hotelForm.check_out || null, confirmation: hotelForm.confirmation || null, }) @@ -128,7 +129,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) )) setShowHotelPicker(false) - setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null }) + setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) onAccommodationChange?.() } catch {} } @@ -356,7 +357,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{acc.place_name}
{acc.place_address &&
{acc.place_address}
}
- {canEditDays && } @@ -368,7 +369,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{acc.check_in && (
-
{fmtTime(acc.check_in)}
+
+ {fmtTime(acc.check_in)}{acc.check_in_end ? ` – ${fmtTime(acc.check_in_end)}` : ''} +
{t('day.checkIn')}
@@ -488,11 +491,15 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri {/* Check-in / Check-out / Confirmation */}
-
+
setHotelForm(f => ({ ...f, check_in: v }))} placeholder="14:00" />
-
+
+ + setHotelForm(f => ({ ...f, check_in_end: v }))} placeholder="22:00" /> +
+
setHotelForm(f => ({ ...f, check_out: v }))} placeholder="11:00" />
@@ -570,11 +577,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri start_day_id: hotelDayRange.start, end_day_id: hotelDayRange.end, check_in: hotelForm.check_in || null, + check_in_end: hotelForm.check_in_end || null, check_out: hotelForm.check_out || null, confirmation: hotelForm.confirmation || null, }) setShowHotelPicker(false) - setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null }) + setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) // Reload accommodationsApi.list(tripId).then(d => { const all = d.accommodations || [] diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index a70924ed..fb2bb01b 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -89,7 +89,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', meta_departure_timezone: '', meta_arrival_timezone: '', meta_train_number: '', meta_platform: '', meta_seat: '', - meta_check_in_time: '', meta_check_out_time: '', + meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '', hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', }) const [isSaving, setIsSaving] = useState(false) @@ -140,6 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p meta_platform: meta.platform || '', meta_seat: meta.seat || '', meta_check_in_time: meta.check_in_time || '', + meta_check_in_end_time: meta.check_in_end_time || '', meta_check_out_time: meta.check_out_time || '', hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(), hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(), @@ -156,7 +157,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', meta_departure_timezone: '', meta_arrival_timezone: '', meta_train_number: '', meta_platform: '', meta_seat: '', - meta_check_in_time: '', meta_check_out_time: '', + meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '', }) setPendingFiles([]) } @@ -207,6 +208,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone } else if (form.type === 'hotel') { if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time + if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time } else if (form.type === 'train') { if (form.meta_train_number) metadata.train_number = form.meta_train_number @@ -245,6 +247,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p start_day_id: form.hotel_start_day, end_day_id: form.hotel_end_day, check_in: form.meta_check_in_time || null, + check_in_end: form.meta_check_in_end_time || null, check_out: form.meta_check_out_time || null, confirmation: form.confirmation_number || null, } @@ -526,11 +529,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{/* Check-in/out times + Status */} -
+
set('meta_check_in_time', v)} />
+
+ + set('meta_check_in_end_time', v)} /> +
set('meta_check_out_time', v)} /> diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 399409b1..51f85e5e 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -230,7 +230,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo 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_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_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 ( diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 121f431f..1a2e35ce 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1015,6 +1015,7 @@ const ar: Record = { 'reservations.meta.platform': 'المنصة', 'reservations.meta.seat': 'المقعد', 'reservations.meta.checkIn': 'تسجيل الوصول', + 'reservations.meta.checkInUntil': 'تسجيل الدخول حتى', 'reservations.meta.checkOut': 'تسجيل المغادرة', 'reservations.meta.linkAccommodation': 'الإقامة', 'reservations.meta.pickAccommodation': 'ربط بالإقامة', @@ -1499,6 +1500,7 @@ const ar: Record = { 'day.noPlacesForHotel': 'أضف أماكن إلى رحلتك أولًا', 'day.allDays': 'الكل', 'day.checkIn': 'تسجيل الوصول', + 'day.checkInUntil': 'حتى', 'day.checkOut': 'تسجيل المغادرة', 'day.confirmation': 'التأكيد', 'day.editAccommodation': 'تعديل الإقامة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 6d5f2363..98f59e05 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -984,6 +984,7 @@ const br: Record = { 'reservations.meta.platform': 'Plataforma', 'reservations.meta.seat': 'Assento', 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkInUntil': 'Check-in até', 'reservations.meta.checkOut': 'Check-out', 'reservations.meta.linkAccommodation': 'Hospedagem', 'reservations.meta.pickAccommodation': 'Vincular à hospedagem', @@ -1468,6 +1469,7 @@ const br: Record = { 'day.noPlacesForHotel': 'Adicione lugares à viagem primeiro', 'day.allDays': 'Todos', 'day.checkIn': 'Check-in', + 'day.checkInUntil': 'Até', 'day.checkOut': 'Check-out', 'day.confirmation': 'Confirmação', 'day.editAccommodation': 'Editar hospedagem', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 0b6bb15f..2b57ef61 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1013,6 +1013,7 @@ const cs: Record = { 'reservations.meta.platform': 'Nástupiště', 'reservations.meta.seat': 'Sedadlo', 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkInUntil': 'Check-in do', 'reservations.meta.checkOut': 'Check-out', 'reservations.meta.linkAccommodation': 'Ubytování', 'reservations.meta.pickAccommodation': 'Propojit s ubytováním', @@ -1497,6 +1498,7 @@ const cs: Record = { 'day.noPlacesForHotel': 'Nejprve přidejte místa ke své cestě', 'day.allDays': 'Vše', 'day.checkIn': 'Check-in', + 'day.checkInUntil': 'Do', 'day.checkOut': 'Check-out', 'day.confirmation': 'Potvrzení', 'day.editAccommodation': 'Upravit ubytování', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 3f7990be..1547e631 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1015,6 +1015,7 @@ const de: Record = { 'reservations.meta.platform': 'Gleis', 'reservations.meta.seat': 'Sitzplatz', 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkInUntil': 'Check-in bis', 'reservations.meta.checkOut': 'Check-out', 'reservations.meta.linkAccommodation': 'Unterkunft', 'reservations.meta.pickAccommodation': 'Mit Unterkunft verknüpfen', @@ -1499,6 +1500,7 @@ const de: Record = { 'day.noPlacesForHotel': 'Füge zuerst Orte zu deiner Reise hinzu', 'day.allDays': 'Alle', 'day.checkIn': 'Check-in', + 'day.checkInUntil': 'Bis', 'day.checkOut': 'Check-out', 'day.confirmation': 'Bestätigung', 'day.editAccommodation': 'Unterkunft bearbeiten', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 377b9d6a..c4d5588f 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1068,6 +1068,7 @@ const en: Record = { 'reservations.meta.platform': 'Platform', 'reservations.meta.seat': 'Seat', 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkInUntil': 'Check-in until', 'reservations.meta.checkOut': 'Check-out', 'reservations.meta.linkAccommodation': 'Accommodation', 'reservations.meta.pickAccommodation': 'Link to accommodation', @@ -1552,6 +1553,7 @@ const en: Record = { 'day.noPlacesForHotel': 'Add places to your trip first', 'day.allDays': 'All', 'day.checkIn': 'Check-in', + 'day.checkInUntil': 'Until', 'day.checkOut': 'Check-out', 'day.confirmation': 'Confirmation', 'day.editAccommodation': 'Edit accommodation', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 12afa1c3..061bfa9c 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1448,6 +1448,7 @@ const es: Record = { 'day.noPlacesForHotel': 'Añade primero lugares al viaje', 'day.allDays': 'Todos', 'day.checkIn': 'Registro de entrada', + 'day.checkInUntil': 'Hasta', 'day.checkOut': 'Registro de salida', 'day.confirmation': 'Confirmación', 'day.editAccommodation': 'Editar alojamiento', @@ -1615,6 +1616,7 @@ const es: Record = { 'reservations.meta.platform': 'Andén', 'reservations.meta.seat': 'Asiento', 'reservations.meta.checkIn': 'Registro de entrada', + 'reservations.meta.checkInUntil': 'Check-in hasta', 'reservations.meta.checkOut': 'Registro de salida', 'reservations.meta.linkAccommodation': 'Alojamiento', 'reservations.meta.pickAccommodation': 'Vincular con alojamiento', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index bea2019d..2050f551 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1011,6 +1011,7 @@ const fr: Record = { 'reservations.meta.platform': 'Quai', 'reservations.meta.seat': 'Place', 'reservations.meta.checkIn': 'Arrivée', + 'reservations.meta.checkInUntil': "Check-in jusqu'à", 'reservations.meta.checkOut': 'Départ', 'reservations.meta.linkAccommodation': 'Hébergement', 'reservations.meta.pickAccommodation': 'Lier à un hébergement', @@ -1495,6 +1496,7 @@ const fr: Record = { 'day.noPlacesForHotel': 'Ajoutez d\'abord des lieux à votre voyage', 'day.allDays': 'Tous', 'day.checkIn': 'Arrivée', + 'day.checkInUntil': "Jusqu'à", 'day.checkOut': 'Départ', 'day.confirmation': 'Confirmation', 'day.editAccommodation': 'Modifier l\'hébergement', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index df9930db..3e9083e3 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1013,6 +1013,7 @@ const hu: Record = { 'reservations.meta.platform': 'Vágány', 'reservations.meta.seat': 'Ülés', 'reservations.meta.checkIn': 'Bejelentkezés', + 'reservations.meta.checkInUntil': 'Bejelentkezés eddig', 'reservations.meta.checkOut': 'Kijelentkezés', 'reservations.meta.linkAccommodation': 'Szállás', 'reservations.meta.pickAccommodation': 'Szállás hozzárendelése', @@ -1496,6 +1497,7 @@ const hu: Record = { 'day.noPlacesForHotel': 'Először adj hozzá helyeket az utazásodhoz', 'day.allDays': 'Összes', 'day.checkIn': 'Bejelentkezés', + 'day.checkInUntil': 'Eddig', 'day.checkOut': 'Kijelentkezés', 'day.confirmation': 'Visszaigazolás', 'day.editAccommodation': 'Szállás szerkesztése', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 97ea0e6d..82b3e317 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -1068,6 +1068,7 @@ const id: Record = { 'reservations.meta.platform': 'Peron', 'reservations.meta.seat': 'Kursi', 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkInUntil': 'Check-in sampai', 'reservations.meta.checkOut': 'Check-out', 'reservations.meta.linkAccommodation': 'Akomodasi', 'reservations.meta.pickAccommodation': 'Hubungkan ke akomodasi', @@ -1552,6 +1553,7 @@ const id: Record = { 'day.noPlacesForHotel': 'Tambahkan tempat ke perjalananmu terlebih dahulu', 'day.allDays': 'Semua', 'day.checkIn': 'Check-in', + 'day.checkInUntil': 'Sampai', 'day.checkOut': 'Check-out', 'day.confirmation': 'Konfirmasi', 'day.editAccommodation': 'Edit akomodasi', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index fc0236d1..fc90c6aa 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1012,6 +1012,7 @@ const it: Record = { 'reservations.meta.platform': 'Binario', 'reservations.meta.seat': 'Posto', 'reservations.meta.checkIn': 'Check-in', + 'reservations.meta.checkInUntil': 'Check-in fino a', 'reservations.meta.checkOut': 'Check-out', 'reservations.meta.linkAccommodation': 'Alloggio', 'reservations.meta.pickAccommodation': 'Collega a un alloggio', @@ -1496,6 +1497,7 @@ const it: Record = { 'day.noPlacesForHotel': 'Aggiungi prima i luoghi al tuo viaggio', 'day.allDays': 'Tutti', 'day.checkIn': 'Check-in', + 'day.checkInUntil': 'Fino a', 'day.checkOut': 'Check-out', 'day.confirmation': 'Conferma', 'day.editAccommodation': 'Modifica alloggio', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 5b601888..f5c26556 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1011,6 +1011,7 @@ const nl: Record = { 'reservations.meta.platform': 'Perron', 'reservations.meta.seat': 'Stoel', 'reservations.meta.checkIn': 'Inchecken', + 'reservations.meta.checkInUntil': 'Check-in tot', 'reservations.meta.checkOut': 'Uitchecken', 'reservations.meta.linkAccommodation': 'Accommodatie', 'reservations.meta.pickAccommodation': 'Koppel aan accommodatie', @@ -1495,6 +1496,7 @@ const nl: Record = { 'day.noPlacesForHotel': 'Voeg eerst plaatsen toe aan je reis', 'day.allDays': 'Alle', 'day.checkIn': 'Inchecken', + 'day.checkInUntil': 'Tot', 'day.checkOut': 'Uitchecken', 'day.confirmation': 'Bevestiging', 'day.editAccommodation': 'Accommodatie bewerken', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 38a30885..c8c84921 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -968,6 +968,7 @@ const pl: Record = { 'reservations.meta.platform': 'Peron', 'reservations.meta.seat': 'Miejsce', 'reservations.meta.checkIn': 'Zameldowanie', + 'reservations.meta.checkInUntil': 'Check-in do', 'reservations.meta.checkOut': 'Wymeldowanie', 'reservations.meta.linkAccommodation': 'Zakwaterowanie', 'reservations.meta.pickAccommodation': 'Link do zakwaterowania', @@ -1450,6 +1451,7 @@ const pl: Record = { 'day.noPlacesForHotel': 'Najpierw dodaj miejsca do swojej podróży', 'day.allDays': 'Wszystkie', 'day.checkIn': 'Zameldowanie', + 'day.checkInUntil': 'Do', 'day.checkOut': 'Wymeldowanie', 'day.confirmation': 'Potwierdzenie', 'day.editAccommodation': 'Edytuj zakwaterowanie', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 693081a3..b3baef9a 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1011,6 +1011,7 @@ const ru: Record = { 'reservations.meta.platform': 'Платформа', 'reservations.meta.seat': 'Место', 'reservations.meta.checkIn': 'Заезд', + 'reservations.meta.checkInUntil': 'Заселение до', 'reservations.meta.checkOut': 'Выезд', 'reservations.meta.linkAccommodation': 'Жильё', 'reservations.meta.pickAccommodation': 'Привязать к жилью', @@ -1495,6 +1496,7 @@ const ru: Record = { 'day.noPlacesForHotel': 'Сначала добавьте места в поездку', 'day.allDays': 'Все', 'day.checkIn': 'Заезд', + 'day.checkInUntil': 'До', 'day.checkOut': 'Выезд', 'day.confirmation': 'Подтверждение', 'day.editAccommodation': 'Редактировать жильё', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 2ec0d9f8..e38ff12f 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1011,6 +1011,7 @@ const zh: Record = { 'reservations.meta.platform': '站台', 'reservations.meta.seat': '座位', 'reservations.meta.checkIn': '入住', + 'reservations.meta.checkInUntil': '入住截止', 'reservations.meta.checkOut': '退房', 'reservations.meta.linkAccommodation': '住宿', 'reservations.meta.pickAccommodation': '关联住宿', @@ -1495,6 +1496,7 @@ const zh: Record = { 'day.noPlacesForHotel': '请先在旅行中添加地点', 'day.allDays': '全部', 'day.checkIn': '入住', + 'day.checkInUntil': '截止', 'day.checkOut': '退房', 'day.confirmation': '确认号', 'day.editAccommodation': '编辑住宿', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 708c1156..a6709e15 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1067,6 +1067,7 @@ const zhTw: Record = { 'reservations.meta.platform': '站臺', 'reservations.meta.seat': '座位', 'reservations.meta.checkIn': '入住', + 'reservations.meta.checkInUntil': '入住截止', 'reservations.meta.checkOut': '退房', 'reservations.meta.linkAccommodation': '住宿', 'reservations.meta.pickAccommodation': '關聯住宿', @@ -1551,6 +1552,7 @@ const zhTw: Record = { 'day.noPlacesForHotel': '請先在旅行中新增地點', 'day.allDays': '全部', 'day.checkIn': '入住', + 'day.checkInUntil': '截止', 'day.checkOut': '退房', 'day.confirmation': '確認號', 'day.editAccommodation': '編輯住宿', diff --git a/client/src/types.ts b/client/src/types.ts index 2cf23741..c0ef08db 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -241,6 +241,7 @@ export interface Accommodation { name: string address: string | null check_in: string | null + check_in_end: string | null check_out: string | null confirmation_number: string | null notes: string | null diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a63c5756..3b02bfe1 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1610,6 +1610,11 @@ function runMigrations(db: Database.Database): void { () => { db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run(); }, + + // Migration 102: Add check_in_end column for check-in time ranges + () => { + try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 9df9d013..40b5beba 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -334,6 +334,7 @@ function createTables(db: Database.Database): void { start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, check_in TEXT, + check_in_end TEXT, check_out TEXT, confirmation TEXT, notes TEXT, diff --git a/server/src/routes/days.ts b/server/src/routes/days.ts index 60ff8b95..ce967355 100644 --- a/server/src/routes/days.ts +++ b/server/src/routes/days.ts @@ -73,7 +73,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r return res.status(403).json({ error: 'No permission' }); const { tripId } = req.params; - const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body; + const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body; if (!place_id || !start_day_id || !end_day_id) { return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' }); @@ -82,7 +82,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); if (errors.length > 0) return res.status(404).json({ error: errors[0].message }); - const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }); + const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); res.status(201).json({ accommodation }); broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string); broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string); @@ -98,12 +98,12 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, const existing = dayService.getAccommodation(id, tripId); if (!existing) return res.status(404).json({ error: 'Accommodation not found' }); - const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body; + const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body; const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); if (errors.length > 0) return res.status(404).json({ error: errors[0].message }); - const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }); + const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); res.json({ accommodation }); broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string); }); diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts index 705b364b..99e846d4 100644 --- a/server/src/services/dayService.ts +++ b/server/src/services/dayService.ts @@ -170,6 +170,7 @@ export interface DayAccommodation { start_day_id: number; end_day_id: number; check_in: string | null; + check_in_end: string | null; check_out: string | null; confirmation: string | null; notes: string | null; @@ -220,17 +221,18 @@ interface CreateAccommodationData { start_day_id: number; end_day_id: number; check_in?: string; + check_in_end?: string; check_out?: string; confirmation?: string; notes?: string; } export function createAccommodation(tripId: string | number, data: CreateAccommodationData) { - const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = data; + const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data; const result = db.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null); + 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null); const accommodationId = result.lastInsertRowid; @@ -239,6 +241,7 @@ export function createAccommodation(tripId: string | number, data: CreateAccommo const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null; const meta: Record = {}; if (check_in) meta.check_in_time = check_in; + if (check_in_end) meta.check_in_end_time = check_in_end; if (check_out) meta.check_out_time = check_out; db.prepare(` INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) @@ -258,25 +261,27 @@ export function getAccommodation(id: string | number, tripId: string | number) { export function updateAccommodation(id: string | number, existing: DayAccommodation, fields: { place_id?: number; start_day_id?: number; end_day_id?: number; - check_in?: string; check_out?: string; confirmation?: string; notes?: string; + check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string; }) { const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id; const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id; const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id; const newCheckIn = fields.check_in !== undefined ? fields.check_in : existing.check_in; + const newCheckInEnd = fields.check_in_end !== undefined ? fields.check_in_end : existing.check_in_end; const newCheckOut = fields.check_out !== undefined ? fields.check_out : existing.check_out; const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation; const newNotes = fields.notes !== undefined ? fields.notes : existing.notes; db.prepare( - 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?' - ).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id); + 'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?' + ).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id); // Sync check-in/out/confirmation to linked reservation const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined; if (linkedRes) { const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {}; if (newCheckIn) meta.check_in_time = newCheckIn; + if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd; if (newCheckOut) meta.check_out_time = newCheckOut; db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?') .run(JSON.stringify(meta), newConfirmation || null, linkedRes.id); diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts index 22791f17..17628270 100644 --- a/server/src/services/reservationService.ts +++ b/server/src/services/reservationService.ts @@ -123,9 +123,9 @@ export function createReservation(tripId: string | number, data: CreateReservati // Sync check-in/out to accommodation if linked if (accommodation_id && metadata) { const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; - if (meta.check_in_time || meta.check_out_time) { - db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') - .run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id); + if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) { + db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?') + .run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, accommodation_id); } if (confirmation_number) { db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?') @@ -257,9 +257,9 @@ export function updateReservation(id: string | number, tripId: string | number, const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null); if (resolvedAccId && resolvedMeta) { const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta; - if (meta.check_in_time || meta.check_out_time) { - db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') - .run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId); + if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) { + db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?') + .run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccId); } const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number; if (resolvedConf) { From 9f3a88223d79c617d0174753f093c66706c8077f Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 16 Apr 2026 00:29:25 +0200 Subject: [PATCH 9/9] fix: update ReservationModal test for check-in time range fields Use getAllByText for check-in labels since both "Check-in" and "Check-in until" now match the /Check-in/i pattern. --- client/src/components/Planner/ReservationModal.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 8685f983..0ec10cdf 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -134,7 +134,8 @@ describe('ReservationModal', () => { it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => { render(); await userEvent.click(screen.getByRole('button', { name: /Accommodation/i })); - expect(screen.getByText(/Check-in/i)).toBeInTheDocument(); + const checkInLabels = screen.getAllByText(/Check-in/i); + expect(checkInLabels.length).toBeGreaterThanOrEqual(1); expect(screen.getByText(/Check-out/i)).toBeInTheDocument(); });