mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
076ed897e7
useFileTrash holds the trashed files with their restore, permanent delete and empty actions; the desktop trash view loads it on demand and the phone sheet while it is open. fileActions holds the clipboard file picking and the two tier link toggle (own column first, then link records) that the desktop assign dialog and the phone link sheet both ran on their own.
181 lines
10 KiB
TypeScript
181 lines
10 KiB
TypeScript
import { useId } from 'react'
|
|
import { MapPin, Ticket, Check, Paperclip } from 'lucide-react'
|
|
import type { Place, Reservation, Day, TripFile } from '../../types'
|
|
import type { FileManagerState } from './useFileManager'
|
|
import { TRANSPORT_TYPES } from './FileManager.constants'
|
|
import { transportIcon } from './FileManager.helpers'
|
|
import { planFileLinkToggle, runFileLinkRecordStep, type FileLinkField } from './fileActions'
|
|
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell'
|
|
import { EditorField, INPUT } from '../shared/dialogParts'
|
|
|
|
export function AssignModal(S: FileManagerState) {
|
|
const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S
|
|
const labelId = useId()
|
|
const close = () => setAssignFileId(null)
|
|
// The file's own column goes through handleAssign, which toasts a failure.
|
|
const toggleLink = async (file: TripFile, field: FileLinkField, targetId: number) => {
|
|
const step = planFileLinkToggle(file, field, targetId)
|
|
if (step.kind === 'update') {
|
|
await handleAssign(file.id, step.data)
|
|
return
|
|
}
|
|
try {
|
|
await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind)
|
|
refreshFiles()
|
|
} catch {
|
|
// A failed link record change stays quiet.
|
|
}
|
|
}
|
|
return (
|
|
<DialogShell
|
|
onClose={close}
|
|
labelledBy={labelId}
|
|
header={(
|
|
<DialogHeader
|
|
tile={<DialogTile><Paperclip size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
|
tint={NEUTRAL_TINT}
|
|
labelId={labelId}
|
|
onClose={close}
|
|
eyebrow={t('files.assignTitle')}
|
|
title={files.find(f => f.id === assignFileId)?.original_name || ''}
|
|
/>
|
|
)}
|
|
>
|
|
<EditorField label={t('files.noteLabel') || 'Note'}>
|
|
<input
|
|
type="text"
|
|
placeholder={t('files.notePlaceholder')}
|
|
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
|
|
onBlur={e => {
|
|
const val = e.target.value.trim()
|
|
const file = files.find(f => f.id === assignFileId)
|
|
if (file && val !== (file.description || '')) {
|
|
void handleAssign(file.id, { description: val } as any)
|
|
}
|
|
}}
|
|
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
|
className={INPUT}
|
|
/>
|
|
</EditorField>
|
|
<div className="rounded-[16px] bg-surface-secondary p-2">
|
|
{(() => {
|
|
const file = files.find(f => f.id === assignFileId)
|
|
if (!file) return null
|
|
const assignedPlaceIds = new Set<number>()
|
|
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
|
|
for (const day of days) {
|
|
const da = assignments[String(day.id)] || []
|
|
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
|
|
if (dayPlaces.length > 0) {
|
|
dayGroups.push({ day, dayPlaces })
|
|
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
|
|
}
|
|
}
|
|
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
|
const placeBtn = (p: Place, idx: number) => {
|
|
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
|
return (
|
|
<button type="button" key={`${p.id}-${idx}`} onClick={() => toggleLink(file, 'place_id', p.id)} style={{
|
|
width: '100%', textAlign: 'start', paddingBlock: 6, paddingInlineEnd: 10, paddingInlineStart: 20, background: isLinked ? 'var(--bg-hover)' : 'none',
|
|
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', 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={{ marginInlineStart: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
const placesSection = places.length > 0 && (
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
{t('files.assignPlace')}
|
|
</div>
|
|
{dayGroups.map(({ day, dayPlaces }) => (
|
|
<div key={day.id}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
|
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
|
{(() => {
|
|
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
|
|
return badge ? (
|
|
<span style={{
|
|
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)',
|
|
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
|
|
}}>{badge}</span>
|
|
) : null
|
|
})()}
|
|
</div>
|
|
{dayPlaces.map((p, i) => placeBtn(p, i))}
|
|
</div>
|
|
))}
|
|
{unassigned.length > 0 && (
|
|
<div>
|
|
{dayGroups.length > 0 && <div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
|
|
{unassigned.map((p, i) => placeBtn(p, i))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
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 (
|
|
<button type="button" key={r.id} onClick={() => toggleLink(file, 'reservation_id', r.id)} style={{
|
|
width: '100%', textAlign: 'start', paddingBlock: 6, paddingInlineEnd: 10, paddingInlineStart: 20, background: isLinked ? 'var(--bg-hover)' : 'none',
|
|
border: 'none', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', 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'}>
|
|
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
|
{isLinked && <Check size={14} style={{ marginInlineStart: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
const bookingsSection = reservations.length > 0 && (
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
{bookingReservations.length > 0 && (
|
|
<>
|
|
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
{t('files.assignBooking')}
|
|
</div>
|
|
{bookingReservations.map(reservationBtn)}
|
|
</>
|
|
)}
|
|
{transportReservations.length > 0 && (
|
|
<>
|
|
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
|
|
{t('files.assignTransport')}
|
|
</div>
|
|
{transportReservations.map(reservationBtn)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
const hasBoth = placesSection && bookingsSection
|
|
return (
|
|
<div className={hasBoth ? 'md:flex' : ''}>
|
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingInlineEnd: hasBoth ? 6 : 0 }}>{placesSection}</div>
|
|
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
|
|
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
|
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingInlineStart: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</DialogShell>
|
|
)
|
|
}
|