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/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/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)}