mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
247433fb2a
* fix(journey): authorize reads of the journey share link GET /api/journeys/:id/share-link now requires journey access (canAccessJourney), matching the create/delete share-link routes and the get_journey_share_link MCP tool. Returns no link when the caller lacks access to the journey. * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/ Splitwise-style cost tracker: multiple payers per expense, equal split across chosen members, settle-up with persisted history + undo, 12 fixed categories, per-expense currency with live FX conversion to a user-set display currency (Settings -> Display), and locale-correct money formatting. Adds a desktop and a dedicated mobile layout. A migration backfills existing budget items (single payer, split members, currency). Closes #551 (per-expense currency). Also switches the app font to self-hosted Poppins (Geist for secondary subtext), replacing the Google Fonts CDN dependency. * fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge - Dark mode used a warm oklch palette that read brownish; switch to the neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a subtle backdrop-blur glass on cards. - Costs now uses the full available page width on desktop instead of a 1280px cap. - Render the expense count next to the Expenses title as a badge. - Adapt budget/journey unit tests to the new payer-based settlement model and the Costs rename (category default 'other', Costs tab/CostsPanel). * fix(costs): drop the entry-count badge, always show row edit/delete actions Removes the count badge next to the Expenses title and makes the per-row edit/delete actions permanently visible (no longer hover-only) on desktop too. * feat(costs): currency-native money formatting, custom select/date, rename addon to Costs - Format every amount in its own currency convention (symbol position, grouping and decimal separators) regardless of app language, via a currency->locale map (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the app locale, so EUR showed the symbol in front under an English UI. - Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the add/edit expense modal instead of the native <select>/<input type=date>. - Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only; id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs. * feat(auth): configurable session duration via SESSION_DURATION Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling how long a session stays valid before re-login. It drives both the trek_session JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid values warn at startup and fall back to the default (24h — unchanged). The MFA challenge token and MCP OAuth tokens keep their own TTL. Implements the request from discussion #946. Documented in the env-var wiki page, .env.example and docker-compose.yml.
383 lines
16 KiB
TypeScript
383 lines
16 KiB
TypeScript
import React from 'react'
|
||
import ReactDOM from 'react-dom'
|
||
import { useState, useRef, useEffect } from 'react'
|
||
import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react'
|
||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||
import { useTranslation } from '../../i18n'
|
||
import { useToast } from '../shared/Toast'
|
||
import { reservationsApi } from '../../api/client'
|
||
import { useTripStore } from '../../store/tripStore'
|
||
|
||
interface BookingImportModalProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
tripId: number
|
||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||
}
|
||
|
||
const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']
|
||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||
const MAX_FILES = 5
|
||
|
||
const TYPE_ICONS: Record<string, React.FC<{ size: number; color?: string }>> = {
|
||
flight: Plane,
|
||
train: Train,
|
||
hotel: Hotel,
|
||
restaurant: UtensilsCrossed,
|
||
car: Car,
|
||
cruise: Anchor,
|
||
event: Calendar,
|
||
}
|
||
|
||
function typeColor(type: string): string {
|
||
const map: Record<string, string> = {
|
||
flight: '#3b82f6',
|
||
train: '#10b981',
|
||
hotel: '#8b5cf6',
|
||
restaurant: '#f59e0b',
|
||
car: '#6b7280',
|
||
cruise: '#06b6d4',
|
||
event: '#ec4899',
|
||
}
|
||
return map[type] ?? 'var(--text-faint)'
|
||
}
|
||
|
||
function formatDateTime(iso: unknown): string {
|
||
if (!iso) return ''
|
||
const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso)
|
||
const date = str.slice(0, 10)
|
||
const time = str.length > 10 ? str.slice(11, 16) : ''
|
||
return [date, time].filter(Boolean).join(' ')
|
||
}
|
||
|
||
export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }: BookingImportModalProps) {
|
||
const { t } = useTranslation()
|
||
const toast = useToast()
|
||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||
|
||
type Phase = 'upload' | 'preview' | 'confirming'
|
||
const [phase, setPhase] = useState<Phase>('upload')
|
||
const [files, setFiles] = useState<File[]>([])
|
||
const [isDragOver, setIsDragOver] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
|
||
const [warnings, setWarnings] = useState<string[]>([])
|
||
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
|
||
|
||
const reset = () => {
|
||
setPhase('upload')
|
||
setFiles([])
|
||
setIsDragOver(false)
|
||
setLoading(false)
|
||
setError('')
|
||
setPreviewItems([])
|
||
setWarnings([])
|
||
setExcluded(new Set())
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (isOpen) reset()
|
||
// reset is stable — intentional
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isOpen])
|
||
|
||
const handleClose = () => { reset(); onClose() }
|
||
|
||
const validateFile = (f: File): string | null => {
|
||
const ext = ('.' + f.name.toLowerCase().split('.').pop()) as string
|
||
if (!ACCEPTED_EXTS.includes(ext)) return t('reservations.import.unsupportedFormat')
|
||
if (f.size > MAX_FILE_BYTES) return t('reservations.import.fileTooLarge', { name: f.name })
|
||
return null
|
||
}
|
||
|
||
const selectFiles = (incoming: File[]) => {
|
||
const valid: File[] = []
|
||
let firstErr: string | null = null
|
||
for (const f of incoming.slice(0, MAX_FILES)) {
|
||
const err = validateFile(f)
|
||
if (err) { firstErr = firstErr ?? err; continue }
|
||
valid.push(f)
|
||
}
|
||
if (valid.length === 0) { setError(firstErr ?? ''); return }
|
||
setFiles(valid)
|
||
setError(firstErr ?? '')
|
||
}
|
||
|
||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const list = e.target.files ? Array.from(e.target.files) : []
|
||
e.target.value = ''
|
||
if (list.length) selectFiles(list)
|
||
}
|
||
|
||
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true) }
|
||
const handleDragLeave = (e: React.DragEvent) => { if (e.target === e.currentTarget) setIsDragOver(false) }
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
setIsDragOver(false)
|
||
const list = Array.from(e.dataTransfer.files)
|
||
if (list.length) selectFiles(list)
|
||
}
|
||
|
||
const handleParse = async () => {
|
||
if (files.length === 0 || loading) return
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const result = await reservationsApi.importBookingPreview(tripId, files)
|
||
setPreviewItems(result.items ?? [])
|
||
setWarnings(result.warnings ?? [])
|
||
setExcluded(new Set())
|
||
setPhase('preview')
|
||
} catch (err: any) {
|
||
const msg = err?.response?.data?.error ?? t('reservations.import.error')
|
||
setError(msg)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleConfirm = async () => {
|
||
const toImport = previewItems.filter((_, i) => !excluded.has(i))
|
||
if (toImport.length === 0) return
|
||
setPhase('confirming')
|
||
setError('')
|
||
try {
|
||
const result = await reservationsApi.importBookingConfirm(tripId, toImport)
|
||
const created = result.created ?? []
|
||
await loadTrip(tripId)
|
||
|
||
if (created.length > 0) {
|
||
pushUndo?.(t('undo.importBooking'), async () => {
|
||
try {
|
||
const { reservationsApi: rApi } = await import('../../api/client')
|
||
await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {})))
|
||
} catch {}
|
||
await loadTrip(tripId)
|
||
})
|
||
toast.success(t('reservations.import.success', { count: created.length }))
|
||
} else {
|
||
toast.warning(t('reservations.import.previewEmpty'))
|
||
}
|
||
|
||
handleClose()
|
||
} catch (err: any) {
|
||
setError(err?.response?.data?.error ?? t('reservations.import.error'))
|
||
setPhase('preview')
|
||
}
|
||
}
|
||
|
||
const toggleExclude = (idx: number) => {
|
||
setExcluded(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(idx)) next.delete(idx); else next.add(idx)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length
|
||
|
||
if (!isOpen) return null
|
||
|
||
return ReactDOM.createPortal(
|
||
<div
|
||
className="bg-[rgba(0,0,0,0.4)]"
|
||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||
onClick={e => {
|
||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||
mouseDownTarget.current = null
|
||
}}
|
||
>
|
||
<div
|
||
onClick={e => e.stopPropagation()}
|
||
className="bg-surface-card"
|
||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||
>
|
||
{/* Header */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||
{phase === 'preview' && (
|
||
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||
<ArrowLeft size={16} />
|
||
</button>
|
||
)}
|
||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||
{t('reservations.import.title')}
|
||
</div>
|
||
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||
{/* Upload phase */}
|
||
{phase === 'upload' && (
|
||
<>
|
||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||
{t('reservations.import.acceptedFormats')}
|
||
</div>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept={ACCEPTED_EXTS.join(',')}
|
||
multiple
|
||
style={{ display: 'none' }}
|
||
onChange={handleInputChange}
|
||
/>
|
||
|
||
<div
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onDragOver={handleDragOver}
|
||
onDragEnter={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
|
||
style={{
|
||
width: '100%', minHeight: 100, borderRadius: 12,
|
||
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
|
||
marginBottom: 12, padding: 16, boxSizing: 'border-box',
|
||
transition: 'border-color 0.15s, background 0.15s',
|
||
}}
|
||
>
|
||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||
{isDragOver ? (
|
||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||
) : files.length > 0 ? (
|
||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
||
) : (
|
||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Preview phase */}
|
||
{(phase === 'preview' || phase === 'confirming') && (
|
||
<>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
|
||
{t('reservations.import.previewHeading', { count: previewItems.length })}
|
||
</div>
|
||
|
||
{previewItems.length === 0 && (
|
||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||
{t('reservations.import.previewEmpty')}
|
||
</div>
|
||
)}
|
||
|
||
{previewItems.map((item, idx) => {
|
||
const Icon = TYPE_ICONS[item.type] ?? Calendar
|
||
const isExcluded = excluded.has(idx)
|
||
const fromEp = item.endpoints?.find(e => e.role === 'from')
|
||
const toEp = item.endpoints?.find(e => e.role === 'to')
|
||
|
||
return (
|
||
<div
|
||
key={`${item.source.fileName}-${idx}`}
|
||
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
|
||
style={{
|
||
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
|
||
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
|
||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||
<Icon size={15} color={typeColor(item.type)} />
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{item.title}
|
||
</div>
|
||
{fromEp && toEp && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
|
||
{fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name}
|
||
</div>
|
||
)}
|
||
{item.reservation_time && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||
{formatDateTime(item.reservation_time)}
|
||
{item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`}
|
||
</div>
|
||
)}
|
||
{item._accommodation?.check_in && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||
{formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)}
|
||
</div>
|
||
)}
|
||
{item.confirmation_number && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
|
||
{item.confirmation_number}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => toggleExclude(idx)}
|
||
className="bg-transparent text-content-faint"
|
||
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
|
||
title={t('reservations.import.removeItem')}
|
||
>
|
||
{isExcluded ? '+' : <X size={12} />}
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</>
|
||
)}
|
||
|
||
{/* Warnings */}
|
||
{warnings.length > 0 && (
|
||
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
|
||
{warnings.join('\n')}
|
||
</div>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
||
<button
|
||
onClick={handleClose}
|
||
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
|
||
>
|
||
{t('common.cancel')}
|
||
</button>
|
||
|
||
{phase === 'upload' && (
|
||
<button
|
||
onClick={handleParse}
|
||
disabled={files.length === 0 || loading}
|
||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||
>
|
||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||
</button>
|
||
)}
|
||
|
||
{(phase === 'preview' || phase === 'confirming') && (
|
||
<button
|
||
onClick={handleConfirm}
|
||
disabled={activeCount === 0 || phase === 'confirming'}
|
||
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||
>
|
||
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)
|
||
}
|