feat: multi-link files to multiple bookings and places — closes #23
Files can now be linked to multiple bookings and places simultaneously via a new file_links junction table. Booking modal includes a file picker to link existing uploads. Unlinking removes the association without deleting the file.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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<number>()
|
||||
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<number>()
|
||||
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 && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||
|
||||
{linkedPlace && (
|
||||
<SourceBadge icon={MapPin} label={`${t('files.sourcePlan')} · ${linkedPlace.name}`} />
|
||||
)}
|
||||
{linkedReservation && (
|
||||
<SourceBadge icon={Ticket} label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`} />
|
||||
)}
|
||||
{linkedPlaces.map(p => (
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
))}
|
||||
{linkedReservations.map(r => (
|
||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
))}
|
||||
{file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
)}
|
||||
@@ -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) => (
|
||||
<button key={p.id} onClick={() => handleAssign(file.id, { place_id: file.place_id === p.id ? null : p.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.place_id === p.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.place_id === p.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{file.place_id === p.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
const placeBtn = (p: Place) => {
|
||||
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.place_id === p.id) {
|
||||
await handleAssign(file.id, { place_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
if (!file.place_id) {
|
||||
await handleAssign(file.id, { place_id: p.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const placesSection = places.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -519,20 +549,47 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{reservations.map(r => (
|
||||
<button key={r.id} onClick={() => handleAssign(file.id, { reservation_id: file.reservation_id === r.id ? null : r.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: file.reservation_id === r.id ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: file.reservation_id === r.id ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = file.reservation_id === r.id ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{file.reservation_id === r.id && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
))}
|
||||
{reservations.map(r => {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
// Link: if no primary, set it; otherwise use file_links
|
||||
if (!file.reservation_id) {
|
||||
await handleAssign(file.id, { reservation_id: r.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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<number[]>([])
|
||||
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||
|
||||
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
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||
{onFileDelete && (
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
// Remove from file_links if linked there
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||
} catch {}
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
@@ -477,14 +503,56 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{showFilePicker && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => {
|
||||
try {
|
||||
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||
setLinkedFileIds(prev => [...prev, f.id])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch {}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -750,6 +750,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.pendingSave': 'سيتم الحفظ…',
|
||||
'reservations.uploading': 'جارٍ الرفع...',
|
||||
'reservations.attachFile': 'إرفاق ملف',
|
||||
'reservations.linkExisting': 'ربط ملف موجود',
|
||||
'reservations.toast.saveError': 'فشل الحفظ',
|
||||
'reservations.toast.updateError': 'فشل التحديث',
|
||||
'reservations.toast.deleteError': 'فشل الحذف',
|
||||
|
||||
@@ -758,6 +758,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -754,6 +754,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -706,6 +706,7 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -743,6 +743,7 @@ const fr: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -743,6 +743,7 @@ const nl: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -743,6 +743,7 @@ const ru: Record<string, string> = {
|
||||
'reservations.pendingSave': 'будет сохранено…',
|
||||
'reservations.uploading': 'Загрузка...',
|
||||
'reservations.attachFile': 'Прикрепить файл',
|
||||
'reservations.linkExisting': 'Привязать существующий файл',
|
||||
'reservations.toast.saveError': 'Ошибка сохранения',
|
||||
'reservations.toast.updateError': 'Ошибка обновления',
|
||||
'reservations.toast.deleteError': 'Ошибка удаления',
|
||||
|
||||
@@ -743,6 +743,7 @@ const zh: Record<string, string> = {
|
||||
'reservations.pendingSave': '将被保存…',
|
||||
'reservations.uploading': '上传中...',
|
||||
'reservations.attachFile': '附加文件',
|
||||
'reservations.linkExisting': '关联已有文件',
|
||||
'reservations.toast.saveError': '保存失败',
|
||||
'reservations.toast.updateError': '更新失败',
|
||||
'reservations.toast.deleteError': '删除失败',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<number, number[]> = {};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user