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 +}