diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 374e9b17..526a0d69 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/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/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index dc25a418..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(/Google 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(/Google 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(/Google 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(/Google 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/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 79f27b10..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) @@ -147,7 +146,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 +260,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 +303,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ ) })} + {places.some(p => p.category_id == null) && (() => { + const active = categoryFilters.has('uncategorized') + return ( + + ) + })()} {categoryFilters.size > 0 && (
{/* 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.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index 235e3acb..2dcd9c86 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', () => { - const r1 = buildReservation({ title: 'Flight', type: 'flight', status: 'confirmed' }); - const r2 = buildReservation({ title: 'Hotel', type: 'hotel', status: 'pending' }); + it('FE-COMP-RES-010: shows reservations title and cards', () => { + const r1 = buildReservation({ title: 'My Flight Booking', type: 'flight', status: 'confirmed' }); + const r2 = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'pending' }); render(); - // reservations.summary = "{confirmed} confirmed, {pending} pending" - expect(screen.getByText(/1 confirmed, 1 pending/i)).toBeInTheDocument(); + expect(screen.getByText('My Flight Booking')).toBeInTheDocument(); + expect(screen.getByText('Grand 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/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 95942a9a..51f85e5e 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -50,6 +50,16 @@ function buildAssignmentLookup(days, assignments) { return map } +/* ── Shared field label style ── */ +const fieldLabelStyle: React.CSSProperties = { + fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', + color: 'var(--text-faint)', marginBottom: 5, +} +const fieldValueStyle: React.CSSProperties = { + fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', + padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, +} + interface ReservationCardProps { r: Reservation tripId: number @@ -84,184 +94,214 @@ 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) return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) } + const hasDate = !!r.reservation_time + const hasTime = r.reservation_time?.includes('T') + const hasCode = !!r.confirmation_number + const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length + return ( -
- {/* Header bar */} -
-
- {canEdit ? ( - - ) : ( - +
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} + onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} + > + {/* Header */} +
+
+ + {confirmed ? t('reservations.confirmed') : t('reservations.pending')} + + + {t(typeInfo.labelKey)} + +
+
+ {r.title} + {canEdit && ( + + )} + {canEdit && ( + + )} +
+
+ + {/* Body */} +
+ {/* Date / Time 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')}
+
+ {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)}` : ''} +
+
+ )} +
)} -
- - {t(typeInfo.labelKey)} - - {r.title} - {canEdit && ( - + {/* 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} +
+
)} - {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) + (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 ( +
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} +
+
+ )} + {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,152 @@ 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/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 4b838319..bdba1a5d 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -590,6 +590,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': 'قالب جديد', @@ -1013,6 +1021,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': 'ربط بالإقامة', @@ -1497,6 +1506,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 546dbfab..8e700b0b 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.defaults': 'Padrões do usuário', 'admin.defaultSettings.title': 'Configurações padrão do usuário', @@ -982,6 +990,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', @@ -1466,6 +1475,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 3e5ee16f..053faa9d 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.defaults': 'Výchozí nastavení uživatele', 'admin.defaultSettings.title': 'Výchozí nastavení uživatele', @@ -1011,6 +1019,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', @@ -1495,6 +1504,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 079a2b93..875967c7 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.defaults': 'Benutzer-Standards', 'admin.defaultSettings.title': 'Standard-Benutzereinstellungen', @@ -1013,6 +1021,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', @@ -1497,6 +1506,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 11c0171f..cff638e3 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.defaults': 'User Defaults', 'admin.defaultSettings.title': 'Default User Settings', @@ -1066,6 +1074,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', @@ -1550,6 +1559,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 8e02694b..4ef8de07 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.defaults': 'Valores predeterminados', 'admin.defaultSettings.title': 'Configuración predeterminada de usuarios', @@ -1446,6 +1454,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', @@ -1613,6 +1622,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 8c2bd02d..91d43d55 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.defaults': 'Valeurs par défaut', 'admin.defaultSettings.title': 'Paramètres utilisateur par défaut', @@ -1009,6 +1017,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', @@ -1493,6 +1502,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 b92d15d4..e918ab99 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.defaults': 'Alapértelmezett beállítások', 'admin.defaultSettings.title': 'Alapértelmezett felhasználói beállítások', @@ -1011,6 +1019,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', @@ -1494,6 +1503,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 65573dc1..3c11b256 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.defaults': 'Pengaturan Default Pengguna', 'admin.defaultSettings.title': 'Pengaturan Default Pengguna', @@ -1066,6 +1074,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', @@ -1550,6 +1559,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 b5e62023..aea9e7fb 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.defaults': 'Impostazioni predefinite', 'admin.defaultSettings.title': 'Impostazioni predefinite utente', @@ -1010,6 +1018,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', @@ -1494,6 +1503,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 a3c1f766..d526ed54 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.defaults': 'Standaardinstellingen', 'admin.defaultSettings.title': 'Standaard gebruikersinstellingen', @@ -1009,6 +1017,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', @@ -1493,6 +1502,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 3b4d0c46..e09e4db5 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.defaults': 'Domyślne ustawienia', 'admin.defaultSettings.title': 'Domyślne ustawienia użytkownika', @@ -966,6 +974,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', @@ -1448,6 +1457,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 08d3b72d..1a063545 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.defaults': 'Настройки по умолчанию', 'admin.defaultSettings.title': 'Настройки пользователей по умолчанию', @@ -1009,6 +1017,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': 'Привязать к жилью', @@ -1493,6 +1502,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 36d4a2e8..26e46768 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.defaults': '用户默认设置', 'admin.defaultSettings.title': '用户默认设置', @@ -1009,6 +1017,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': '关联住宿', @@ -1493,6 +1502,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 d7fa4a02..7f521893 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.defaults': '用戶預設設定', 'admin.defaultSettings.title': '用戶預設設定', @@ -1065,6 +1073,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': '關聯住宿', @@ -1549,6 +1558,7 @@ const zhTw: Record = { 'day.noPlacesForHotel': '請先在旅行中新增地點', 'day.allDays': '全部', 'day.checkIn': '入住', + 'day.checkInUntil': '截止', 'day.checkOut': '退房', 'day.confirmation': '確認號', 'day.editAccommodation': '編輯住宿', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 1b57ef30..ce71e530 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -194,6 +194,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) @@ -799,6 +803,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 703c9f93..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) @@ -246,7 +248,11 @@ export default function TripPlannerPage(): React.ReactElement | null { return places.filter(p => { if (!p.lat || !p.lng) return false - if (mapCategoryFilter.size > 0 && !mapCategoryFilter.has(String(p.category_id))) return false + if (mapCategoryFilter.size > 0) { + if (p.category_id == null) { + if (!mapCategoryFilter.has('uncategorized')) return false + } else if (!mapCategoryFilter.has(String(p.category_id))) return false + } if (hiddenPlaceIds.has(p.id)) return false if (plannedIds && plannedIds.has(p.id)) return false return true @@ -906,7 +912,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )} {activeTab === 'buchungen' && ( -
+
- +
)}
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/app.ts b/server/src/app.ts index 34266f3d..679e43f7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -46,6 +46,7 @@ import systemNoticesRoutes from './routes/systemNotices'; import { mcpHandler } from './mcp'; import { Addon } from './types'; import { getPhotoProviderConfig } from './services/memories/helpersService'; +import { getCollabFeatures } from './services/adminService'; import { isAddonEnabled } from './services/adminService'; import { ADDON_IDS } from './addons'; @@ -239,6 +240,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/db/migrations.ts b/server/src/db/migrations.ts index 4eddc78d..e089da7b 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1605,7 +1605,16 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at); `); }, - // Migration 101: System notices — user tracking columns + dismissals table + + // Migration 101: Enable naver_list_import by default + () => { + 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; } + // Migration 103: System notices — user tracking columns + dismissals table () => { db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`); db.exec(`ALTER TABLE users ADD COLUMN login_count INTEGER NOT NULL DEFAULT 0`); 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/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/admin.ts b/server/src/routes/admin.ts index d530b5b0..ab873e52 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -201,6 +201,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/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/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' }); 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() { 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) { 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);