Files
TREK/client/src/components/Files/FileManagerAssignModal.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

211 lines
11 KiB
TypeScript

import { useId } from 'react'
import { MapPin, Ticket, Check, Paperclip } from 'lucide-react'
import { filesApi } from '../../api/client'
import type { Place, Reservation, Day } from '../../types'
import type { FileManagerState } from './useFileManager'
import { TRANSPORT_TYPES } from './FileManager.constants'
import { transportIcon } from './FileManager.helpers'
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)
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 || '')) {
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={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: '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={{ marginLeft: '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={async () => {
if (isLinked) {
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 {
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: '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={{ marginLeft: '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', paddingRight: 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', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
</div>
)
})()}
</div>
</DialogShell>
)
}