From 78d6f2ba7738dae997ab0384165fe75bba9c1d81 Mon Sep 17 00:00:00 2001 From: "Julien G." <66769052+jubnl@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:16:56 +0200 Subject: [PATCH] Bug fixes - April 28th 2026 (#915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: replace raw day-ID range checks with position-based helper (issue #889 follow-up) Commit 8e05ba7 fixed the accommodation date-range pickers, but the post-save state filters in DayDetailPanel and several other consumers still compared `day.id >= start_day_id && day.id <= end_day_id`. With non-monotonic ID layouts (day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7) this made the just-saved accommodation immediately invisible — matching the regression reported in the last comment of #889. Introduces `isDayInAccommodationRange` in `client/src/utils/dayOrder.ts` which compares positional order (`day_number` with `indexOf` fallback) rather than raw IDs. Falls back to the old numeric comparison when endpoint days are absent from the loaded array (sparse test data or partial loads) so existing tests are unaffected. Fixed call sites: - DayDetailPanel.tsx (initial load, post-create, post-delete, post-edit-save) - DayPlanSidebar.tsx (daily badge renderer) - SharedTripPage.tsx (public share view) - TripPDF.tsx (PDF export filter + sort) Also declares `day_number?: number` on the client `Day` type (already returned by the server but previously untyped). Adds regression tests FE-PLANNER-DAYDETAIL-060/061/062 covering the edit-save, create-save, and initial-load paths with the reporter's exact non-monotonic ID layout. * fix: non-transport reservations no longer appear as transports in day planner (issue #914) getTransportForDay now uses TRANSPORT_TYPES allowlist instead of only excluding hotels, and the click handler dispatches to onEditReservation for non-transport types instead of always opening TransportModal, preventing silent type coercion to 'flight'. * feat: add file attachment support to TransportModal (issue #918) Transports (flight/train/car/cruise) now support file attachments identical to the reservation modal — upload on create/edit, link existing files, and unlink. The Files tab and Assign File modal now differentiate between bookings and transports with separate sections and type-specific icons. Translations added for all 15 locales. --- client/src/components/Files/FileManager.tsx | 119 ++++--- client/src/components/PDF/TripPDF.tsx | 9 +- .../Planner/DayDetailPanel.test.tsx | 94 +++++ .../src/components/Planner/DayDetailPanel.tsx | 11 +- .../src/components/Planner/DayPlanSidebar.tsx | 11 +- .../Planner/TransportModal.test.tsx | 324 ++++++++++++++++++ .../src/components/Planner/TransportModal.tsx | 154 ++++++++- 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/pages/SharedTripPage.tsx | 3 +- client/src/pages/TripPlannerPage.tsx | 17 +- client/src/types.ts | 1 + client/src/utils/dayOrder.ts | 23 ++ 26 files changed, 727 insertions(+), 69 deletions(-) create mode 100644 client/src/components/Planner/TransportModal.test.tsx create mode 100644 client/src/utils/dayOrder.ts diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index b8b7de2d..f3b93546 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -1,7 +1,7 @@ import ReactDOM from 'react-dom' import { useState, useCallback, useRef, useEffect } from 'react' import { useDropzone } from 'react-dropzone' -import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react' +import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { filesApi } from '../../api/client' @@ -236,6 +236,15 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: ) } +const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise']) + +function transportIcon(type: string) { + if (type === 'train') return Train + if (type === 'car') return Car + if (type === 'cruise') return Ship + return Plane +} + interface FileManagerProps { files?: TripFile[] onUpload: (fd: FormData) => Promise @@ -490,7 +499,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, ))} {linkedReservations.map(r => ( - + TRANSPORT_TYPES.has(r.type) + ? + : ))} {file.note_id && ( @@ -673,52 +684,68 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, ) + const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type)) + const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type)) + + const reservationBtn = (r: Reservation) => { + const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id) + const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket + return ( + + ) + } + const bookingsSection = reservations.length > 0 && (
-
- {t('files.assignBooking')} -
- {reservations.map(r => { - const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id) - return ( - - ) - })} + {bookingReservations.length > 0 && ( + <> +
+ {t('files.assignBooking')} +
+ {bookingReservations.map(reservationBtn)} + + )} + {transportReservations.length > 0 && ( + <> +
0 ? 4 : 0 }}> + {t('files.assignTransport')} +
+ {transportReservations.map(reservationBtn)} + + )}
) diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 1a5a3316..e5700b93 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -4,6 +4,7 @@ import { getCategoryIcon } from '../shared/categoryIcons' import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react' import { accommodationsApi, mapsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' +import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' function renderLucideIcon(icon:LucideIcon, props = {}) { if (!_renderToStaticMarkup) return '' @@ -285,8 +286,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor }).join('') const accommodationsForDay = (accommodations.accommodations || []).filter(a => - days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) - ).sort((a, b) => a.start_day_id - b.start_day_id) + day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false + ).sort((a, b) => { + const startA = days.find(d => d.id === a.start_day_id) + const startB = days.find(d => d.id === b.start_day_id) + return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0) + }) const accommodationDetails = accommodationsForDay.map(item => { const isCheckIn = day.id === item.start_day_id diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index 9bdc0256..a70fd9d5 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -1069,6 +1069,100 @@ describe('DayDetailPanel', () => { }); }); + // ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ──────── + + it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => { + const days = buildNonMonotonicDays(); + let getCallCount = 0; + server.use( + http.get('/api/trips/1/accommodations', () => { + getCallCount++; + const acc = getCallCount === 1 + // Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible + ? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null } + // Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it + : { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }; + return HttpResponse.json({ accommodations: [acc] }); + }), + http.put('/api/trips/1/accommodations/1', async ({ request }) => { + const body = await request.json() as any; + return HttpResponse.json({ + accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, + start_day_id: body.start_day_id, end_day_id: body.end_day_id, + check_in: null, check_out: null, confirmation: null }, + }); + }), + ); + + render(); + await screen.findByText('Span Hotel'); + + // Pencil = 3rd button (index 2): collapse, close, pencil, remove + const allButtons = screen.getAllByRole('button'); + await userEvent.click(allButtons[2]); + + // Extend end picker to Day 16 (id=7) + await userEvent.click(getDayPickerTriggers()[1]); + await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!); + await userEvent.click(screen.getByRole('button', { name: /^Save$/i })); + + // Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible. + await waitFor(() => { + expect(screen.getByText('Span Hotel')).toBeInTheDocument(); + }); + }); + + it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => { + const days = buildNonMonotonicDays(); + const place = buildPlace({ id: 55, name: 'Created Hotel' }); + // Current day: days[5] = id 22, position 5 (within any full-span range) + const currentDay = days[5]; + server.use( + http.post('/api/trips/1/accommodations', async ({ request }) => { + const body = await request.json() as any; + return HttpResponse.json({ + accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null, + start_day_id: body.start_day_id, end_day_id: body.end_day_id, + check_in: null, check_out: null, confirmation: null }, + }); + }), + ); + + render(); + await userEvent.click(await screen.findByText(/Add accommodation/i)); + await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i })); + + // Extend end to Day 16 (id=7) — start stays at current day id=22 + await userEvent.click(getDayPickerTriggers()[1]); + await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!); + await userEvent.click(screen.getByRole('button', { name: /^Save$/i })); + + // Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible. + await waitFor(() => { + expect(screen.getByText('Created Hotel')).toBeInTheDocument(); + }); + }); + + it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => { + const days = buildNonMonotonicDays(); + server.use( + http.get('/api/trips/1/accommodations', () => + HttpResponse.json({ + accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null, + start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }], + }) + ), + ); + + // Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible. + render(); + await screen.findByText('Full Trip Hotel'); + + // Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible. + render(); + await screen.findByText('Full Trip Hotel'); + }); + it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => { seedStore(useSettingsStore, { settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false }, diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 0d53f8ff..407db408 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -12,6 +12,7 @@ import CustomTimePicker from '../shared/CustomTimePicker' import { useSettingsStore } from '../../store/settingsStore' import { getLocaleForLanguage, useTranslation } from '../../i18n' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' +import { isDayInAccommodationRange } from '../../utils/dayOrder' const WEATHER_ICON_MAP = { Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, @@ -99,7 +100,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri .then(data => { setAccommodations(data.accommodations || []) const allForDay = (data.accommodations || []).filter(a => - days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) + day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false ) setDayAccommodations(allForDay) setAccommodation(allForDay[0] || null) @@ -130,7 +131,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri setAccommodations(updated) setAccommodation(newAcc) setDayAccommodations(updated.filter(a => - days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) + day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false )) setShowHotelPicker(false) setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) @@ -154,7 +155,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const updated = accommodations.filter(a => a.id !== accommodation.id) setAccommodations(updated) setDayAccommodations(updated.filter(a => - days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id) + day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false )) setAccommodation(null) onAccommodationChange?.() @@ -598,9 +599,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const all = d.accommodations || [] setAccommodations(all) setDayAccommodations(all.filter(a => - days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id) + day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false )) - const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)) + const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false) setAccommodation(acc || null) }) onAccommodationChange?.() diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index b1eda2d3..967cbfcc 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -21,6 +21,7 @@ import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' +import { isDayInAccommodationRange } from '../../utils/dayOrder' import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import Tooltip from '../shared/Tooltip' @@ -397,7 +398,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const getTransportForDay = (dayId: number) => { const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id) return reservations.filter(r => { - if (r.type === 'hotel') return false + if (!TRANSPORT_TYPES.has(r.type)) return false if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false const startDayId = r.day_id @@ -1214,7 +1215,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ )} {(() => { - const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id) + const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) // Sort: check-out first, then ongoing stays, then check-in last .sort((a, b) => { const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id @@ -1725,7 +1726,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ return (
canEditDays && onEditTransport?.(res)} + onClick={() => { + if (!canEditDays) return + if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res) + else onEditReservation?.(res) + }} onDragOver={e => { e.preventDefault(); e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx new file mode 100644 index 00000000..71829e72 --- /dev/null +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -0,0 +1,324 @@ +// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021 +import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { useAuthStore } from '../../store/authStore'; +import { useTripStore } from '../../store/tripStore'; +import { useAddonStore } from '../../store/addonStore'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { + buildUser, + buildTrip, + buildDay, + buildReservation, + buildTripFile, +} from '../../../tests/helpers/factories'; +import { TransportModal } from './TransportModal'; + +vi.mock('react-router-dom', async (importActual) => { + const actual = await importActual(); + return { ...actual, useParams: () => ({ id: '1' }) }; +}); + +vi.mock('../shared/CustomTimePicker', () => ({ + default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( + onChange(e.target.value)} /> + ), +})); + +vi.mock('./AirportSelect', () => ({ + default: ({ onChange }: { onChange: (a: any) => void }) => ( + onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} /> + ), +})); + +vi.mock('./LocationSelect', () => ({ + default: ({ onChange }: { onChange: (l: any) => void }) => ( + onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} /> + ), +})); + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + onSave: vi.fn().mockResolvedValue(undefined), + reservation: null, + days: [], + selectedDayId: null, + files: [], + onFileUpload: vi.fn().mockResolvedValue(undefined), + onFileDelete: vi.fn().mockResolvedValue(undefined), +}; + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] }); + vi.clearAllMocks(); +}); + +describe('TransportModal', () => { + // ── Rendering ────────────────────────────────────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => { + render(); + expect(document.body).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => { + render(); + expect(screen.getByText(/Add transport/i)).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => { + const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); + render(); + expect(screen.getByText(/Edit transport/i)).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => { + render(); + expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => { + const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' }); + render(); + expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => { + const res = buildReservation({ title: 'My Train', type: 'train' }); + render(); + expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => { + const onClose = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' })); + }); + + it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.click(screen.getByRole('button', { name: /^Train$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' })); + }); + + // ── Budget addon ───────────────────────────────────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => { + seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + render(); + expect(screen.getByText(/^Price$/i)).toBeInTheDocument(); + expect(screen.getByText(/Budget category/i)).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => { + render(); + expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => { + seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train'); + await userEvent.type(screen.getByPlaceholderText('0.00'), '85'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) }) + ); + }); + + // ── File attachment ─────────────────────────────────────────────────────────── + + it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => { + render(); + expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => { + render(); + expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => { + const res = buildReservation({ id: 5, type: 'flight' }); + const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' }); + (file as any).reservation_id = 5; + + render(); + expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => { + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' }); + fireEvent.change(fileInput, { target: { files: [testFile] } }); + + await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument()); + }); + + it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => { + const onFileUpload = vi.fn().mockResolvedValue(undefined); + const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' }); + + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' }); + fireEvent.change(fileInput, { target: { files: [testFile] } }); + + await waitFor(() => expect(onFileUpload).toHaveBeenCalled()); + const [fd] = onFileUpload.mock.calls[0] as [FormData]; + expect(fd.get('file')).toBeTruthy(); + expect(fd.get('reservation_id')).toBe('10'); + }); + + it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => { + const res = buildReservation({ id: 5, type: 'flight' }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + + render(); + expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => { + const res = buildReservation({ id: 5, type: 'flight' }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => { + server.use( + http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })), + http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })), + ); + + const res = buildReservation({ id: 5, type: 'flight' }); + const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); + + render(); + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + await userEvent.click(screen.getByText('invoice.pdf')); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument(); + }); + }); + + it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => { + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' }); + fireEvent.change(fileInput, { target: { files: [testFile] } }); + + await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument()); + + const pendingFileRow = screen.getByText('draft.pdf').closest('div')!; + const removeBtn = pendingFileRow.querySelector('button')!; + await userEvent.click(removeBtn); + + await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument()); + }); + + it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => { + render(); + const attachBtn = screen.getByRole('button', { name: /Attach file/i }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); + await userEvent.click(attachBtn); + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); + + it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => { + server.use( + http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })), + http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })), + http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })), + http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })), + ); + + const res = buildReservation({ id: 7, type: 'car' }); + const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); + await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument()); + await userEvent.click(screen.getByText('rental-agreement.pdf')); + + await waitFor(() => + expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument() + ); + + const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!; + const unlinkBtn = fileRow.querySelector('button[type="button"]')!; + await userEvent.click(unlinkBtn); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument(); + }); + }); + + it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => { + const savedReservation = buildReservation({ id: 99, type: 'flight' }); + const onSave = vi.fn().mockResolvedValue(savedReservation); + const onFileUpload = vi.fn().mockResolvedValue(undefined); + + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' }); + fireEvent.change(fileInput, { target: { files: [testFile] } }); + await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument()); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001'); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onFileUpload).toHaveBeenCalled()); + const [fd] = onFileUpload.mock.calls[0] as [FormData]; + expect(fd.get('reservation_id')).toBe('99'); + expect(fd.get('file')).toBeTruthy(); + }); +}); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 259bf29e..367a25cc 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect, useMemo } from 'react' -import { Plane, Train, Car, Ship } from 'lucide-react' +import { useState, useEffect, useMemo, useRef } from 'react' +import { useParams } from 'react-router-dom' +import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import CustomTimePicker from '../shared/CustomTimePicker' @@ -10,7 +11,9 @@ import { useToast } from '../shared/Toast' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import { formatDate } from '../../utils/formatters' -import type { Day, Reservation, ReservationEndpoint } from '../../types' +import { openFile } from '../../utils/fileDownload' +import apiClient from '../../api/client' +import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types' const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const type TransportType = typeof TRANSPORT_TYPES[number] @@ -89,26 +92,36 @@ const defaultForm = { interface TransportModalProps { isOpen: boolean onClose: () => void - onSave: (data: Record) => Promise + onSave: (data: Record) => Promise reservation: Reservation | null days: Day[] selectedDayId: number | null + files?: TripFile[] + onFileUpload?: (fd: FormData) => Promise + onFileDelete?: (fileId: number) => Promise } -export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) { +export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) { const { t, locale } = useTranslation() const toast = useToast() const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const budgetItems = useTripStore(s => s.budgetItems) + const loadFiles = useTripStore(s => s.loadFiles) const budgetCategories = useMemo(() => { const cats = new Set() budgetItems.forEach(i => { if (i.category) cats.add(i.category) }) return Array.from(cats).sort() }, [budgetItems]) + const { id: tripId } = useParams<{ id: string }>() const [form, setForm] = useState({ ...defaultForm }) const [isSaving, setIsSaving] = useState(false) const [fromPick, setFromPick] = useState({}) const [toPick, setToPick] = useState({}) + const [uploadingFile, setUploadingFile] = useState(false) + const [pendingFiles, setPendingFiles] = useState([]) + const [showFilePicker, setShowFilePicker] = useState(false) + const [linkedFileIds, setLinkedFileIds] = useState([]) + const fileInputRef = useRef(null) useEffect(() => { if (!isOpen) return @@ -222,7 +235,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } : { total_price: 0 } } - await onSave(payload) + const saved = await onSave(payload) + if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) { + for (const file of pendingFiles) { + const fd = new FormData() + fd.append('file', file) + fd.append('reservation_id', String(saved.id)) + fd.append('description', form.title) + await onFileUpload(fd) + } + } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } finally { @@ -230,6 +252,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel } } + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + if (reservation?.id) { + setUploadingFile(true) + try { + const fd = new FormData() + fd.append('file', file) + fd.append('reservation_id', String(reservation.id)) + fd.append('description', reservation.title) + await onFileUpload!(fd) + toast.success(t('reservations.toast.fileUploaded')) + } catch { + toast.error(t('reservations.toast.uploadError')) + } finally { + setUploadingFile(false) + e.target.value = '' + } + } else { + setPendingFiles(prev => [...prev, file]) + e.target.value = '' + } + } + + const attachedFiles = reservation?.id + ? files.filter(f => + f.reservation_id === reservation.id || + linkedFileIds.includes(f.id) || + (f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id)) + ) + : [] + const inputStyle = { width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', @@ -444,6 +498,94 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
+ {/* Files */} +
+ +
+ {attachedFiles.map(f => ( +
+ + {f.original_name} + { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}> + +
+ ))} + {pendingFiles.map((f, i) => ( +
+ + {f.name} + +
+ ))} + +
+ {onFileUpload && } + {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && ( +
+ + {showFilePicker && ( +
+ {files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => ( + + ))} +
+ )} +
+ )} +
+
+
+ {/* Price + Budget Category */} {isBudgetEnabled && ( <> diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 77fb21df..cbc68592 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1249,6 +1249,7 @@ const ar: Record = { 'files.toast.deleteError': 'فشل حذف الملف', 'files.sourcePlan': 'خطة اليوم', 'files.sourceBooking': 'الحجز', + 'files.sourceTransport': 'النقل', 'files.attach': 'إرفاق', 'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)', 'files.trash': 'سلة المهملات', @@ -1261,6 +1262,7 @@ const ar: Record = { 'files.assignTitle': 'إسناد ملف', 'files.assignPlace': 'المكان', 'files.assignBooking': 'الحجز', + 'files.assignTransport': 'النقل', 'files.unassigned': 'غير مسند', 'files.unlink': 'إزالة الرابط', 'files.toast.trashed': 'تم النقل إلى سلة المهملات', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0ee132d5..c2ae52ea 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1218,6 +1218,7 @@ const br: Record = { 'files.toast.deleteError': 'Falha ao excluir arquivo', 'files.sourcePlan': 'Plano do dia', 'files.sourceBooking': 'Reserva', + 'files.sourceTransport': 'Transporte', 'files.attach': 'Anexar', 'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)', 'files.trash': 'Lixeira', @@ -1230,6 +1231,7 @@ const br: Record = { 'files.assignTitle': 'Atribuir arquivo', 'files.assignPlace': 'Lugar', 'files.assignBooking': 'Reserva', + 'files.assignTransport': 'Transporte', 'files.unassigned': 'Não atribuído', 'files.unlink': 'Remover vínculo', 'files.toast.trashed': 'Movido para a lixeira', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 55f7160d..5085236c 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1247,6 +1247,7 @@ const cs: Record = { 'files.toast.deleteError': 'Nepodařilo se smazat soubor', 'files.sourcePlan': 'Denní plán', 'files.sourceBooking': 'Rezervace', + 'files.sourceTransport': 'Doprava', 'files.attach': 'Přiložit', 'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)', 'files.trash': 'Koš', @@ -1259,6 +1260,7 @@ const cs: Record = { 'files.assignTitle': 'Přiřadit soubor', 'files.assignPlace': 'Místo', 'files.assignBooking': 'Rezervace', + 'files.assignTransport': 'Doprava', 'files.unassigned': 'Nepřiřazeno', 'files.unlink': 'Zrušit propojení', 'files.toast.trashed': 'Přesunuto do koše', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 0834fc62..f6a8e3f7 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1251,6 +1251,7 @@ const de: Record = { 'files.toast.deleteError': 'Fehler beim Löschen der Datei', 'files.sourcePlan': 'Tagesplan', 'files.sourceBooking': 'Buchung', + 'files.sourceTransport': 'Transport', 'files.attach': 'Anhängen', 'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)', 'files.trash': 'Papierkorb', @@ -1263,6 +1264,7 @@ const de: Record = { 'files.assignTitle': 'Datei zuweisen', 'files.assignPlace': 'Ort', 'files.assignBooking': 'Buchung', + 'files.assignTransport': 'Transport', 'files.unassigned': 'Nicht zugewiesen', 'files.unlink': 'Verknüpfung entfernen', 'files.toast.trashed': 'In den Papierkorb verschoben', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 70559aca..7b2cebe9 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1322,6 +1322,7 @@ const en: Record = { 'files.toast.deleteError': 'Failed to delete file', 'files.sourcePlan': 'Day Plan', 'files.sourceBooking': 'Booking', + 'files.sourceTransport': 'Transport', 'files.attach': 'Attach', 'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)', 'files.trash': 'Trash', @@ -1334,6 +1335,7 @@ const en: Record = { 'files.assignTitle': 'Assign File', 'files.assignPlace': 'Place', 'files.assignBooking': 'Booking', + 'files.assignTransport': 'Transport', 'files.unassigned': 'Unassigned', 'files.unlink': 'Remove link', 'files.toast.trashed': 'Moved to trash', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index d587df5a..5348dbc6 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1195,6 +1195,7 @@ const es: Record = { 'files.toast.deleteError': 'No se pudo eliminar el archivo', 'files.sourcePlan': 'Plan diario', 'files.sourceBooking': 'Reserva', + 'files.sourceTransport': 'Transporte', 'files.attach': 'Adjuntar', 'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)', @@ -1682,6 +1683,7 @@ const es: Record = { 'files.assignTitle': 'Asignar archivo', 'files.assignPlace': 'Lugar', 'files.assignBooking': 'Reserva', + 'files.assignTransport': 'Transporte', 'files.unassigned': 'Sin asignar', 'files.unlink': 'Eliminar vínculo', 'files.noteLabel': 'Nota', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 2d225b87..88cd4577 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1245,6 +1245,7 @@ const fr: Record = { 'files.toast.deleteError': 'Impossible de supprimer le fichier', 'files.sourcePlan': 'Plan du jour', 'files.sourceBooking': 'Réservation', + 'files.sourceTransport': 'Transport', 'files.attach': 'Joindre', 'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)', 'files.trash': 'Corbeille', @@ -1257,6 +1258,7 @@ const fr: Record = { 'files.assignTitle': 'Assigner le fichier', 'files.assignPlace': 'Lieu', 'files.assignBooking': 'Réservation', + 'files.assignTransport': 'Transport', 'files.unassigned': 'Non attribué', 'files.unlink': 'Supprimer le lien', 'files.toast.trashed': 'Déplacé dans la corbeille', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 9f243a25..263bfbc7 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1246,6 +1246,7 @@ const hu: Record = { 'files.toast.deleteError': 'Nem sikerült törölni a fájlt', 'files.sourcePlan': 'Napi terv', 'files.sourceBooking': 'Foglalás', + 'files.sourceTransport': 'Közlekedés', 'files.attach': 'Csatolás', 'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)', 'files.trash': 'Kuka', @@ -1258,6 +1259,7 @@ const hu: Record = { 'files.assignTitle': 'Fájl hozzárendelése', 'files.assignPlace': 'Hely', 'files.assignBooking': 'Foglalás', + 'files.assignTransport': 'Közlekedés', 'files.unassigned': 'Nincs hozzárendelve', 'files.unlink': 'Kapcsolat eltávolítása', 'files.toast.trashed': 'Kukába helyezve', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 84f8e8f0..1cf3050c 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -1306,6 +1306,7 @@ const id: Record = { 'files.toast.deleteError': 'Gagal menghapus file', 'files.sourcePlan': 'Rencana Harian', 'files.sourceBooking': 'Pemesanan', + 'files.sourceTransport': 'Transportasi', 'files.attach': 'Lampirkan', 'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)', 'files.trash': 'Sampah', @@ -1318,6 +1319,7 @@ const id: Record = { 'files.assignTitle': 'Tugaskan File', 'files.assignPlace': 'Tempat', 'files.assignBooking': 'Pemesanan', + 'files.assignTransport': 'Transportasi', 'files.unassigned': 'Tidak ditugaskan', 'files.unlink': 'Hapus tautan', 'files.toast.trashed': 'Dipindahkan ke sampah', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 40e8ff2f..6286cb6f 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1246,6 +1246,7 @@ const it: Record = { 'files.toast.deleteError': 'Impossibile eliminare il file', 'files.sourcePlan': 'Programma giornaliero', 'files.sourceBooking': 'Prenotazione', + 'files.sourceTransport': 'Trasporto', 'files.attach': 'Allega', 'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)', 'files.trash': 'Cestino', @@ -1258,6 +1259,7 @@ const it: Record = { 'files.assignTitle': 'Assegna file', 'files.assignPlace': 'Luogo', 'files.assignBooking': 'Prenotazione', + 'files.assignTransport': 'Trasporto', 'files.unassigned': 'Non assegnato', 'files.unlink': 'Rimuovi collegamento', 'files.toast.trashed': 'Spostato nel cestino', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index ab8c9781..551b3779 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1245,6 +1245,7 @@ const nl: Record = { 'files.toast.deleteError': 'Bestand verwijderen mislukt', 'files.sourcePlan': 'Dagplan', 'files.sourceBooking': 'Boeking', + 'files.sourceTransport': 'Transport', 'files.attach': 'Bijvoegen', 'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)', 'files.trash': 'Prullenbak', @@ -1257,6 +1258,7 @@ const nl: Record = { 'files.assignTitle': 'Bestand toewijzen', 'files.assignPlace': 'Plaats', 'files.assignBooking': 'Boeking', + 'files.assignTransport': 'Transport', 'files.unassigned': 'Niet toegewezen', 'files.unlink': 'Koppeling verwijderen', 'files.toast.trashed': 'Naar prullenbak verplaatst', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index d05a0a13..8a9b16a2 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1197,6 +1197,7 @@ const pl: Record = { 'files.toast.deleteError': 'Nie udało się usunąć pliku', 'files.sourcePlan': 'Plan dni', 'files.sourceBooking': 'Rezerwacje', + 'files.sourceTransport': 'Transport', 'files.attach': 'Załącz', 'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)', 'files.trash': 'Kosz', @@ -1209,6 +1210,7 @@ const pl: Record = { 'files.assignTitle': 'Przypisz plik', 'files.assignPlace': 'Miejsce', 'files.assignBooking': 'Rezerwacja', + 'files.assignTransport': 'Transport', 'files.unassigned': 'Nieprzypisane', 'files.unlink': 'Usuń link', 'files.toast.trashed': 'Przeniesiono do kosza', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 3995a461..1f4c9302 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1245,6 +1245,7 @@ const ru: Record = { 'files.toast.deleteError': 'Не удалось удалить файл', 'files.sourcePlan': 'План дня', 'files.sourceBooking': 'Бронирование', + 'files.sourceTransport': 'Транспорт', 'files.attach': 'Прикрепить', 'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)', 'files.trash': 'Корзина', @@ -1257,6 +1258,7 @@ const ru: Record = { 'files.assignTitle': 'Назначить файл', 'files.assignPlace': 'Место', 'files.assignBooking': 'Бронирование', + 'files.assignTransport': 'Транспорт', 'files.unassigned': 'Не назначен', 'files.unlink': 'Удалить связь', 'files.toast.trashed': 'Перемещено в корзину', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 096eea8d..e3a97283 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1245,6 +1245,7 @@ const zh: Record = { 'files.toast.deleteError': '删除文件失败', 'files.sourcePlan': '日程计划', 'files.sourceBooking': '预订', + 'files.sourceTransport': '交通', 'files.attach': '附加', 'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)', 'files.trash': '回收站', @@ -1257,6 +1258,7 @@ const zh: Record = { 'files.assignTitle': '分配文件', 'files.assignPlace': '地点', 'files.assignBooking': '预订', + 'files.assignTransport': '交通', 'files.unassigned': '未分配', 'files.unlink': '移除关联', 'files.toast.trashed': '已移至回收站', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index c7b75b8a..26207c7f 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1305,6 +1305,7 @@ const zhTw: Record = { 'files.toast.deleteError': '刪除檔案失敗', 'files.sourcePlan': '日程計劃', 'files.sourceBooking': '預訂', + 'files.sourceTransport': '交通', 'files.attach': '附加', 'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)', 'files.trash': '回收站', @@ -1317,6 +1318,7 @@ const zhTw: Record = { 'files.assignTitle': '分配檔案', 'files.assignPlace': '地點', 'files.assignBooking': '預訂', + 'files.assignTransport': '交通', 'files.unassigned': '未分配', 'files.unlink': '移除關聯', 'files.toast.trashed': '已移至回收站', diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 09fa8669..e7c75c1f 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -10,6 +10,7 @@ import { getCategoryIcon } from '../components/shared/categoryIcons' import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react' +import { isDayInAccommodationRange } from '../utils/dayOrder' const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } @@ -184,7 +185,7 @@ export default function SharedTripPage() { const da = assignments[String(day.id)] || [] const notes = (dayNotes[String(day.id)] || []) const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date) - const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id) + const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays)) const merged = [ ...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })), diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index f4d3dde8..54cc4726 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -666,15 +666,20 @@ export default function TripPlannerPage(): React.ReactElement | null { const handleSaveTransport = async (data) => { try { if (editingTransport) { - await tripActions.updateReservation(tripId, editingTransport.id, data) + const r = await tripActions.updateReservation(tripId, editingTransport.id, data) toast.success(t('trip.toast.reservationUpdated')) + setShowTransportModal(false) + setEditingTransport(null) + setTransportModalDayId(null) + return r } else { - await tripActions.addReservation(tripId, data) + const r = await tripActions.addReservation(tripId, data) toast.success(t('trip.toast.reservationAdded')) + setShowTransportModal(false) + setEditingTransport(null) + setTransportModalDayId(null) + return r } - setShowTransportModal(false) - setEditingTransport(null) - setTransportModalDayId(null) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } @@ -1194,7 +1199,7 @@ export default function TripPlannerPage(): React.ReactElement | null { setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} /> - {showTransportModal && { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />} + {showTransportModal && { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />} setDeletePlaceId(null)} diff --git a/client/src/types.ts b/client/src/types.ts index 5701597a..8c9c3039 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -31,6 +31,7 @@ export interface Trip { export interface Day { id: number trip_id: number + day_number?: number date: string title: string | null notes: string | null diff --git a/client/src/utils/dayOrder.ts b/client/src/utils/dayOrder.ts new file mode 100644 index 00000000..8ef66046 --- /dev/null +++ b/client/src/utils/dayOrder.ts @@ -0,0 +1,23 @@ +import type { Day } from '../types' + +export const getDayOrder = (day: Day, days: Day[]): number => + day.day_number ?? days.indexOf(day) + +export const isDayInAccommodationRange = ( + day: Day, + startDayId: number, + endDayId: number, + days: Day[], +): boolean => { + const startDay = days.find(d => d.id === startDayId) + const endDay = days.find(d => d.id === endDayId) + if (!startDay || !endDay) { + // Endpoint days not in the loaded array (e.g. sparse test data or partial load). + // Fall back to numeric ID range — acceptable since non-monotonic IDs only arise when + // both endpoints are present in a fully-loaded trip's days list. + return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId) + } + const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days)) + const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days)) + return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi +}