mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
92c48fba22
Every file write went straight to the API, so offline a star snapped back
without a word and moving a file to the trash failed with "Failed to delete
file". Notes, the place or booking of a file, a further link, the star, the
trash move, restore, delete for good and emptying the trash now go through
fileRepo and the mutation queue like the other trip data: the local copy
changes at once, and an online write to a file that still has an older one
queued joins the queue behind it, since file writes carry no version token.
The desktop file manager, its assign dialog and the phone's files tab,
menu, link and trash sheets all write through it.
The single file answers carry no ids of further links, so a replayed edit,
star or restore keeps the ones the local copy holds, and the link answer
({ success, links }) writes no row.
What only works online says so instead of failing: an upload shows that it
needs a connection, a place saved with newly picked files is stopped with a
hint, and removing a further link (its row id is known only to the server)
asks to try again once online.
Refs #2601
183 lines
10 KiB
TypeScript
183 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'
|
|
import { translateApiError } from '../../i18n'
|
|
|
|
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 (err) {
|
|
// Offline a link record cannot be removed, and the hint says so.
|
|
S.toast.error(translateApiError(t, err, 'files.toast.assignError'))
|
|
}
|
|
}
|
|
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>
|
|
)
|
|
}
|