Files
TREK/client/src/components/Planner/TransitJourneyModal.tsx
T
Maurice d4f4ab3891 fix(bookings): the delete question for a booking with a linked expense says the expense is deleted too
Deleting a booking also deletes the expenses linked to it, but the
question only said the booking would be deleted. A booking with a linked
expense now says so in every place that asks: the bookings list and detail,
the transport and transit dialogs, and the phone's booking cards. The MCP
delete_reservation description names the linked expenses and the stay a
hotel booking created as well.

Refs #2604
2026-10-12 20:42:55 +02:00

541 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { ArrowRight, ArrowRightLeft, Bold, CalendarDays, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, MoveRight, Pencil, RefreshCw, Strikethrough, TramFront, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton, PILL, fs } from '../shared/DialogShell'
import { Segmented, TEXTAREA } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { BookingDialogHeader, TypePill } from './bookings/BookingDialogShell'
import { TransitLegs } from './bookings/transitParts'
import { deleteBodyKey } from './bookings/bookingsModel'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useTripStore } from '../../store/tripStore'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { TransitTitle, TransitMetaBadges, TransitWalkDivider, fmtTransitDuration } from './transitDisplay'
import type { Reservation } from '../../types'
import { useIsPhone } from '../../mobile/useIsPhone'
/**
* The journey view for an automated public-transit entry (#1065): a roomy modal
* around the stop-by-stop itinerary. The title renames inline right in the
* header, notes get the full width with markdown support, and "Change route"
* re-enters the transit search pre-seeded with this journey's route. "Edit
* details" hands off to the full transport editor for the booking fields
* (travelers, costs, files, code, status — #2148).
*/
interface TransitLegMeta {
mode?: string
line?: string | null
line_color?: string | null
line_text_color?: string | null
headsign?: string | null
agency?: string | null
duration?: number
stops?: number
from?: { name?: string; time?: string | null; track?: string | null }
to?: { name?: string; time?: string | null; track?: string | null }
}
interface TransitJourneyModalProps {
reservation: Reservation
onClose: () => void
/** Partial field update — endpoints + itinerary stay untouched. */
onSave: (fields: { title: string; notes: string | null }) => Promise<unknown>
onDelete: () => Promise<unknown>
onChangeRoute: () => void
/** Switch to the full transport editor (travelers, costs, files, code, status). */
onEditDetails?: () => void
canEdit: boolean
}
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, onEditDetails, canEdit }: TransitJourneyModalProps) {
const { t, locale } = useTranslation()
const isMobile = useIsPhone()
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
const budgetItems = useTripStore(st => st.budgetItems)
const res = reservation
const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {})
const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
const [title, setTitle] = useState(res.title || '')
const [editingTitle, setEditingTitle] = useState(false)
const [notes, setNotes] = useState(res.notes || '')
// Existing notes open rendered; the write tab is for editing.
const [notesTab, setNotesTab] = useState<'write' | 'preview'>(() => (res.notes ? 'preview' : 'write'))
const [saving, setSaving] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const titleInputRef = useRef<HTMLInputElement | null>(null)
const notesRef = useRef<HTMLTextAreaElement | null>(null)
// Markdown toolbar: wrap the selection / prefix the current lines, then
// restore focus and a sensible cursor.
const applyMd = (action: { wrap?: [string, string]; linePrefix?: string }) => {
const el = notesRef.current
if (!el) return
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
const value = notes
let next = value
let selStart = start
let selEnd = end
if (action.wrap) {
const [pre, post] = action.wrap
const selected = value.slice(start, end)
next = value.slice(0, start) + pre + selected + post + value.slice(end)
selStart = start + pre.length
selEnd = selStart + selected.length
} else if (action.linePrefix) {
const prefix = action.linePrefix
const lineStart = value.lastIndexOf('\n', start - 1) + 1
const block = value.slice(lineStart, end)
const prefixed = block.split('\n').map(l => prefix + l).join('\n')
next = value.slice(0, lineStart) + prefixed + value.slice(end)
selStart = start + prefix.length
selEnd = end + (prefixed.length - block.length)
}
setNotes(next)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(selStart, selEnd)
})
}
const MD_TOOLS: { Icon: typeof Bold; label: string; action: { wrap?: [string, string]; linePrefix?: string } }[] = [
{ Icon: Bold, label: 'Bold', action: { wrap: ['**', '**'] } },
{ Icon: Italic, label: 'Italic', action: { wrap: ['*', '*'] } },
{ Icon: Strikethrough, label: 'Strikethrough', action: { wrap: ['~~', '~~'] } },
{ Icon: Heading2, label: 'Heading', action: { linePrefix: '## ' } },
{ Icon: List, label: 'List', action: { linePrefix: '- ' } },
{ Icon: ListChecks, label: 'Checklist', action: { linePrefix: '- [ ] ' } },
{ Icon: Link2, label: 'Link', action: { wrap: ['[', '](https://)'] } },
{ Icon: Code, label: 'Code', action: { wrap: ['`', '`'] } },
]
useEffect(() => {
setTitle(res.title || '')
setNotes(res.notes || '')
setEditingTitle(false)
setNotesTab(res.notes ? 'preview' : 'write')
}, [res.id])
useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle])
const dirty = title !== (res.title || '') || notes !== (res.notes || '')
const save = async () => {
if (!title.trim()) return
setSaving(true)
try {
await onSave({ title: title.trim(), notes: notes.trim() || null })
onClose()
} finally { setSaving(false) }
}
const { date, time } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) : ''
const statTiles = transit ? [
{ Icon: Clock, value: transit.duration > 0 ? fmtTransitDuration(transit.duration, t) : '—', label: t('transit.durationLabel') },
{ Icon: ArrowRightLeft, value: String(transit.transfers ?? 0), label: t('transit.transfersLabel') },
{ Icon: Footprints, value: transit.walk_seconds > 59 ? t('transit.min', { count: Math.round(transit.walk_seconds / 60) }) : '—', label: t('transit.walkLabel') },
] : []
if (!isMobile) {
const titleId = `transit-journey-${res.id}`
const timeText = time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : ''
const header = (
<BookingDialogHeader
tone="transit"
type="transit"
labelId={titleId}
onClose={onClose}
title={<TransitTitle title={title} iconSize={16} />}
onTitleClick={canEdit ? () => setEditingTitle(true) : undefined}
titleTooltip={t('reservations.rename')}
titleInput={editingTitle ? {
value: title,
onChange: setTitle,
label: t('reservations.titleLabel'),
autoFocus: true,
onBlur: () => setEditingTitle(false),
onKeyDown: e => {
if (e.key === 'Enter') setEditingTitle(false)
// Spent here, so the dialog stays open and only the rename is dropped.
if (e.key === 'Escape') { e.preventDefault(); setTitle(res.title || ''); setEditingTitle(false) }
},
} : undefined}
pills={(
<>
<TypePill type="transit" />
{dateStr && <span className={PILL}><CalendarDays size={13} strokeWidth={2.2} className="text-content-faint" />{dateStr}</span>}
{timeText && <span className={PILL}><Clock size={13} strokeWidth={2.2} className="text-content-faint" /><span className="tabular-nums">{timeText}</span></span>}
</>
)}
/>
)
const footer = (
<DialogFooter>
{canEdit && <DialogButton onClick={onChangeRoute} icon={<RefreshCw size={14} strokeWidth={2} />}>{t('transit.changeRoute')}</DialogButton>}
{canEdit && onEditDetails && <DialogButton onClick={onEditDetails} icon={<Pencil size={14} strokeWidth={2} />}>{t('transit.editDetails')}</DialogButton>}
<FooterSpacer />
{canEdit && <DeleteButton onClick={() => setConfirmDelete(true)} />}
{canEdit
? <DialogButton variant="primary" onClick={save} disabled={saving || !title.trim() || !dirty}>{saving ? t('common.saving') : t('common.save')}</DialogButton>
: <DialogButton variant="primary" onClick={onClose}>{t('common.close')}</DialogButton>}
</DialogFooter>
)
const noteTools = canEdit && (
<span className="flex items-center gap-2">
{notesTab === 'write' && (
<span className="flex gap-px rounded-[10px] bg-surface-tertiary p-[3px]">
{MD_TOOLS.map(({ Icon, label, action }) => (
<Tooltip key={label} label={label} placement="top">
<button type="button" onClick={() => applyMd(action)} aria-label={label}
className="grid h-7 w-7 place-items-center rounded-[8px] text-content-muted hover:bg-surface-card hover:text-content">
<Icon size={13} strokeWidth={2} />
</button>
</Tooltip>
))}
</span>
)}
<Segmented<'write' | 'preview'>
value={notesTab}
onChange={setNotesTab}
label={t('reservations.notes')}
options={[{ value: 'write', label: t('common.edit') }, { value: 'preview', label: t('common.preview') }]}
/>
</span>
)
return (
<>
<DialogShell onClose={onClose} labelledBy={titleId} blocked={confirmDelete} header={header} footer={footer}>
{transit && (
<div className="flex gap-2">
{statTiles.map(({ value, label }) => (
<div key={label} className="min-w-0 flex-1 rounded-[12px] bg-surface-tertiary px-3 py-2.5 text-center">
<div className="truncate font-bold tabular-nums tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>{value}</div>
<div className="mt-0.5 truncate font-geist text-content-faint" style={fs(10.5)}>{label}</div>
</div>
))}
</div>
)}
{transit && transit.legs.length > 0 && (
<DialogSection label={t('transit.itinerary')}>
<TransitLegs legs={transit.legs as TransitLegMeta[]} />
</DialogSection>
)}
<DialogSection label={t('reservations.notes')} action={noteTools}>
{canEdit && notesTab === 'write' ? (
<textarea
ref={notesRef}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={t('reservations.notesPlaceholder')}
aria-label={t('reservations.notes')}
className={TEXTAREA}
style={{ minHeight: 130, resize: 'vertical', lineHeight: 1.55 }}
/>
) : (
<div className={`rounded-[10px] border border-edge-faint bg-surface-card px-3.5 py-3 ${canEdit ? 'min-h-[130px]' : ''}`}>
{notes.trim()
? <div className="collab-note-md break-words text-content" style={fs(13, 'body')}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{notes}</Markdown></div>
: <span className="text-content-faint" style={fs(12.5, 'body')}>{t('reservations.notesPlaceholder')}</span>}
</div>
)}
</DialogSection>
</DialogShell>
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }}
title={t('reservations.confirm.deleteTitle')}
message={t(deleteBodyKey(res.id, budgetItems), { name: res.title })}
/>
</>
)
}
return (
<Modal
isOpen
onClose={onClose}
title={t('transit.journey')}
size="2xl"
footer={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{canEdit && (
<button type="button" onClick={() => setConfirmDelete(true)} aria-label={t('common.delete')} title={t('common.delete')} style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: isMobile ? '9px 11px' : '8px 14px', borderRadius: 10,
border: '1px solid rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.06)', color: '#ef4444',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Trash2 size={13} /> {!isMobile && t('common.delete')}
</button>
)}
<div style={{ flex: 1 }} />
{canEdit && (
<button type="button" onClick={onChangeRoute} className="text-content-muted" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
}}>
<RefreshCw size={13} /> {t('transit.changeRoute')}
</button>
)}
{canEdit && onEditDetails && (
<button type="button" onClick={onEditDetails} className="text-content-muted" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
border: '1px solid var(--border-primary)', background: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Pencil size={13} /> {t('transit.editDetails')}
</button>
)}
{canEdit ? (
<button type="button" onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
padding: '8px 20px', borderRadius: 10, border: 'none',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
opacity: saving || !title.trim() || !dirty ? 0.5 : 1,
}}>
{saving ? t('common.saving') : t('common.save')}
</button>
) : (
<button type="button" onClick={onClose} className="bg-accent text-accent-text" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.close')}
</button>
)}
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, fontFamily: 'var(--font-system)' }}>
{/* header: icon + inline-renamable title + date/time */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ width: isMobile ? 40 : 48, height: isMobile ? 40 : 48, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 13, background: '#7c3aed18' }}>
<TramFront size={isMobile ? 19 : 23} strokeWidth={1.8} color="#7c3aed" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{editingTitle ? (
<input
ref={titleInputRef}
value={title}
onChange={e => setTitle(e.target.value)}
onBlur={() => setEditingTitle(false)}
onKeyDown={e => { if (e.key === 'Enter') setEditingTitle(false); if (e.key === 'Escape') { setTitle(res.title || ''); setEditingTitle(false) } }}
className="text-content"
aria-label={t('reservations.titleLabel')}
style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', padding: 0, fontFamily: 'inherit', fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', borderBottom: '1.5px solid var(--text-primary)' }}
/>
) : (
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.015em', minWidth: 0 }}>
<span style={{ minWidth: 0, overflow: 'hidden' }}><TransitTitle title={title} iconSize={15} /></span>
{canEdit && (
<button type="button" onClick={() => setEditingTitle(true)} aria-label={t('common.edit')} title={t('common.edit')} className="text-content-faint" style={{ border: 'none', background: 'none', padding: 3, cursor: 'pointer', display: 'flex', flexShrink: 0 }}>
<Pencil size={13} strokeWidth={1.8} />
</button>
)}
</div>
)}
<div className="text-content-muted" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', marginTop: 3 }}>
{[dateStr, time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : ''].filter(Boolean).join(' · ')}
</div>
</div>
</div>
{transit && (
<>
{/* journey stats — three full-width tiles; iconless and flat on mobile */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: isMobile ? 6 : 10 }}>
{statTiles.map(({ Icon, value, label }, i) => (
isMobile ? (
<div key={i} className="bg-surface-tertiary" style={{ padding: '7px 6px 6px', borderRadius: 11, textAlign: 'center', minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: 1 }}>{label}</div>
</div>
) : (
<div key={i} className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 16px', borderRadius: 12 }}>
<div className="bg-surface-card" style={{ width: 34, height: 34, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 10 }}>
<Icon size={16} strokeWidth={1.9} className="text-content-muted" />
</div>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em', whiteSpace: 'nowrap' }}>{value}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
</div>
</div>
)
))}
</div>
{/* stop-by-stop itinerary */}
<div className="bg-surface-tertiary" style={{ padding: isMobile ? '11px 10px' : '14px 16px', borderRadius: 12 }}>
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 12 }}>
{t('transit.itinerary')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{(transit.legs as TransitLegMeta[]).map((leg, i) => {
if (leg.mode === 'WALK') return <TransitWalkDivider key={i} leg={leg} t={t} size={isMobile ? 'sm' : 'md'} />
const mins = leg.duration ? Math.round(leg.duration / 60) : null
if (isMobile) {
// The wide from → to line plus the chip row doesn't fit a
// phone: each leg becomes a depart / ride / arrive rail in
// the line's color, meta as one quiet text line.
const color = leg.line_color || 'var(--text-muted)'
const metaLine = [
mins ? t('transit.min', { count: mins }) : null,
leg.stops ? t('transit.stops', { count: leg.stops }) : null,
leg.agency || null,
].filter(Boolean).join(' · ')
// Names wrap instead of clipping; the platform gets its
// own quiet line below so it never pushes the name out.
const stopName = (stop: TransitLegMeta['from']) => (
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, lineHeight: 1.35, overflowWrap: 'anywhere' }}>
{stop?.name}
</div>
{stop?.track && (
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
{t('transit.platform', { track: stop.track })}
</div>
)}
</div>
)
const timeCell = (time?: string | null) => (
<div className="text-content-muted" style={{ fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'end', paddingTop: 1 }}>
{time || ''}
</div>
)
return (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '34px 14px 1fr', columnGap: 8 }}>
{timeCell(leg.from?.time)}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-tertiary)', flexShrink: 0, marginTop: 3 }} />
<span style={{ flex: 1, width: 3, borderRadius: 2, background: color, marginTop: 2 }} />
</div>
{stopName(leg.from)}
<div />
<div style={{ display: 'flex', justifyContent: 'center' }}>
<span style={{ width: 3, borderRadius: 2, background: color }} />
</div>
<div style={{ padding: '6px 0 8px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 6, padding: '1px 7px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
{leg.line || leg.mode}
</span>
{leg.headsign && (
<span className="text-content-faint" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', minWidth: 0 }}>
<MoveRight size={10} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{leg.headsign}</span>
</span>
)}
</div>
{metaLine && (
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', marginTop: 3 }}>{metaLine}</div>
)}
</div>
{timeCell(leg.to?.time)}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', border: `2.5px solid ${color}`, background: 'var(--bg-tertiary)', marginTop: 3 }} />
</div>
{stopName(leg.to)}
</div>
)
}
return (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div className="text-content-muted" style={{ width: 44, flexShrink: 0, textAlign: 'end', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, paddingTop: 1 }}>
{leg.from?.time || ''}
</div>
<span style={{ display: 'inline-flex', alignItems: 'center', background: leg.line_color || 'var(--bg-hover)', color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', borderRadius: 6, padding: '2px 8px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
{leg.line || leg.mode}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.from?.name}</span>
{leg.from?.track && <span className="text-content-faint" style={{ fontWeight: 500 }}>({t('transit.platform', { track: leg.from.track })})</span>}
<ArrowRight size={12} className="text-content-faint" style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{leg.to?.name}</span>
</div>
<div style={{ marginTop: 3 }}>
<TransitMetaBadges items={[
{ icon: Clock, text: leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : '' },
{ text: mins ? t('transit.min', { count: mins }) : '' },
{ text: leg.stops ? t('transit.stops', { count: leg.stops }) : '' },
{ icon: MoveRight, text: leg.headsign || '' },
{ text: leg.agency || '', dim: true },
]} />
</div>
</div>
</div>
)
})}
</div>
</div>
</>
)}
{/* notes — full width, markdown */}
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, gap: 8, flexWrap: 'wrap' }}>
<label className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.notes')}</label>
{canEdit && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{notesTab === 'write' && (
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 1 }}>
{MD_TOOLS.map(({ Icon, label, action }) => (
<button key={label} type="button" onClick={() => applyMd(action)} title={label} aria-label={label}
className="text-content-muted"
style={{ width: 26, height: 24, display: 'grid', placeItems: 'center', borderRadius: 6, border: 0, background: 'transparent', cursor: 'pointer' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}>
<Icon size={13} strokeWidth={2} />
</button>
))}
</div>
)}
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2, gap: 2 }}>
{([['write', t('common.edit')], ['preview', t('common.preview')]] as const).map(([tab, label]) => (
<button key={tab} type="button" onClick={() => setNotesTab(tab)}
className={notesTab === tab ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 6, border: 0, cursor: 'pointer', fontFamily: 'inherit', background: notesTab === tab ? undefined : 'transparent' }}>
{label}
</button>
))}
</div>
</div>
)}
</div>
{canEdit && notesTab === 'write' ? (
<textarea
ref={notesRef}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={t('reservations.notesPlaceholder')}
className="w-full border border-edge rounded-[10px] px-[12px] py-[10px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input"
style={{ minHeight: 130, resize: 'vertical', lineHeight: 1.55 }}
/>
) : (
<div className="bg-surface-tertiary" style={{ borderRadius: 10, padding: '12px 14px', minHeight: canEdit ? 130 : undefined }}>
{notes.trim()
? <div className="collab-note-md text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{notes}</Markdown></div>
: <span className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('reservations.notesPlaceholder')}</span>}
</div>
)}
</div>
</div>
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }}
title={t('reservations.confirm.deleteTitle')}
message={t(deleteBodyKey(res.id, budgetItems), { name: res.title })}
/>
</Modal>
)
}