diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 7e8f28b..6726140 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -197,6 +197,9 @@ export const filesApi = { restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data), permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data), emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data), + addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data), + removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data), + getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } export const reservationsApi = { diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index c5fc0bd..2f62c1b 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -302,10 +302,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, const renderFileRow = (file: TripFile, isTrash = false) => { const FileIcon = getFileIcon(file.mime_type) - const linkedPlace = places?.find(p => p.id === file.place_id) - const linkedReservation = file.reservation_id - ? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title }) - : null + const allLinkedPlaceIds = new Set() + if (file.place_id) allLinkedPlaceIds.add(file.place_id) + for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid) + const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean) + // All linked reservations (primary + file_links) + const allLinkedResIds = new Set() + if (file.reservation_id) allLinkedResIds.add(file.reservation_id) + for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid) + const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean) const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`) return ( @@ -365,12 +370,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, {file.file_size && {formatSize(file.file_size)}} {formatDateWithLocale(file.created_at, locale)} - {linkedPlace && ( - - )} - {linkedReservation && ( - - )} + {linkedPlaces.map(p => ( + + ))} + {linkedReservations.map(r => ( + + ))} {file.note_id && ( )} @@ -477,20 +482,45 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, } } const unassigned = places.filter(p => !assignedPlaceIds.has(p.id)) - const placeBtn = (p: Place) => ( - - ) + const placeBtn = (p: Place) => { + const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id) + return ( + + ) + } const placesSection = places.length > 0 && (
@@ -519,20 +549,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{t('files.assignBooking')}
- {reservations.map(r => ( - - ))} + {reservations.map(r => { + const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id) + return ( + + ) + })}
) diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index a6b993e..7d46a7c 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -169,7 +169,7 @@ export default function PlaceInspector({ const selectedDay = days?.find(d => d.id === selectedDayId) const weekdayIndex = getWeekdayIndex(selectedDay?.date) - const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id)) + const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id)) const handleFileUpload = useCallback(async (e) => { const selectedFiles = Array.from((e.target as HTMLInputElement).files || []) diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index dafaac4..85d9e88 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -1,4 +1,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' +import { useParams } from 'react-router-dom' +import apiClient from '../../api/client' +import { useTripStore } from '../../store/tripStore' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' @@ -62,6 +65,8 @@ interface ReservationModalProps { } export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) { + const { id: tripId } = useParams<{ id: string }>() + const loadFiles = useTripStore(s => s.loadFiles) const toast = useToast() const { t, locale } = useTranslation() const fileInputRef = useRef(null) @@ -78,6 +83,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const [isSaving, setIsSaving] = useState(false) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) + const [showFilePicker, setShowFilePicker] = useState(false) + const [linkedFileIds, setLinkedFileIds] = useState([]) + const [unlinkedFileIds, setUnlinkedFileIds] = useState([]) const assignmentOptions = useMemo( () => buildAssignmentOptions(days, assignments, t, locale), @@ -204,7 +212,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p } } - const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : [] + 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, @@ -459,11 +473,23 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p {f.original_name} - {onFileDelete && ( - - )} + ))} {pendingFiles.map((f, i) => ( @@ -477,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p ))} - +
+ + {/* Link existing file picker */} + {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 => ( + + ))} +
+ )} +
+ )} +
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index c8de359..86c844f 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -65,7 +65,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo const typeInfo = getType(r.type) const TypeIcon = typeInfo.Icon const confirmed = r.status === 'confirmed' - const attachedFiles = files.filter(f => f.reservation_id === r.id) + const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id)) const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null const handleToggle = async () => { diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index c216f35..e4d5070 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -750,6 +750,7 @@ const ar: Record = { 'reservations.pendingSave': 'سيتم الحفظ…', 'reservations.uploading': 'جارٍ الرفع...', 'reservations.attachFile': 'إرفاق ملف', + 'reservations.linkExisting': 'ربط ملف موجود', 'reservations.toast.saveError': 'فشل الحفظ', 'reservations.toast.updateError': 'فشل التحديث', 'reservations.toast.deleteError': 'فشل الحذف', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index fdc437d..840a4f2 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -758,6 +758,7 @@ const de: Record = { 'reservations.pendingSave': 'wird gespeichert…', 'reservations.uploading': 'Wird hochgeladen...', 'reservations.attachFile': 'Datei anhängen', + 'reservations.linkExisting': 'Vorhandene verknüpfen', 'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen', 'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...', 'reservations.noAssignment': 'Keine Verknüpfung', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 4a89978..18d79ac 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -754,6 +754,7 @@ const en: Record = { 'reservations.pendingSave': 'will be saved…', 'reservations.uploading': 'Uploading...', 'reservations.attachFile': 'Attach file', + 'reservations.linkExisting': 'Link existing file', 'reservations.toast.saveError': 'Failed to save', 'reservations.toast.updateError': 'Failed to update', 'reservations.toast.deleteError': 'Failed to delete', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index e8448fc..3cb293f 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -706,6 +706,7 @@ const es: Record = { 'reservations.pendingSave': 'se guardará…', 'reservations.uploading': 'Subiendo...', 'reservations.attachFile': 'Adjuntar archivo', + 'reservations.linkExisting': 'Vincular archivo existente', 'reservations.toast.saveError': 'No se pudo guardar', 'reservations.toast.updateError': 'No se pudo actualizar', 'reservations.toast.deleteError': 'No se pudo eliminar', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 51e95cd..722e8d8 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -743,6 +743,7 @@ const fr: Record = { 'reservations.pendingSave': 'sera enregistré…', 'reservations.uploading': 'Téléversement...', 'reservations.attachFile': 'Joindre un fichier', + 'reservations.linkExisting': 'Lier un fichier existant', 'reservations.toast.saveError': 'Échec de l\'enregistrement', 'reservations.toast.updateError': 'Échec de la mise à jour', 'reservations.toast.deleteError': 'Échec de la suppression', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 89407f7..add8dd6 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -743,6 +743,7 @@ const nl: Record = { 'reservations.pendingSave': 'wordt opgeslagen…', 'reservations.uploading': 'Uploaden...', 'reservations.attachFile': 'Bestand bijvoegen', + 'reservations.linkExisting': 'Bestaand bestand koppelen', 'reservations.toast.saveError': 'Opslaan mislukt', 'reservations.toast.updateError': 'Bijwerken mislukt', 'reservations.toast.deleteError': 'Verwijderen mislukt', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 75d29ba..039f8dc 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -743,6 +743,7 @@ const ru: Record = { 'reservations.pendingSave': 'будет сохранено…', 'reservations.uploading': 'Загрузка...', 'reservations.attachFile': 'Прикрепить файл', + 'reservations.linkExisting': 'Привязать существующий файл', 'reservations.toast.saveError': 'Ошибка сохранения', 'reservations.toast.updateError': 'Ошибка обновления', 'reservations.toast.deleteError': 'Ошибка удаления', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index fbe1121..6da7988 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -743,6 +743,7 @@ const zh: Record = { 'reservations.pendingSave': '将被保存…', 'reservations.uploading': '上传中...', 'reservations.attachFile': '附加文件', + 'reservations.linkExisting': '关联已有文件', 'reservations.toast.saveError': '保存失败', 'reservations.toast.updateError': '更新失败', 'reservations.toast.deleteError': '删除失败', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 816288b..55078fd 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -307,6 +307,20 @@ function runMigrations(db: Database.Database): void { db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7); } catch {} }, + () => { + // Allow files to be linked to multiple reservations/assignments + db.exec(`CREATE TABLE IF NOT EXISTS file_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE, + reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE, + assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(file_id, reservation_id), + UNIQUE(file_id, assignment_id), + UNIQUE(file_id, place_id) + )`); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 378f074..a48c2d4 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -82,7 +82,27 @@ router.get('/', authenticate, (req: Request, res: Response) => { const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL'; const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[]; - res.json({ files: files.map(formatFile) }); + + // Get all file_links for this trip's files + const fileIds = files.map(f => f.id); + let linksMap: Record = {}; + if (fileIds.length > 0) { + const placeholders = fileIds.map(() => '?').join(','); + const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as { file_id: number; reservation_id: number | null; place_id: number | null }[]; + for (const link of links) { + if (!linksMap[link.file_id]) linksMap[link.file_id] = []; + linksMap[link.file_id].push(link); + } + } + + res.json({ files: files.map(f => { + const fileLinks = linksMap[f.id] || []; + return { + ...formatFile(f), + linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id), + linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id), + }; + })}); }); // Upload file @@ -239,4 +259,55 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => { res.json({ success: true, deleted: trashed.length }); }); +// Link a file to a reservation (many-to-many) +router.post('/:id/link', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, id } = req.params; + const { reservation_id, assignment_id, place_id } = req.body; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!file) return res.status(404).json({ error: 'File not found' }); + + try { + db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run( + id, reservation_id || null, assignment_id || null, place_id || null + ); + } catch {} + + const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id); + res.json({ success: true, links }); +}); + +// Unlink a file from a reservation +router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, id, linkId } = req.params; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, id); + res.json({ success: true }); +}); + +// Get all links for a file +router.get('/:id/links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, id } = req.params; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const links = db.prepare(` + SELECT fl.*, r.title as reservation_title + FROM file_links fl + LEFT JOIN reservations r ON fl.reservation_id = r.id + WHERE fl.file_id = ? + `).all(id); + res.json({ links }); +}); + export default router;