mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #706 from mauriceboe/dev-maurice
feat(bookings): show transport routes on map (#384, #587)
This commit is contained in:
+2
-1
@@ -16,7 +16,8 @@ client/public/icons/*.png
|
|||||||
*.sqlite-wal
|
*.sqlite-wal
|
||||||
|
|
||||||
# User data
|
# User data
|
||||||
server/data/
|
server/data/*
|
||||||
|
!server/data/airports.json
|
||||||
server/uploads/
|
server/uploads/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
@@ -371,6 +371,11 @@ export const mapsApi = {
|
|||||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const airportsApi = {
|
||||||
|
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||||
|
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const budgetApi = {
|
export const budgetApi = {
|
||||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||||
|
|||||||
@@ -416,8 +416,8 @@ describe('BudgetPanel', () => {
|
|||||||
render(<BudgetPanel tripId={1} />);
|
render(<BudgetPanel tripId={1} />);
|
||||||
await screen.findByText('Flight');
|
await screen.findByText('Flight');
|
||||||
await screen.findByText('Hotel');
|
await screen.findByText('Hotel');
|
||||||
// Grand total card shows 300.00
|
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
|
||||||
expect(screen.getByText('300.00')).toBeInTheDocument();
|
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,69 @@ import DOM from 'react-dom'
|
|||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
|
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
function useIsDark(): boolean {
|
||||||
|
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||||
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
return () => mo.disconnect()
|
||||||
|
}, [])
|
||||||
|
return dark
|
||||||
|
}
|
||||||
|
|
||||||
|
function widgetTheme(dark: boolean) {
|
||||||
|
if (dark) return {
|
||||||
|
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
|
||||||
|
border: 'rgba(255,255,255,0.07)',
|
||||||
|
text: '#ffffff',
|
||||||
|
sub: 'rgba(255,255,255,0.6)',
|
||||||
|
faint: 'rgba(255,255,255,0.4)',
|
||||||
|
track: 'rgba(255,255,255,0.04)',
|
||||||
|
divider: 'rgba(255,255,255,0.07)',
|
||||||
|
iconBg: 'rgba(255,255,255,0.08)',
|
||||||
|
iconBorder: 'rgba(255,255,255,0.12)',
|
||||||
|
iconColor: 'rgba(255,255,255,0.9)',
|
||||||
|
centerBg: '#17171d',
|
||||||
|
flowBg: 'rgba(255,255,255,0.05)',
|
||||||
|
flowBorder: 'rgba(255,255,255,0.07)',
|
||||||
|
flowHoverBg: 'rgba(255,255,255,0.08)',
|
||||||
|
flowHoverBorder: 'rgba(255,255,255,0.12)',
|
||||||
|
rowHover: 'rgba(255,255,255,0.03)',
|
||||||
|
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||||
|
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
|
||||||
|
border: 'rgba(15,23,42,0.08)',
|
||||||
|
text: '#111827',
|
||||||
|
sub: 'rgba(17,24,39,0.6)',
|
||||||
|
faint: 'rgba(17,24,39,0.4)',
|
||||||
|
track: 'rgba(15,23,42,0.05)',
|
||||||
|
divider: 'rgba(15,23,42,0.08)',
|
||||||
|
iconBg: 'rgba(15,23,42,0.05)',
|
||||||
|
iconBorder: 'rgba(15,23,42,0.1)',
|
||||||
|
iconColor: 'rgba(17,24,39,0.75)',
|
||||||
|
centerBg: '#ffffff',
|
||||||
|
flowBg: 'rgba(15,23,42,0.03)',
|
||||||
|
flowBorder: 'rgba(15,23,42,0.08)',
|
||||||
|
flowHoverBg: 'rgba(15,23,42,0.06)',
|
||||||
|
flowHoverBorder: 'rgba(15,23,42,0.14)',
|
||||||
|
rowHover: 'rgba(15,23,42,0.04)',
|
||||||
|
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
|
||||||
|
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexLighten(hex: string, amount: number): string {
|
||||||
|
const m = hex.replace('#', '').match(/.{2}/g)
|
||||||
|
if (!m || m.length !== 3) return hex
|
||||||
|
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
|
||||||
|
const [r, g, b] = m.map(x => parseInt(x, 16))
|
||||||
|
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||||
|
}
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { budgetApi } from '../../api/client'
|
import { budgetApi } from '../../api/client'
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
@@ -361,9 +423,47 @@ interface PerPersonInlineProps {
|
|||||||
locale: string
|
locale: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
const SPLIT_COLORS = [
|
||||||
const [data, setData] = useState(null)
|
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
|
||||||
const fmt = (v) => fmtNum(v, locale, currency)
|
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
|
||||||
|
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
|
||||||
|
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
|
||||||
|
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
|
||||||
|
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function splitColorFor(userId: number, order: number) {
|
||||||
|
return SPLIT_COLORS[order % SPLIT_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForUserId(userId: number) {
|
||||||
|
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
|
||||||
|
const color = colorForUserId(userId)
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||||
|
padding: 2, background: color.gradient,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%', borderRadius: '50%',
|
||||||
|
background: innerBg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
|
||||||
|
}}>
|
||||||
|
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
|
||||||
|
const [data, setData] = useState<any[] | null>(null)
|
||||||
|
const fmt = (v: number) => fmtNum(v, locale, currency)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||||
@@ -371,25 +471,38 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInl
|
|||||||
|
|
||||||
if (!data || data.length === 0) return null
|
if (!data || data.length === 0) return null
|
||||||
|
|
||||||
|
const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<>
|
||||||
{data.map(person => (
|
{grandTotal > 0 && (
|
||||||
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
|
||||||
<div style={{
|
{people.map(p => (
|
||||||
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
|
<div key={p.user_id} style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
|
height: '100%', borderRadius: 999,
|
||||||
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
|
flex: Math.max(p.total_assigned || 0, 0.01),
|
||||||
}}>
|
background: p.color.gradient,
|
||||||
{person.avatar_url
|
}} />
|
||||||
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
))}
|
||||||
: person.username?.[0]?.toUpperCase()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
|
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{people.map(p => {
|
||||||
|
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
|
||||||
|
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +559,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
const isDark = useIsDark()
|
||||||
|
const theme = useMemo(() => widgetTheme(isDark), [isDark])
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||||
@@ -589,20 +704,69 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||||
|
const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{
|
||||||
<Calculator size={20} color="var(--text-primary)" />
|
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
|
padding: '14px 16px 14px 22px',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
|
{t('budget.title')}
|
||||||
|
</h2>
|
||||||
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 150 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={currency}
|
||||||
|
onChange={setCurrency}
|
||||||
|
disabled={!canEdit}
|
||||||
|
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||||
|
<input
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={e => setNewCategoryName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
||||||
|
placeholder={t('budget.categoryName')}
|
||||||
|
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||||
|
title={t('budget.addCategory')}
|
||||||
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||||
|
opacity: newCategoryName.trim() ? 1 : 0.4,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}>
|
||||||
|
<Plus size={14} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
||||||
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
>
|
||||||
|
<Download size={14} strokeWidth={2.5} /> CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
<Download size={13} /> CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{categoryNames.map((cat, ci) => {
|
{categoryNames.map((cat, ci) => {
|
||||||
const items = grouped.get(cat) || []
|
const items = grouped.get(cat) || []
|
||||||
@@ -811,61 +975,57 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<CustomSelect
|
|
||||||
value={currency}
|
|
||||||
onChange={setCurrency}
|
|
||||||
disabled={!canEdit}
|
|
||||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
|
||||||
searchable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
|
||||||
<input
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
|
|
||||||
placeholder={t('budget.categoryName')}
|
|
||||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
|
||||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
background: theme.bg,
|
||||||
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
|
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||||
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: theme.shadow,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{
|
||||||
<Wallet size={18} color="rgba(255,255,255,0.8)" />
|
width: 40, height: 40, borderRadius: 12,
|
||||||
|
background: theme.iconBg,
|
||||||
|
border: `1px solid ${theme.iconBorder}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: theme.iconColor, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Wallet size={20} strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
|
||||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
{(() => {
|
||||||
|
const decimals = currencyDecimals(currency)
|
||||||
|
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
|
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||||
|
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
|
||||||
|
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
|
||||||
|
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
|
||||||
|
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>{currency}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
|
||||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settlement dropdown inside the total card */}
|
{/* Settlement dropdown inside the total card */}
|
||||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
|
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||||
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||||
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||||
}}>
|
}}>
|
||||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
{t('budget.settlement')}
|
{t('budget.settlement')}
|
||||||
@@ -890,53 +1050,60 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{settlementOpen && (
|
{settlementOpen && (
|
||||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{settlement.flows.map((flow, i) => (
|
{settlement.flows.map((flow, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
padding: '8px 10px', borderRadius: 10,
|
padding: '12px 14px', borderRadius: 14,
|
||||||
background: 'rgba(255,255,255,0.06)',
|
background: theme.flowBg,
|
||||||
}}>
|
border: `1px solid ${theme.flowBorder}`,
|
||||||
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
|
transition: 'all 0.2s',
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
}}
|
||||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
|
||||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
|
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
|
||||||
|
>
|
||||||
|
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
|
||||||
{fmt(flow.amount, currency)}
|
{fmt(flow.amount, currency)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||||
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
|
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
|
||||||
{t('budget.netBalances')}
|
{t('budget.netBalances')}
|
||||||
</div>
|
</div>
|
||||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
|
||||||
<div style={{
|
const positive = b.balance > 0
|
||||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
const Trend = positive ? TrendingUp : TrendingDown
|
||||||
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
return (
|
||||||
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
|
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
|
||||||
}}>
|
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
|
||||||
{b.avatar_url
|
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
{b.username}
|
||||||
: b.username?.[0]?.toUpperCase()
|
</span>
|
||||||
}
|
<span style={{
|
||||||
</div>
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
padding: '4px 10px', borderRadius: 8,
|
||||||
{b.username}
|
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
</span>
|
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
|
||||||
<span style={{
|
color: positive ? '#10b981' : '#ef4444',
|
||||||
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
}}>
|
||||||
color: b.balance > 0 ? '#4ade80' : '#f87171',
|
<Trend size={11} strokeWidth={3} />
|
||||||
}}>
|
{positive ? '+' : ''}{fmt(b.balance, currency)}
|
||||||
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -945,36 +1112,115 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pieSegments.length > 0 && (
|
{pieSegments.length > 0 && (() => {
|
||||||
<div style={{
|
const decimals = currencyDecimals(currency)
|
||||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
const total = pieSegments.reduce((s, x) => s + x.value, 0)
|
||||||
border: '1px solid var(--border-primary)',
|
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
|
||||||
marginBottom: 16,
|
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
|
||||||
}}>
|
const R = 80
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
const CIRC = 2 * Math.PI * R
|
||||||
|
let dashOffset = 0
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: theme.bg,
|
||||||
|
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
boxShadow: theme.shadow,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 11,
|
||||||
|
background: theme.iconBg,
|
||||||
|
border: `1px solid ${theme.iconBorder}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: theme.iconColor, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<PieChartIcon size={18} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
|
||||||
|
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
|
||||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<defs>
|
||||||
{pieSegments.map((seg, i) => {
|
{pieSegments.map((seg, i) => {
|
||||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
const c2 = hexLighten(seg.color, 0.2)
|
||||||
return (
|
return (
|
||||||
<div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
|
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<stop offset="0%" stopColor={seg.color} />
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
<stop offset="100%" stopColor={c2} />
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
|
</linearGradient>
|
||||||
</div>
|
)
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
|
})}
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
|
</defs>
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
|
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
|
||||||
</div>
|
{pieSegments.map((seg, i) => {
|
||||||
|
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
|
||||||
|
const circle = (
|
||||||
|
<circle key={i}
|
||||||
|
cx={100} cy={100} r={R}
|
||||||
|
fill="none" strokeLinecap="round" strokeWidth={22}
|
||||||
|
stroke={`url(#cat-grad-${i})`}
|
||||||
|
strokeDasharray={`${segLen} ${CIRC}`}
|
||||||
|
strokeDashoffset={-dashOffset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
dashOffset += segLen
|
||||||
|
return circle
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
|
||||||
|
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||||
|
<span>{totalInt}</span>
|
||||||
|
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
|
||||||
})}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{pieSegments.map((seg, i) => {
|
||||||
|
const pct = total > 0 ? (seg.value / total) * 100 : 0
|
||||||
|
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
|
||||||
|
const c2 = hexLighten(seg.color, 0.2)
|
||||||
|
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
|
||||||
|
return (
|
||||||
|
<div key={seg.name} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '10px 8px', borderRadius: 12,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
|
||||||
|
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
|
||||||
|
boxShadow: `0 0 12px ${seg.color}80`,
|
||||||
|
}} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '4px 9px', borderRadius: 7,
|
||||||
|
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
background: `${seg.color}26`,
|
||||||
|
border: `1px solid ${seg.color}40`,
|
||||||
|
color: chipColor,
|
||||||
|
}}>{pctLabel}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -779,25 +779,81 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
|
||||||
<div>
|
<div style={{
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
|
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
padding: '14px 16px 14px 22px',
|
||||||
{showTrash
|
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||||
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
|
|
||||||
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={toggleTrash} style={{
|
|
||||||
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
|
||||||
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
|
|
||||||
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
|
|
||||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}>
|
}}>
|
||||||
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
</button>
|
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!showTrash && (
|
||||||
|
<>
|
||||||
|
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||||
|
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: t('files.filterAll') },
|
||||||
|
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
|
||||||
|
{ id: 'pdf', label: t('files.filterPdf') },
|
||||||
|
{ id: 'image', label: t('files.filterImages') },
|
||||||
|
{ id: 'doc', label: t('files.filterDocs') },
|
||||||
|
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||||
|
].map(tab => {
|
||||||
|
const active = filterType === tab.id
|
||||||
|
const TabIcon = 'icon' in tab ? tab.icon : null
|
||||||
|
const count = tab.id === 'all' ? files.length
|
||||||
|
: tab.id === 'starred' ? files.filter(f => f.starred).length
|
||||||
|
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
|
||||||
|
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
|
||||||
|
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
|
||||||
|
: tab.id === 'collab' ? files.filter(f => f.note_id).length
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
|
<button key={tab.id} onClick={() => setFilterType(tab.id)}
|
||||||
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||||
|
background: active ? 'var(--bg-card)' : 'transparent',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
fontWeight: active ? 500 : 400,
|
||||||
|
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
|
||||||
|
{'label' in tab && tab.label}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600,
|
||||||
|
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||||
|
color: 'var(--text-faint)',
|
||||||
|
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||||
|
}}>{count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={toggleTrash} style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-text)',
|
||||||
|
flexShrink: 0, marginLeft: 'auto',
|
||||||
|
opacity: showTrash ? 1 : 0.88,
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTrash ? (
|
{showTrash ? (
|
||||||
@@ -835,7 +891,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
{can('file_upload', trip) && <div
|
{can('file_upload', trip) && <div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
style={{
|
style={{
|
||||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||||
@@ -860,7 +916,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{/* Filter tabs */}
|
{/* Filter tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
{[
|
{[
|
||||||
{ id: 'all', label: t('files.filterAll') },
|
{ id: 'all', label: t('files.filterAll') },
|
||||||
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||||
@@ -883,7 +939,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File list */}
|
{/* File list */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||||
{filteredFiles.length === 0 ? (
|
{filteredFiles.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ vi.mock('react-leaflet', () => ({
|
|||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
panBy: vi.fn(),
|
panBy: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useMapEvents: () => ({}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('react-leaflet-cluster', () => ({
|
vi.mock('react-leaflet-cluster', () => ({
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.css'
|
|||||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||||
|
import ReservationOverlay from './ReservationOverlay'
|
||||||
|
import type { Reservation } from '../../types'
|
||||||
|
|
||||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
@@ -385,7 +387,16 @@ export const MapView = memo(function MapView({
|
|||||||
rightWidth = 0,
|
rightWidth = 0,
|
||||||
hasInspector = false,
|
hasInspector = false,
|
||||||
hasDayDetail = false,
|
hasDayDetail = false,
|
||||||
}) {
|
reservations = [] as Reservation[],
|
||||||
|
showReservationStats = false,
|
||||||
|
visibleConnectionIds = [] as number[],
|
||||||
|
onReservationClick,
|
||||||
|
}: any) {
|
||||||
|
const visibleReservations = useMemo(() => {
|
||||||
|
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||||
|
const set = new Set(visibleConnectionIds)
|
||||||
|
return reservations.filter((r: Reservation) => set.has(r.id))
|
||||||
|
}, [reservations, visibleConnectionIds])
|
||||||
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
|
||||||
const paddingOpts = useMemo(() => {
|
const paddingOpts = useMemo(() => {
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
@@ -569,6 +580,13 @@ export const MapView = memo(function MapView({
|
|||||||
)
|
)
|
||||||
} catch { return null }
|
} catch { return null }
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<ReservationOverlay
|
||||||
|
reservations={visibleReservations}
|
||||||
|
showConnections
|
||||||
|
showStats={showReservationStats}
|
||||||
|
onEndpointClick={onReservationClick}
|
||||||
|
/>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import { Plane, Train, Ship, Car } from 'lucide-react'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
|
const ENDPOINT_PANE = 'reservation-endpoints'
|
||||||
|
const AIRPORT_BADGE_HALF_PX = 16
|
||||||
|
const BADGE_GAP_PX = 5
|
||||||
|
|
||||||
|
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
|
||||||
|
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
|
||||||
|
|
||||||
|
const TRANSPORT_COLOR = '#3b82f6'
|
||||||
|
|
||||||
|
const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geodesic: boolean }> = {
|
||||||
|
flight: { color: TRANSPORT_COLOR, icon: Plane, geodesic: true },
|
||||||
|
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
|
||||||
|
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
|
||||||
|
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
function useEndpointPane() {
|
||||||
|
const map = useMap()
|
||||||
|
useMemo(() => {
|
||||||
|
if (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return
|
||||||
|
if (!map.getPane(ENDPOINT_PANE)) {
|
||||||
|
const pane = map.createPane(ENDPOINT_PANE)
|
||||||
|
pane.style.zIndex = '650'
|
||||||
|
pane.style.pointerEvents = 'auto'
|
||||||
|
}
|
||||||
|
}, [map])
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||||
|
const { icon: IconCmp, color } = TYPE_META[type]
|
||||||
|
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
||||||
|
const labelHtml = label ? `<span>${label}</span>` : ''
|
||||||
|
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'trek-endpoint-marker',
|
||||||
|
html: `<div style="
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;gap:4px;
|
||||||
|
padding:0 8px;border-radius:999px;
|
||||||
|
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
|
border:1.5px solid #fff;color:#fff;
|
||||||
|
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||||
|
box-sizing:border-box;height:22px;white-space:nowrap;
|
||||||
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
||||||
|
iconSize: [estWidth, 22],
|
||||||
|
iconAnchor: [estWidth / 2, 11],
|
||||||
|
popupAnchor: [0, -11],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRad(d: number) { return d * Math.PI / 180 }
|
||||||
|
function toDeg(r: number) { return r * 180 / Math.PI }
|
||||||
|
|
||||||
|
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
||||||
|
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
||||||
|
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
||||||
|
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
||||||
|
if (d === 0) return [a, b]
|
||||||
|
const pts: [number, number][] = []
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const f = i / steps
|
||||||
|
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
||||||
|
const B = Math.sin(f * d) / Math.sin(d)
|
||||||
|
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
||||||
|
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
||||||
|
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
||||||
|
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
||||||
|
const lng = Math.atan2(y, x)
|
||||||
|
pts.push([toDeg(lat), toDeg(lng)])
|
||||||
|
}
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
||||||
|
const segments: [number, number][][] = []
|
||||||
|
let cur: [number, number][] = []
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
||||||
|
if (cur.length > 1) segments.push(cur)
|
||||||
|
cur = []
|
||||||
|
}
|
||||||
|
cur.push(points[i])
|
||||||
|
}
|
||||||
|
if (cur.length > 1) segments.push(cur)
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanName(name: string): string {
|
||||||
|
return name.replace(/\s*\([^)]*\)/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineKm(a: [number, number], b: [number, number]): number {
|
||||||
|
const R = 6371
|
||||||
|
const dLat = toRad(b[0] - a[0])
|
||||||
|
const dLng = toRad(b[1] - a[1])
|
||||||
|
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInTz(isoLocal: string, tz: string): number {
|
||||||
|
const [datePart, timePart] = isoLocal.split('T')
|
||||||
|
const [y, mo, d] = datePart.split('-').map(Number)
|
||||||
|
const [h, mi] = (timePart || '00:00').split(':').map(Number)
|
||||||
|
const guess = Date.UTC(y, mo - 1, d, h, mi)
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tz, hour12: false,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
|
||||||
|
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
|
||||||
|
return guess - (asUtc - guess)
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
|
||||||
|
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
|
||||||
|
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
|
||||||
|
if (!start || !end) return null
|
||||||
|
|
||||||
|
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
|
||||||
|
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
|
||||||
|
if (!start.includes('T') || !end.includes('T')) return null
|
||||||
|
|
||||||
|
const fromTz = from.timezone || to.timezone
|
||||||
|
const toTz = to.timezone || fromTz
|
||||||
|
|
||||||
|
let startMs: number, endMs: number
|
||||||
|
if (fromTz && toTz) {
|
||||||
|
startMs = parseInTz(start, fromTz)
|
||||||
|
endMs = parseInTz(end, toTz)
|
||||||
|
} else {
|
||||||
|
startMs = new Date(start).getTime()
|
||||||
|
endMs = new Date(end).getTime()
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
|
||||||
|
if (endMs <= startMs) endMs += 24 * 60 * 60000
|
||||||
|
const minutes = Math.round((endMs - startMs) / 60000)
|
||||||
|
if (minutes <= 0 || minutes > 48 * 60) return null
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransportItem {
|
||||||
|
res: Reservation
|
||||||
|
from: ReservationEndpoint
|
||||||
|
to: ReservationEndpoint
|
||||||
|
type: TransportType
|
||||||
|
arcs: [number, number][][]
|
||||||
|
primaryArc: [number, number][]
|
||||||
|
fallback: [number, number]
|
||||||
|
mainLabel: string | null
|
||||||
|
subLabel: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
|
||||||
|
const estWidth = Math.max(
|
||||||
|
mainLabel ? mainLabel.length * 6.5 : 0,
|
||||||
|
subLabel ? subLabel.length * 5.5 : 0,
|
||||||
|
) + 22
|
||||||
|
const hasBoth = !!mainLabel && !!subLabel
|
||||||
|
const height = hasBoth ? 36 : 22
|
||||||
|
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
|
||||||
|
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
|
||||||
|
const html = `<div class="trek-stats-inner" style="
|
||||||
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
width:100%;height:100%;
|
||||||
|
padding:0 11px;border-radius:999px;
|
||||||
|
background:rgba(17,24,39,0.92);color:#fff;
|
||||||
|
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
|
border:1px solid ${color}aa;
|
||||||
|
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
|
||||||
|
white-space:nowrap;box-sizing:border-box;
|
||||||
|
transform-origin:center;
|
||||||
|
will-change:transform;
|
||||||
|
">${main}${sub}</div>`
|
||||||
|
return { html, width: estWidth, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsLabel({ item }: { item: TransportItem }) {
|
||||||
|
const map = useMap()
|
||||||
|
const markerRef = useRef<L.Marker | null>(null)
|
||||||
|
const innerRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const arc = item.primaryArc
|
||||||
|
const color = TYPE_META[item.type].color
|
||||||
|
|
||||||
|
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
|
||||||
|
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
|
||||||
|
|
||||||
|
const compute = () => {
|
||||||
|
if (arc.length < 2) return null
|
||||||
|
const size = map.getSize()
|
||||||
|
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
|
||||||
|
const cum: number[] = [0]
|
||||||
|
let total = 0
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
total += pts[i].distanceTo(pts[i - 1])
|
||||||
|
cum.push(total)
|
||||||
|
}
|
||||||
|
if (total <= 0) return null
|
||||||
|
|
||||||
|
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||||
|
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||||
|
|
||||||
|
const isIn = (p: L.Point) => {
|
||||||
|
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
|
||||||
|
if (p.distanceTo(fromPx) < buffer) return false
|
||||||
|
if (p.distanceTo(toPx) < buffer) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstIdx = -1
|
||||||
|
let lastIdx = -1
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
if (isIn(pts[i])) {
|
||||||
|
if (firstIdx < 0) firstIdx = i
|
||||||
|
lastIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstIdx < 0) {
|
||||||
|
const target = total / 2
|
||||||
|
let sIdx = 0
|
||||||
|
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
|
||||||
|
const span = cum[sIdx + 1] - cum[sIdx]
|
||||||
|
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
|
||||||
|
const pA = pts[sIdx]
|
||||||
|
const pB = pts[sIdx + 1]
|
||||||
|
const mx = pA.x + (pB.x - pA.x) * tm
|
||||||
|
const my = pA.y + (pB.y - pA.y) * tm
|
||||||
|
const latlng = map.containerPointToLatLng([mx, my])
|
||||||
|
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
|
||||||
|
if (angle > 90) angle -= 180
|
||||||
|
if (angle < -90) angle += 180
|
||||||
|
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bisectFraction = (a: L.Point, b: L.Point) => {
|
||||||
|
let lo = 0, hi = 1
|
||||||
|
for (let k = 0; k < 10; k++) {
|
||||||
|
const mid = (lo + hi) / 2
|
||||||
|
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
|
||||||
|
if (isIn(mp)) hi = mid
|
||||||
|
else lo = mid
|
||||||
|
}
|
||||||
|
return (lo + hi) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let lowCum = cum[firstIdx]
|
||||||
|
if (firstIdx > 0) {
|
||||||
|
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
|
||||||
|
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
|
||||||
|
}
|
||||||
|
let highCum = cum[lastIdx]
|
||||||
|
if (lastIdx < pts.length - 1) {
|
||||||
|
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
|
||||||
|
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLen = (lowCum + highCum) / 2
|
||||||
|
|
||||||
|
let segIdx = 0
|
||||||
|
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
|
||||||
|
const segSpan = cum[segIdx + 1] - cum[segIdx]
|
||||||
|
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
|
||||||
|
const pA = pts[segIdx]
|
||||||
|
const pB = pts[segIdx + 1]
|
||||||
|
const px = pA.x + (pB.x - pA.x) * t
|
||||||
|
const py = pA.y + (pB.y - pA.y) * t
|
||||||
|
const latlng = map.containerPointToLatLng([px, py])
|
||||||
|
|
||||||
|
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
|
||||||
|
if (angle > 90) angle -= 180
|
||||||
|
if (angle < -90) angle += 180
|
||||||
|
|
||||||
|
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const pose = compute()
|
||||||
|
const marker = markerRef.current
|
||||||
|
if (!marker) return
|
||||||
|
const el = marker.getElement() as HTMLElement | null
|
||||||
|
if (!pose) {
|
||||||
|
if (el) el.style.display = 'none'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (el) el.style.display = ''
|
||||||
|
marker.setLatLng(pose.point as L.LatLngTuple)
|
||||||
|
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
|
||||||
|
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: 'trek-endpoint-stats',
|
||||||
|
html,
|
||||||
|
iconSize: [width, height],
|
||||||
|
iconAnchor: [width / 2, height / 2],
|
||||||
|
})
|
||||||
|
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
|
||||||
|
marker.addTo(map)
|
||||||
|
markerRef.current = marker
|
||||||
|
innerRef.current = null
|
||||||
|
apply()
|
||||||
|
return () => {
|
||||||
|
marker.remove()
|
||||||
|
markerRef.current = null
|
||||||
|
innerRef.current = null
|
||||||
|
}
|
||||||
|
}, [map, html, width, height])
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
move: apply,
|
||||||
|
zoom: apply,
|
||||||
|
viewreset: apply,
|
||||||
|
resize: apply,
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reservations: Reservation[]
|
||||||
|
showConnections: boolean
|
||||||
|
showStats: boolean
|
||||||
|
onEndpointClick?: (reservationId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
|
||||||
|
useEndpointPane()
|
||||||
|
const map = useMap()
|
||||||
|
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||||
|
useMapEvents({
|
||||||
|
zoomend: () => setZoom(map.getZoom()),
|
||||||
|
})
|
||||||
|
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||||
|
|
||||||
|
const items = useMemo<TransportItem[]>(() => {
|
||||||
|
const out: TransportItem[] = []
|
||||||
|
for (const r of reservations) {
|
||||||
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||||
|
const eps = r.endpoints || []
|
||||||
|
const from = eps.find(e => e.role === 'from')
|
||||||
|
const to = eps.find(e => e.role === 'to')
|
||||||
|
if (!from || !to) continue
|
||||||
|
const type = r.type as TransportType
|
||||||
|
const isGeo = TYPE_META[type].geodesic
|
||||||
|
const arcs = isGeo
|
||||||
|
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||||
|
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||||
|
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||||
|
const primaryArc = arcs[primaryIdx] ?? []
|
||||||
|
const fallback: [number, number] = primaryArc.length > 0
|
||||||
|
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
|
||||||
|
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
||||||
|
|
||||||
|
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||||
|
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||||
|
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||||
|
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||||
|
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||||
|
|
||||||
|
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [reservations])
|
||||||
|
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
return items.filter(item => {
|
||||||
|
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||||
|
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||||
|
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||||
|
return fromPx.distanceTo(toPx) >= minPx
|
||||||
|
})
|
||||||
|
}, [items, zoom, map])
|
||||||
|
|
||||||
|
const labelVisibleIds = useMemo(() => {
|
||||||
|
const set = new Set<number>()
|
||||||
|
for (const item of visibleItems) {
|
||||||
|
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||||
|
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||||
|
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
|
||||||
|
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}, [visibleItems, zoom, map])
|
||||||
|
|
||||||
|
if (!showConnections) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
|
||||||
|
<Polyline
|
||||||
|
key={`line-${item.res.id}-${segIdx}`}
|
||||||
|
positions={seg}
|
||||||
|
pathOptions={{
|
||||||
|
color: TYPE_META[item.type].color,
|
||||||
|
weight: 2.5,
|
||||||
|
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
|
||||||
|
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)))}
|
||||||
|
|
||||||
|
{visibleItems.flatMap(item => [
|
||||||
|
<Marker
|
||||||
|
key={`from-${item.res.id}`}
|
||||||
|
position={[item.from.lat, item.from.lng]}
|
||||||
|
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
|
||||||
|
pane={ENDPOINT_PANE}
|
||||||
|
zIndexOffset={1000}
|
||||||
|
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
|
||||||
|
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>,
|
||||||
|
<Marker
|
||||||
|
key={`to-${item.res.id}`}
|
||||||
|
position={[item.to.lat, item.to.lng]}
|
||||||
|
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
|
||||||
|
pane={ENDPOINT_PANE}
|
||||||
|
zIndexOffset={1000}
|
||||||
|
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
|
||||||
|
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>,
|
||||||
|
])}
|
||||||
|
|
||||||
|
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
|
||||||
|
<StatsLabel key={`stats-${item.res.id}`} item={item} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -729,9 +729,11 @@ function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
|
|||||||
interface PackingListPanelProps {
|
interface PackingListPanelProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
items: PackingItem[]
|
items: PackingItem[]
|
||||||
|
openImportSignal?: number
|
||||||
|
inlineHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
|
export default function PackingListPanel({ tripId, items, openImportSignal = 0, inlineHeader = true }: PackingListPanelProps) {
|
||||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
@@ -896,6 +898,14 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
const [saveTemplateName, setSaveTemplateName] = useState('')
|
const [saveTemplateName, setSaveTemplateName] = useState('')
|
||||||
const [showImportModal, setShowImportModal] = useState(false)
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
const [importText, setImportText] = useState('')
|
const [importText, setImportText] = useState('')
|
||||||
|
const lastHandledImportSignal = useRef(openImportSignal)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
|
||||||
|
setShowImportModal(true)
|
||||||
|
}
|
||||||
|
lastHandledImportSignal.current = openImportSignal
|
||||||
|
}, [openImportSignal])
|
||||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -999,15 +1009,34 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
|
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
|
||||||
<div>
|
{inlineHeader ? (
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
|
<div>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
|
||||||
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
{items.length > 0 && (
|
||||||
</p>
|
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||||
</div>
|
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.length > 0 ? (
|
||||||
|
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||||
|
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
||||||
|
</p>
|
||||||
|
) : <span />
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{inlineHeader && canEdit && (
|
||||||
|
<button onClick={() => setShowImportModal(true)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
|
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canEdit && abgehakt > 0 && (
|
{canEdit && abgehakt > 0 && (
|
||||||
<button onClick={handleClearChecked} style={{
|
<button onClick={handleClearChecked} style={{
|
||||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||||
@@ -1017,15 +1046,6 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
|
||||||
<button onClick={() => setShowImportModal(true)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
|
||||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canEdit && availableTemplates.length > 0 && (
|
{canEdit && availableTemplates.length > 0 && (
|
||||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||||
@@ -1151,7 +1171,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
|
|
||||||
{/* ── Filter-Tabs ── */}
|
{/* ── Filter-Tabs ── */}
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
|
||||||
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
|
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
|
||||||
<button key={id} onClick={() => setFilter(id)} style={{
|
<button key={id} onClick={() => setFilter(id)} style={{
|
||||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
|
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
|
||||||
@@ -1165,7 +1185,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
|
|
||||||
{/* ── Liste + Bags Sidebar ── */}
|
{/* ── Liste + Bags Sidebar ── */}
|
||||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
|
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Plane, X } from 'lucide-react'
|
||||||
|
import { airportsApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
export interface Airport {
|
||||||
|
iata: string
|
||||||
|
icao: string | null
|
||||||
|
name: string
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
tz: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: Airport | null
|
||||||
|
onChange: (airport: Airport | null) => void
|
||||||
|
placeholder?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(a: Airport) {
|
||||||
|
return `${a.city || a.name} (${a.iata})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AirportSelect({ value, onChange, placeholder, style }: Props) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const countryName = useMemo(() => {
|
||||||
|
try { return new Intl.DisplayNames([locale || 'en'], { type: 'region' }) } catch { return null }
|
||||||
|
}, [locale])
|
||||||
|
const displayCountry = (code: string) => {
|
||||||
|
if (!code) return ''
|
||||||
|
try { return countryName?.of(code) || code } catch { return code }
|
||||||
|
}
|
||||||
|
const [query, setQuery] = useState(value ? formatLabel(value) : '')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [results, setResults] = useState<Airport[]>([])
|
||||||
|
const [highlight, setHighlight] = useState(-1)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQuery(value ? formatLabel(value) : '')
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
if (open) document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (trimmed.length < 2 || (value && trimmed === formatLabel(value))) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await airportsApi.search(trimmed, controller.signal)
|
||||||
|
setResults(Array.isArray(data) ? data : [])
|
||||||
|
setHighlight(-1)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name !== 'AbortError' && err?.name !== 'CanceledError') {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, 220)
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||||
|
}, [query, value])
|
||||||
|
|
||||||
|
const pick = (a: Airport) => {
|
||||||
|
onChange(a)
|
||||||
|
setQuery(formatLabel(a))
|
||||||
|
setOpen(false)
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
onChange(null)
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!open || results.length === 0) return
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
|
||||||
|
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
|
||||||
|
else if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
|
||||||
|
<Plane size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
placeholder={placeholder ?? t('airport.searchPlaceholder')}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (loading || results.length > 0) && (
|
||||||
|
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
|
||||||
|
{loading && results.length === 0 && (
|
||||||
|
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
|
||||||
|
)}
|
||||||
|
{results.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={a.iata}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(a)}
|
||||||
|
onMouseEnter={() => setHighlight(i)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||||
|
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace', fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', minWidth: 32 }}>{a.iata}</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.city || a.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.name}{a.country ? ` · ${displayCountry(a.country)}` : ''}</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||||
|
|
||||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
@@ -170,6 +170,10 @@ interface DayPlanSidebarProps {
|
|||||||
onEditPlace: (place: Place) => void
|
onEditPlace: (place: Place) => void
|
||||||
onDeletePlace: (placeId: number) => void
|
onDeletePlace: (placeId: number) => void
|
||||||
reservations?: Reservation[]
|
reservations?: Reservation[]
|
||||||
|
visibleConnectionIds?: number[]
|
||||||
|
onToggleConnection?: (reservationId: number) => void
|
||||||
|
externalTransportDetail?: Reservation | null
|
||||||
|
onExternalTransportDetailHandled?: () => void
|
||||||
onAddReservation: () => void
|
onAddReservation: () => void
|
||||||
onNavigateToFiles?: () => void
|
onNavigateToFiles?: () => void
|
||||||
onAddPlace?: () => void
|
onAddPlace?: () => void
|
||||||
@@ -189,6 +193,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||||
reservations = [],
|
reservations = [],
|
||||||
|
visibleConnectionIds = [],
|
||||||
|
onToggleConnection,
|
||||||
|
externalTransportDetail,
|
||||||
|
onExternalTransportDetailHandled,
|
||||||
onAddReservation,
|
onAddReservation,
|
||||||
onAddPlace,
|
onAddPlace,
|
||||||
onAddPlaceToDay,
|
onAddPlaceToDay,
|
||||||
@@ -234,6 +242,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const [hoveredId, setHoveredId] = useState(null)
|
const [hoveredId, setHoveredId] = useState(null)
|
||||||
const [transportDetail, setTransportDetail] = useState(null)
|
const [transportDetail, setTransportDetail] = useState(null)
|
||||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalTransportDetail) {
|
||||||
|
setTransportDetail(externalTransportDetail)
|
||||||
|
onExternalTransportDetailHandled?.()
|
||||||
|
}
|
||||||
|
}, [externalTransportDetail, onExternalTransportDetailHandled])
|
||||||
const [timeConfirm, setTimeConfirm] = useState<{
|
const [timeConfirm, setTimeConfirm] = useState<{
|
||||||
dayId: number; fromId: number; time: string;
|
dayId: number; fromId: number; time: string;
|
||||||
// For drag & drop reorder
|
// For drag & drop reorder
|
||||||
@@ -1570,6 +1585,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
||||||
|
const active = visibleConnectionIds.includes(res.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
|
||||||
|
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, appearance: 'none',
|
||||||
|
width: 26, height: 26, borderRadius: 6,
|
||||||
|
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
background: active ? color : 'transparent',
|
||||||
|
color: active ? '#fff' : 'var(--text-faint)',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
<RouteIcon size={13} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { MapPin, X } from 'lucide-react'
|
||||||
|
import { mapsApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
export interface LocationPoint {
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
address?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: LocationPoint | null
|
||||||
|
onChange: (loc: LocationPoint | null) => void
|
||||||
|
placeholder?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const [query, setQuery] = useState(value?.name || '')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [results, setResults] = useState<any[]>([])
|
||||||
|
const [highlight, setHighlight] = useState(-1)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQuery(value?.name || '')
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
if (open) document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (trimmed.length < 3 || (value && trimmed === value.name)) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await mapsApi.search(trimmed, locale)
|
||||||
|
setResults(data.places || [])
|
||||||
|
setHighlight(-1)
|
||||||
|
} catch {
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, 320)
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||||
|
}, [query, value, locale])
|
||||||
|
|
||||||
|
const pick = (r: any) => {
|
||||||
|
const lat = Number(r.lat)
|
||||||
|
const lng = Number(r.lng)
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||||
|
const loc: LocationPoint = { name: r.name || r.address || 'Location', lat, lng, address: r.address || null }
|
||||||
|
onChange(loc)
|
||||||
|
setQuery(loc.name)
|
||||||
|
setOpen(false)
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
onChange(null)
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!open || results.length === 0) return
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
|
||||||
|
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
|
||||||
|
else if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
|
||||||
|
<MapPin size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
placeholder={placeholder ?? t('reservations.searchLocation')}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (loading || results.length > 0) && (
|
||||||
|
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
|
||||||
|
{loading && results.length === 0 && (
|
||||||
|
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
|
||||||
|
)}
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={`${r.osm_id || r.google_place_id || i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pick(r)}
|
||||||
|
onMouseEnter={() => setHighlight(i)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%',
|
||||||
|
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||||
|
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin size={12} style={{ color: 'var(--text-faint)', marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
|
||||||
|
{r.address && r.name !== r.address && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ describe('ReservationModal', () => {
|
|||||||
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Rental Car/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
|
||||||
@@ -575,16 +575,14 @@ describe('ReservationModal', () => {
|
|||||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and airports', async () => {
|
it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and flight number', async () => {
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447');
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447 CDG → JFK');
|
||||||
await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France');
|
await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France');
|
||||||
await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447');
|
await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447');
|
||||||
await userEvent.type(screen.getByPlaceholderText('FRA'), 'CDG');
|
|
||||||
await userEvent.type(screen.getByPlaceholderText('NRT'), 'JFK');
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
|
||||||
@@ -595,8 +593,6 @@ describe('ReservationModal', () => {
|
|||||||
metadata: expect.objectContaining({
|
metadata: expect.objectContaining({
|
||||||
airline: 'Air France',
|
airline: 'Air France',
|
||||||
flight_number: 'AF 447',
|
flight_number: 'AF 447',
|
||||||
departure_airport: 'CDG',
|
|
||||||
arrival_airport: 'JFK',
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -640,7 +636,7 @@ describe('ReservationModal', () => {
|
|||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
|
it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /Rental Car/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||||
// Car type still shows date fields (not hotel which hides them)
|
// Car type still shows date fields (not hotel which hides them)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
|
expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -11,7 +11,58 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
import AirportSelect, { type Airport } from './AirportSelect'
|
||||||
|
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||||
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
|
const TRANSPORT_TYPES = ['flight', 'train', 'cruise', 'car'] as const
|
||||||
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
|
const isTransport = (t: string): t is TransportType => (TRANSPORT_TYPES as readonly string[]).includes(t)
|
||||||
|
|
||||||
|
interface EndpointPick {
|
||||||
|
airport?: Airport
|
||||||
|
location?: LocationPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||||
|
return {
|
||||||
|
role, sequence,
|
||||||
|
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
||||||
|
code: a.iata,
|
||||||
|
lat: a.lat, lng: a.lng,
|
||||||
|
timezone: a.tz,
|
||||||
|
local_date: date,
|
||||||
|
local_time: time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||||
|
return {
|
||||||
|
role, sequence,
|
||||||
|
name: l.name,
|
||||||
|
code: null,
|
||||||
|
lat: l.lat, lng: l.lng,
|
||||||
|
timezone: null,
|
||||||
|
local_date: date,
|
||||||
|
local_time: time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||||
|
if (!e || !e.code) return null
|
||||||
|
return {
|
||||||
|
iata: e.code, icao: null,
|
||||||
|
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
|
||||||
|
country: '',
|
||||||
|
lat: e.lat, lng: e.lng,
|
||||||
|
tz: e.timezone || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
|
||||||
|
if (!e) return null
|
||||||
|
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||||
|
}
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||||
@@ -98,6 +149,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||||
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||||
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
|
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||||
|
|
||||||
const assignmentOptions = useMemo(
|
const assignmentOptions = useMemo(
|
||||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||||
@@ -148,6 +201,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
price: meta.price || '',
|
price: meta.price || '',
|
||||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const eps = reservation.endpoints || []
|
||||||
|
const from = eps.find(e => e.role === 'from')
|
||||||
|
const to = eps.find(e => e.role === 'to')
|
||||||
|
if (reservation.type === 'flight') {
|
||||||
|
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||||
|
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||||
|
} else if (isTransport(reservation.type)) {
|
||||||
|
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||||
|
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||||
|
} else {
|
||||||
|
setFromPick({})
|
||||||
|
setToPick({})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
@@ -160,6 +227,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||||
})
|
})
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
|
setFromPick({})
|
||||||
|
setToPick({})
|
||||||
}
|
}
|
||||||
}, [reservation, isOpen, selectedDayId])
|
}, [reservation, isOpen, selectedDayId])
|
||||||
|
|
||||||
@@ -202,10 +271,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
if (form.type === 'flight') {
|
if (form.type === 'flight') {
|
||||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||||
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
|
if (fromPick.airport) {
|
||||||
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
|
metadata.departure_airport = fromPick.airport.iata
|
||||||
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
|
metadata.departure_timezone = fromPick.airport.tz
|
||||||
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
|
}
|
||||||
|
if (toPick.airport) {
|
||||||
|
metadata.arrival_airport = toPick.airport.iata
|
||||||
|
metadata.arrival_timezone = toPick.airport.tz
|
||||||
|
}
|
||||||
} else if (form.type === 'hotel') {
|
} else if (form.type === 'hotel') {
|
||||||
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
||||||
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
|
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
|
||||||
@@ -224,6 +297,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
if (form.price) metadata.price = form.price
|
if (form.price) metadata.price = form.price
|
||||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||||
}
|
}
|
||||||
|
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||||
|
if (isTransport(form.type)) {
|
||||||
|
const startDate = (form.reservation_time || '').split('T')[0] || null
|
||||||
|
const startTime = (form.reservation_time || '').split('T')[1]?.slice(0, 5) || null
|
||||||
|
const endDate = form.end_date || null
|
||||||
|
const endTime = form.reservation_end_time || null
|
||||||
|
if (form.type === 'flight') {
|
||||||
|
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, startTime))
|
||||||
|
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, endTime))
|
||||||
|
} else {
|
||||||
|
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, startTime))
|
||||||
|
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, endTime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveData: Record<string, any> = {
|
const saveData: Record<string, any> = {
|
||||||
title: form.title, type: form.type, status: form.status,
|
title: form.title, type: form.type, status: form.status,
|
||||||
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
|
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
|
||||||
@@ -233,6 +321,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
assignment_id: form.assignment_id || null,
|
assignment_id: form.assignment_id || null,
|
||||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||||
|
endpoints: isTransport(form.type) ? endpoints : [],
|
||||||
|
needs_review: false,
|
||||||
}
|
}
|
||||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||||
if (isBudgetEnabled) {
|
if (isBudgetEnabled) {
|
||||||
@@ -394,11 +484,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{form.type === 'flight' && (
|
{form.type === 'flight' && fromPick.airport && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||||
<input type="text" value={form.meta_departure_timezone} onChange={e => set('meta_departure_timezone', e.target.value)}
|
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||||
placeholder="e.g. CET, UTC+1" style={inputStyle} />
|
{fromPick.airport.tz}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -414,51 +505,75 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||||
</div>
|
</div>
|
||||||
{form.type === 'flight' && (
|
{form.type === 'flight' && toPick.airport && (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||||
<input type="text" value={form.meta_arrival_timezone} onChange={e => set('meta_arrival_timezone', e.target.value)}
|
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||||
placeholder="e.g. JST, UTC+9" style={inputStyle} />
|
{toPick.airport.tz}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isEndBeforeStart && (
|
{isEndBeforeStart && (
|
||||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.status}
|
|
||||||
onChange={value => set('status', value)}
|
|
||||||
options={[
|
|
||||||
{ value: 'pending', label: t('reservations.pending') },
|
|
||||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
|
||||||
]}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location + Booking Code */}
|
{/* Location (own row for non-transport, non-hotel types) */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
{!isTransport(form.type) && form.type !== 'hotel' && (
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
||||||
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
||||||
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
|
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Booking Code + Status */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
||||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||||
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.status}
|
||||||
|
onChange={value => set('status', value)}
|
||||||
|
options={[
|
||||||
|
{ value: 'pending', label: t('reservations.pending') },
|
||||||
|
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||||
|
]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type-specific fields */}
|
{/* From / To endpoints for transport bookings */}
|
||||||
|
{isTransport(form.type) && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.from')}</label>
|
||||||
|
{form.type === 'flight' ? (
|
||||||
|
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
||||||
|
) : (
|
||||||
|
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.to')}</label>
|
||||||
|
{form.type === 'flight' ? (
|
||||||
|
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
||||||
|
) : (
|
||||||
|
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{form.type === 'flight' && (
|
{form.type === 'flight' && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
|
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
|
||||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||||
@@ -469,16 +584,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||||
placeholder="LH 123" style={inputStyle} />
|
placeholder="LH 123" style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
|
|
||||||
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
|
|
||||||
placeholder="FRA" style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
|
|
||||||
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
|
|
||||||
placeholder="NRT" style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -528,8 +633,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Check-in/out times + Status */}
|
{/* Check-in / check-in-until / check-out */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
||||||
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
||||||
@@ -542,18 +647,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
|
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
|
||||||
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.status}
|
|
||||||
onChange={value => set('status', value)}
|
|
||||||
options={[
|
|
||||||
{ value: 'pending', label: t('reservations.pending') },
|
|
||||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
|
||||||
]}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import {
|
import {
|
||||||
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
||||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||||
@@ -142,6 +142,17 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<TypeIcon size={12} style={{ color: typeInfo.color }} />
|
<TypeIcon size={12} style={{ color: typeInfo.color }} />
|
||||||
{t(typeInfo.labelKey)}
|
{t(typeInfo.labelKey)}
|
||||||
</span>
|
</span>
|
||||||
|
{r.needs_review ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
fontSize: 11, fontWeight: 600, color: '#b45309',
|
||||||
|
padding: '3px 8px', borderRadius: 6,
|
||||||
|
background: 'rgba(245,158,11,0.12)',
|
||||||
|
}} title={t('reservations.needsReviewHint')}>
|
||||||
|
<AlertCircle size={11} />
|
||||||
|
{t('reservations.needsReview')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -218,15 +229,35 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const eps = r.endpoints || []
|
||||||
|
const from = eps.find(e => e.role === 'from')
|
||||||
|
const to = eps.find(e => e.role === 'to')
|
||||||
|
if (!from || !to) return null
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
padding: '8px 12px', borderRadius: 10,
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
fontSize: 12.5, color: 'var(--text-primary)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
|
||||||
|
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||||
|
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Type-specific metadata */}
|
{/* Type-specific metadata */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
if (!meta || Object.keys(meta).length === 0) return null
|
if (!meta || Object.keys(meta).length === 0) return null
|
||||||
|
const hasEndpoints = (r.endpoints || []).some(e => e.role === 'from') && (r.endpoints || []).some(e => e.role === 'to')
|
||||||
const cells: { label: string; value: string }[] = []
|
const cells: { label: string; value: string }[] = []
|
||||||
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||||
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||||
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
if (!hasEndpoints && meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||||
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||||
@@ -351,10 +382,20 @@ interface SectionProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
accent: 'green' | string
|
accent: 'green' | string
|
||||||
|
storageKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
|
function Section({ title, count, children, defaultOpen = true, accent, storageKey }: SectionProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(() => {
|
||||||
|
if (!storageKey || typeof window === 'undefined') return defaultOpen
|
||||||
|
const stored = window.localStorage.getItem(storageKey)
|
||||||
|
if (stored === null) return defaultOpen
|
||||||
|
return stored === '1'
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageKey || typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(storageKey, open ? '1' : '0')
|
||||||
|
}, [open, storageKey])
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 28 }}>
|
<div style={{ marginBottom: 28 }}>
|
||||||
<button onClick={() => setOpen(o => !o)} style={{
|
<button onClick={() => setOpen(o => !o)} style={{
|
||||||
@@ -537,12 +578,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{allPending.length > 0 && (
|
{allPending.length > 0 && (
|
||||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
|
||||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{allConfirmed.length > 0 && (
|
{allConfirmed.length > 0 && (
|
||||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}>
|
||||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen, Tent, Compass, Plane, Crown, Infinity as InfinityIcon } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
|
||||||
@@ -7,8 +7,229 @@ interface Props {
|
|||||||
appVersion: string
|
appVersion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SupporterTierId = 'no_return_ticket' | 'lost_luggage_vip' | 'business_class_dreamer' | 'budget_traveller' | 'hostel_bunkmate'
|
||||||
|
|
||||||
|
interface SupporterTier {
|
||||||
|
id: SupporterTierId
|
||||||
|
labelKey: string
|
||||||
|
price: string
|
||||||
|
gradient: string
|
||||||
|
glow: string
|
||||||
|
icon: typeof Tent
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTER_TIERS: SupporterTier[] = [
|
||||||
|
{ id: 'no_return_ticket', labelKey: 'settings.about.supporter.tier.noReturnTicket', price: '∞', gradient: 'linear-gradient(135deg, #fbbf24, #ec4899 55%, #6366f1)', glow: 'rgba(236,72,153,0.45)', icon: InfinityIcon },
|
||||||
|
{ id: 'lost_luggage_vip', labelKey: 'settings.about.supporter.tier.lostLuggageVip', price: '$30', gradient: 'linear-gradient(135deg, #a855f7, #ec4899)', glow: 'rgba(168,85,247,0.35)', icon: Crown },
|
||||||
|
{ id: 'business_class_dreamer', labelKey: 'settings.about.supporter.tier.businessClassDreamer', price: '$15', gradient: 'linear-gradient(135deg, #6366f1, #0ea5e9)', glow: 'rgba(99,102,241,0.35)', icon: Plane },
|
||||||
|
{ id: 'budget_traveller', labelKey: 'settings.about.supporter.tier.budgetTraveller', price: '$10', gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: 'rgba(20,184,166,0.3)', icon: Compass },
|
||||||
|
{ id: 'hostel_bunkmate', labelKey: 'settings.about.supporter.tier.hostelBunkmate', price: '$5', gradient: 'linear-gradient(135deg, #64748b, #94a3b8)', glow: 'rgba(100,116,139,0.25)', icon: Tent },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Supporter {
|
||||||
|
username: string
|
||||||
|
tier: SupporterTierId
|
||||||
|
since: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTERS: Supporter[] = [
|
||||||
|
{ username: 'Someone', tier: 'hostel_bunkmate', since: '2026-04' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function SupporterSection({ t, locale }: { t: (key: string, vars?: Record<string, string | number>) => string; locale: string }) {
|
||||||
|
if (SUPPORTERS.length === 0) return null
|
||||||
|
|
||||||
|
const formatSince = (yearMonth: string): string => {
|
||||||
|
const [y, m] = yearMonth.split('-').map(Number)
|
||||||
|
if (!y || !m) return yearMonth
|
||||||
|
try {
|
||||||
|
return new Date(y, m - 1, 1).toLocaleDateString(locale, { year: 'numeric', month: 'long' })
|
||||||
|
} catch { return yearMonth }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="supporter-section">
|
||||||
|
<style>{`
|
||||||
|
.supporter-section { margin-top: 20px; }
|
||||||
|
.supporter-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 22px 22px 18px;
|
||||||
|
background: linear-gradient(180deg, rgba(99,102,241,0.06) 0%, rgba(236,72,153,0.04) 100%);
|
||||||
|
border: 1px solid rgba(99,102,241,0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.supporter-glow {
|
||||||
|
position: absolute; inset: -60px; z-index: 0; pointer-events: none;
|
||||||
|
background: radial-gradient(500px 240px at 15% -10%, rgba(99,102,241,0.18), transparent 60%), radial-gradient(400px 200px at 90% 110%, rgba(236,72,153,0.12), transparent 60%);
|
||||||
|
animation: supporterGlow 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.supporter-header {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.supporter-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px; border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #ec4899, #fbbf24);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: supporterShimmer 4s ease-in-out infinite;
|
||||||
|
color: #fff; font-weight: 700; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
box-shadow: 0 4px 16px rgba(236,72,153,0.25);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.supporter-title {
|
||||||
|
margin: 0; font-size: 16px; font-weight: 700;
|
||||||
|
color: var(--text-primary); letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.supporter-subtitle {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
margin: 0 0 16px; font-size: 12.5px;
|
||||||
|
color: var(--text-secondary); line-height: 1.55;
|
||||||
|
}
|
||||||
|
.supporter-tiers {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.supporter-tier {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
padding: 10px 12px; border-radius: 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
.supporter-tier-icon {
|
||||||
|
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.supporter-tier-body { flex: 1; min-width: 0; }
|
||||||
|
.supporter-tier-head {
|
||||||
|
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.supporter-tier-label {
|
||||||
|
font-size: 13.5px; font-weight: 700; color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.supporter-tier-price {
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-faint);
|
||||||
|
padding: 1px 7px; border-radius: 6px; background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
.supporter-tier-chips {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.supporter-tier-empty {
|
||||||
|
font-size: 11.5px; font-style: italic; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.supporter-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
padding: 4px 10px; border-radius: 999px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.supporter-chip-name {
|
||||||
|
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.supporter-chip-since {
|
||||||
|
font-size: 10.5px; font-weight: 500; color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.supporter-chip-since-short { display: none; }
|
||||||
|
@keyframes supporterShimmer {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
@keyframes supporterGlow {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.75; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.supporter-card { border-radius: 16px; padding: 16px 14px 14px; }
|
||||||
|
.supporter-glow { inset: -40px; }
|
||||||
|
.supporter-header { gap: 8px; }
|
||||||
|
.supporter-badge { font-size: 10px; padding: 3px 9px; letter-spacing: 0.03em; }
|
||||||
|
.supporter-title { font-size: 15px; flex-basis: 100%; }
|
||||||
|
.supporter-subtitle { font-size: 12px; margin-bottom: 14px; }
|
||||||
|
.supporter-tier { padding: 10px; gap: 10px; border-radius: 12px; }
|
||||||
|
.supporter-tier-icon { width: 34px; height: 34px; border-radius: 10px; }
|
||||||
|
.supporter-tier-label { font-size: 13px; }
|
||||||
|
.supporter-tier-chips { gap: 5px; margin-top: 7px; }
|
||||||
|
.supporter-chip { padding: 3px 9px; }
|
||||||
|
.supporter-chip-since { font-size: 10px; }
|
||||||
|
.supporter-chip-since-full { display: none; }
|
||||||
|
.supporter-chip-since-short { display: inline; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="supporter-card">
|
||||||
|
<div className="supporter-glow" />
|
||||||
|
|
||||||
|
<div className="supporter-header">
|
||||||
|
<span className="supporter-badge">{t('settings.about.supporters.badge')}</span>
|
||||||
|
<h3 className="supporter-title">{t('settings.about.supporters.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="supporter-subtitle">{t('settings.about.supporters.subtitle')}</p>
|
||||||
|
|
||||||
|
<div className="supporter-tiers">
|
||||||
|
{SUPPORTER_TIERS.map(tier => {
|
||||||
|
const members = SUPPORTERS.filter(s => s.tier === tier.id)
|
||||||
|
const empty = members.length === 0
|
||||||
|
const TierIcon = tier.icon
|
||||||
|
return (
|
||||||
|
<div key={tier.id} className="supporter-tier" style={{ opacity: empty ? 0.55 : 1 }}>
|
||||||
|
<div className="supporter-tier-icon" style={{ background: tier.gradient, boxShadow: `0 6px 18px ${tier.glow}` }}>
|
||||||
|
<TierIcon size={18} strokeWidth={2.2} />
|
||||||
|
</div>
|
||||||
|
<div className="supporter-tier-body">
|
||||||
|
<div className="supporter-tier-head">
|
||||||
|
<span className="supporter-tier-label">{t(tier.labelKey)}</span>
|
||||||
|
<span className="supporter-tier-price">{tier.price}</span>
|
||||||
|
</div>
|
||||||
|
<div className="supporter-tier-chips">
|
||||||
|
{empty && (
|
||||||
|
<span className="supporter-tier-empty">
|
||||||
|
{t('settings.about.supporters.tierEmpty')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{members.map(m => {
|
||||||
|
const chipContent = (
|
||||||
|
<>
|
||||||
|
<span className="supporter-chip-name">{m.username}</span>
|
||||||
|
<span className="supporter-chip-since supporter-chip-since-full">
|
||||||
|
· {t('settings.about.supporters.since', { date: formatSince(m.since) })}
|
||||||
|
</span>
|
||||||
|
<span className="supporter-chip-since supporter-chip-since-short">
|
||||||
|
· {formatSince(m.since)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return m.link ? (
|
||||||
|
<a key={m.username} href={m.link} target="_blank" rel="noopener noreferrer" className="supporter-chip"
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.boxShadow = `0 2px 8px ${tier.glow}` }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
>
|
||||||
|
{chipContent}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div key={m.username} className="supporter-chip">{chipContent}</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('settings.about')} icon={Info}>
|
<Section title={t('settings.about')} icon={Info}>
|
||||||
@@ -141,6 +362,8 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
|||||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SupporterSection t={t} locale={locale} />
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Booking route labels */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[
|
||||||
|
{ value: true, label: t('settings.on') || 'On' },
|
||||||
|
{ value: false, label: t('settings.off') || 'Off' },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
onClick={async () => {
|
||||||
|
try { await updateSetting('map_booking_labels', opt.value) }
|
||||||
|
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||||
|
border: (settings.map_booking_labels !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||||
|
background: (settings.map_booking_labels !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.bookingLabelsHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ describe('TodoListPanel', () => {
|
|||||||
expect(screen.getByText('Buy tickets')).toBeInTheDocument();
|
expect(screen.getByText('Buy tickets')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-TODO-002: shows Add new task button', () => {
|
it('FE-COMP-TODO-002: raising addItemSignal opens the new task form', async () => {
|
||||||
render(<TodoListPanel tripId={1} items={[]} />);
|
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||||
expect(screen.getByText('Add new task...')).toBeInTheDocument();
|
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||||
|
await screen.findByText('Create task');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => {
|
it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => {
|
||||||
@@ -119,11 +120,9 @@ describe('TodoListPanel', () => {
|
|||||||
expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument();
|
expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-TODO-011: clicking Add new task opens detail form', async () => {
|
it('FE-COMP-TODO-011: raising addItemSignal opens detail form with Create task button', async () => {
|
||||||
const user = userEvent.setup();
|
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||||
render(<TodoListPanel tripId={1} items={[]} />);
|
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||||
await user.click(screen.getByText('Add new task...'));
|
|
||||||
// The detail pane shows "Create task" button
|
|
||||||
await screen.findByText('Create task');
|
await screen.findByText('Create task');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -398,15 +397,12 @@ describe('TodoListPanel', () => {
|
|||||||
return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) });
|
return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
render(<TodoListPanel tripId={1} items={[]} />);
|
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||||
// Open the new task pane
|
// Raising the signal opens the new task pane (simulates the toolbar button click)
|
||||||
await user.click(screen.getByText('Add new task...'));
|
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||||
// Wait for "Create task" button to appear
|
|
||||||
await screen.findByText('Create task');
|
await screen.findByText('Create task');
|
||||||
// Type a task name in the autoFocus input (Task name placeholder)
|
|
||||||
const nameInput = screen.getByPlaceholderText('Task name');
|
const nameInput = screen.getByPlaceholderText('Task name');
|
||||||
await user.type(nameInput, 'Brand New Task');
|
await user.type(nameInput, 'Brand New Task');
|
||||||
// Click the Create task button
|
|
||||||
await user.click(screen.getByText('Create task'));
|
await user.click(screen.getByText('Create task'));
|
||||||
await waitFor(() => expect(postCalled).toBe(true));
|
await waitFor(() => expect(postCalled).toBe(true));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
@@ -37,7 +38,7 @@ type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
|
|||||||
|
|
||||||
interface Member { id: number; username: string; avatar: string | null }
|
interface Member { id: number; username: string; avatar: string | null }
|
||||||
|
|
||||||
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
|
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
|
||||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
||||||
const canEdit = useCanDo('packing_edit')
|
const canEdit = useCanDo('packing_edit')
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -55,6 +56,15 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
const [filter, setFilter] = useState<FilterType>('all')
|
const [filter, setFilter] = useState<FilterType>('all')
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false)
|
const [isAddingNew, setIsAddingNew] = useState(false)
|
||||||
|
const lastHandledAddSignal = useRef(addItemSignal)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
|
||||||
|
setSelectedId(null)
|
||||||
|
setIsAddingNew(true)
|
||||||
|
}
|
||||||
|
lastHandledAddSignal.current = addItemSignal
|
||||||
|
}, [addItemSignal])
|
||||||
const [sortByPrio, setSortByPrio] = useState(false)
|
const [sortByPrio, setSortByPrio] = useState(false)
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
@@ -160,12 +170,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
{/* ── Left Sidebar ── */}
|
{/* ── Left Sidebar ── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
|
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
|
||||||
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
padding: isMobile ? '12px 6px' : '16px 12px 16px 0', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||||
transition: 'width 0.2s',
|
transition: 'width 0.2s',
|
||||||
}}>
|
}}>
|
||||||
{/* Progress Card */}
|
{/* Progress Card */}
|
||||||
{!isMobile && <div style={{
|
{!isMobile && <div style={{
|
||||||
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
|
margin: '0 0 12px', padding: '14px 14px 12px', borderRadius: 14,
|
||||||
background: 'var(--bg-hover)',
|
background: 'var(--bg-hover)',
|
||||||
border: '1px solid var(--border-primary)',
|
border: '1px solid var(--border-primary)',
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
|
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
|
||||||
@@ -192,9 +202,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
|
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
|
||||||
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
|
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
|
||||||
|
|
||||||
{/* Sort by priority */}
|
{/* Sort by */}
|
||||||
|
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{t('todo.sidebar.sortBy')}
|
||||||
|
</div>}
|
||||||
<button onClick={() => setSortByPrio(v => !v)}
|
<button onClick={() => setSortByPrio(v => !v)}
|
||||||
title={isMobile ? t('todo.sortByPrio') : undefined}
|
title={isMobile ? t('todo.priority') : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||||
@@ -206,7 +219,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
|
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
|
||||||
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
|
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
@@ -251,27 +264,6 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add task */}
|
|
||||||
{canEdit && (
|
|
||||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
||||||
width: '100%', padding: '9px 16px', borderRadius: 8,
|
|
||||||
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
|
|
||||||
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
|
|
||||||
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
|
|
||||||
<Plus size={14} />
|
|
||||||
{t('todo.addItem')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task list */}
|
{/* Task list */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
||||||
{filtered.length === 0 ? null : (
|
{filtered.length === 0 ? null : (
|
||||||
@@ -407,18 +399,27 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAddingNew && !selectedItem && !isMobile && (
|
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
|
||||||
<NewTaskPane
|
|
||||||
tripId={tripId}
|
|
||||||
categories={categories}
|
|
||||||
members={members}
|
|
||||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
|
||||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
|
||||||
onClose={() => setIsAddingNew(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isAddingNew && !selectedItem && isMobile && (
|
|
||||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||||
|
className="modal-backdrop"
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
|
||||||
|
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }}
|
||||||
|
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}>
|
||||||
|
<NewTaskPane
|
||||||
|
tripId={tripId}
|
||||||
|
categories={categories}
|
||||||
|
members={members}
|
||||||
|
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||||
|
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||||
|
onClose={() => setIsAddingNew(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
|
||||||
|
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||||
|
className="modal-backdrop"
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||||
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
|
||||||
@@ -431,7 +432,8 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
onClose={() => setIsAddingNew(false)}
|
onClose={() => setIsAddingNew(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -647,6 +649,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
|||||||
const [desc, setDesc] = useState('')
|
const [desc, setDesc] = useState('')
|
||||||
const [dueDate, setDueDate] = useState('')
|
const [dueDate, setDueDate] = useState('')
|
||||||
const [category, setCategory] = useState(defaultCategory || '')
|
const [category, setCategory] = useState(defaultCategory || '')
|
||||||
|
const [addingCategory, setAddingCategoryInline] = useState(false)
|
||||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
|
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
|
||||||
const [priority, setPriority] = useState(0)
|
const [priority, setPriority] = useState(0)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -657,9 +660,10 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
|||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
|
const trimmedCategory = category.trim()
|
||||||
const item = await addTodoItem(tripId, {
|
const item = await addTodoItem(tripId, {
|
||||||
name: name.trim(), description: desc || null, priority,
|
name: name.trim(), description: desc || null, priority,
|
||||||
due_date: dueDate || null, category: category || null,
|
due_date: dueDate || null, category: trimmedCategory || null,
|
||||||
assigned_user_id: assignedUserId,
|
assigned_user_id: assignedUserId,
|
||||||
} as any)
|
} as any)
|
||||||
if (item?.id) onCreated(item.id)
|
if (item?.id) onCreated(item.id)
|
||||||
@@ -696,19 +700,49 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||||
<CustomSelect
|
{addingCategory ? (
|
||||||
value={category}
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
onChange={v => setCategory(v)}
|
<input
|
||||||
options={[
|
autoFocus
|
||||||
{ value: '', label: t('todo.noCategory') },
|
value={category}
|
||||||
...categories.map(c => ({
|
onChange={e => setCategory(e.target.value)}
|
||||||
value: c, label: c,
|
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
||||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
placeholder={t('todo.newCategory')}
|
||||||
})),
|
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||||
]}
|
/>
|
||||||
placeholder={t('todo.noCategory')}
|
<button type="button" onClick={() => setAddingCategoryInline(false)}
|
||||||
size="sm"
|
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
|
||||||
/>
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={category}
|
||||||
|
onChange={v => setCategory(v)}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('todo.noCategory') },
|
||||||
|
...categories.map(c => ({
|
||||||
|
value: c, label: c,
|
||||||
|
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||||
|
})),
|
||||||
|
...(category && !categories.includes(category) ? [{
|
||||||
|
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
|
||||||
|
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
|
||||||
|
}] : []),
|
||||||
|
]}
|
||||||
|
placeholder={t('todo.noCategory')}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
|
||||||
|
title={t('todo.newCategory')}
|
||||||
|
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -313,6 +313,16 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'اقتراح ميزة',
|
'settings.about.featureRequest': 'اقتراح ميزة',
|
||||||
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
|
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
|
||||||
'settings.about.wikiHint': 'التوثيق والأدلة',
|
'settings.about.wikiHint': 'التوثيق والأدلة',
|
||||||
|
'settings.about.supporters.badge': 'الداعمون الشهريون',
|
||||||
|
'settings.about.supporters.title': 'رفاق رحلة TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'بينما تخطّط لمسارك التالي، يساعد هؤلاء الأشخاص في التخطيط لمستقبل TREK. تذهب مساهمتهم الشهرية مباشرةً إلى التطوير والساعات الفعلية المبذولة — حتى يظلّ TREK مفتوح المصدر.',
|
||||||
|
'settings.about.supporters.since': 'داعم منذ {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'كن الأول',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
|
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
|
||||||
'settings.about.madeWith': 'صُنع بـ',
|
'settings.about.madeWith': 'صُنع بـ',
|
||||||
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
|
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
|
||||||
@@ -1023,6 +1033,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'رقم الرحلة',
|
'reservations.meta.flightNumber': 'رقم الرحلة',
|
||||||
'reservations.meta.from': 'من',
|
'reservations.meta.from': 'من',
|
||||||
'reservations.meta.to': 'إلى',
|
'reservations.meta.to': 'إلى',
|
||||||
|
'reservations.needsReview': 'مراجعة',
|
||||||
|
'reservations.needsReviewHint': 'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
|
||||||
|
'reservations.searchLocation': 'ابحث عن محطة، ميناء، عنوان...',
|
||||||
|
'airport.searchPlaceholder': 'رمز المطار أو المدينة (مثل FRA)',
|
||||||
|
'map.connections': 'الاتصالات',
|
||||||
|
'map.showConnections': 'عرض مسارات الحجوزات',
|
||||||
|
'map.hideConnections': 'إخفاء مسارات الحجوزات',
|
||||||
|
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
|
||||||
|
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
|
||||||
'reservations.meta.trainNumber': 'رقم القطار',
|
'reservations.meta.trainNumber': 'رقم القطار',
|
||||||
'reservations.meta.platform': 'المنصة',
|
'reservations.meta.platform': 'المنصة',
|
||||||
'reservations.meta.seat': 'المقعد',
|
'reservations.meta.seat': 'المقعد',
|
||||||
@@ -1041,7 +1060,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'إقامة',
|
'reservations.type.hotel': 'إقامة',
|
||||||
'reservations.type.restaurant': 'مطعم',
|
'reservations.type.restaurant': 'مطعم',
|
||||||
'reservations.type.train': 'قطار',
|
'reservations.type.train': 'قطار',
|
||||||
'reservations.type.car': 'سيارة مستأجرة',
|
'reservations.type.car': 'سيارة',
|
||||||
'reservations.type.cruise': 'رحلة بحرية',
|
'reservations.type.cruise': 'رحلة بحرية',
|
||||||
'reservations.type.event': 'فعالية',
|
'reservations.type.event': 'فعالية',
|
||||||
'reservations.type.tour': 'جولة',
|
'reservations.type.tour': 'جولة',
|
||||||
@@ -1813,7 +1832,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'غير مُسنَد',
|
'todo.unassigned': 'غير مُسنَد',
|
||||||
'todo.noCategory': 'بدون فئة',
|
'todo.noCategory': 'بدون فئة',
|
||||||
'todo.hasDescription': 'له وصف',
|
'todo.hasDescription': 'له وصف',
|
||||||
'todo.addItem': 'إضافة مهمة جديدة...',
|
'todo.addItem': 'إضافة مهمة جديدة',
|
||||||
|
'todo.sidebar.sortBy': 'ترتيب حسب',
|
||||||
|
'todo.priority': 'الأولوية',
|
||||||
|
'todo.newCategoryLabel': 'جديد',
|
||||||
|
'budget.categoriesLabel': 'فئات',
|
||||||
'todo.newCategory': 'اسم الفئة',
|
'todo.newCategory': 'اسم الفئة',
|
||||||
'todo.addCategory': 'إضافة فئة',
|
'todo.addCategory': 'إضافة فئة',
|
||||||
'todo.newItem': 'مهمة جديدة',
|
'todo.newItem': 'مهمة جديدة',
|
||||||
|
|||||||
@@ -240,6 +240,16 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Solicitar recurso',
|
'settings.about.featureRequest': 'Solicitar recurso',
|
||||||
'settings.about.featureRequestHint': 'Sugira um novo recurso',
|
'settings.about.featureRequestHint': 'Sugira um novo recurso',
|
||||||
'settings.about.wikiHint': 'Documentação e guias',
|
'settings.about.wikiHint': 'Documentação e guias',
|
||||||
|
'settings.about.supporters.badge': 'Apoiadores Mensais',
|
||||||
|
'settings.about.supporters.title': 'Companheiros de viagem do TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Enquanto você planeja sua próxima rota, essas pessoas planejam junto o futuro do TREK. A contribuição mensal delas vai direto para o desenvolvimento e horas reais investidas — para o TREK continuar Open Source.',
|
||||||
|
'settings.about.supporters.since': 'apoiador desde {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Seja o primeiro',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
|
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
|
||||||
'settings.about.madeWith': 'Feito com',
|
'settings.about.madeWith': 'Feito com',
|
||||||
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
|
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
|
||||||
@@ -992,6 +1002,15 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'Nº do voo',
|
'reservations.meta.flightNumber': 'Nº do voo',
|
||||||
'reservations.meta.from': 'De',
|
'reservations.meta.from': 'De',
|
||||||
'reservations.meta.to': 'Para',
|
'reservations.meta.to': 'Para',
|
||||||
|
'reservations.needsReview': 'Verificar',
|
||||||
|
'reservations.needsReviewHint': 'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
|
||||||
|
'reservations.searchLocation': 'Buscar estação, porto, endereço...',
|
||||||
|
'airport.searchPlaceholder': 'Código ou cidade do aeroporto (ex. FRA)',
|
||||||
|
'map.connections': 'Conexões',
|
||||||
|
'map.showConnections': 'Mostrar rotas de reservas',
|
||||||
|
'map.hideConnections': 'Ocultar rotas de reservas',
|
||||||
|
'settings.bookingLabels': 'Rótulos das rotas de reservas',
|
||||||
|
'settings.bookingLabelsHint': 'Mostra nomes de estações / aeroportos no mapa. Desativado, apenas o ícone aparece.',
|
||||||
'reservations.meta.trainNumber': 'Nº do trem',
|
'reservations.meta.trainNumber': 'Nº do trem',
|
||||||
'reservations.meta.platform': 'Plataforma',
|
'reservations.meta.platform': 'Plataforma',
|
||||||
'reservations.meta.seat': 'Assento',
|
'reservations.meta.seat': 'Assento',
|
||||||
@@ -1010,7 +1029,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'Hospedagem',
|
'reservations.type.hotel': 'Hospedagem',
|
||||||
'reservations.type.restaurant': 'Restaurante',
|
'reservations.type.restaurant': 'Restaurante',
|
||||||
'reservations.type.train': 'Trem',
|
'reservations.type.train': 'Trem',
|
||||||
'reservations.type.car': 'Carro alugado',
|
'reservations.type.car': 'Carro',
|
||||||
'reservations.type.cruise': 'Cruzeiro',
|
'reservations.type.cruise': 'Cruzeiro',
|
||||||
'reservations.type.event': 'Evento',
|
'reservations.type.event': 'Evento',
|
||||||
'reservations.type.tour': 'Passeio',
|
'reservations.type.tour': 'Passeio',
|
||||||
@@ -1754,7 +1773,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Não atribuído',
|
'todo.unassigned': 'Não atribuído',
|
||||||
'todo.noCategory': 'Sem categoria',
|
'todo.noCategory': 'Sem categoria',
|
||||||
'todo.hasDescription': 'Com descrição',
|
'todo.hasDescription': 'Com descrição',
|
||||||
'todo.addItem': 'Adicionar nova tarefa...',
|
'todo.addItem': 'Nova tarefa',
|
||||||
|
'todo.sidebar.sortBy': 'Ordenar por',
|
||||||
|
'todo.priority': 'Prioridade',
|
||||||
|
'todo.newCategoryLabel': 'nova',
|
||||||
|
'budget.categoriesLabel': 'categorias',
|
||||||
'todo.newCategory': 'Nome da categoria',
|
'todo.newCategory': 'Nome da categoria',
|
||||||
'todo.addCategory': 'Adicionar categoria',
|
'todo.addCategory': 'Adicionar categoria',
|
||||||
'todo.newItem': 'Nova tarefa',
|
'todo.newItem': 'Nova tarefa',
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Navrhnout funkci',
|
'settings.about.featureRequest': 'Navrhnout funkci',
|
||||||
'settings.about.featureRequestHint': 'Navrhněte novou funkci',
|
'settings.about.featureRequestHint': 'Navrhněte novou funkci',
|
||||||
'settings.about.wikiHint': 'Dokumentace a návody',
|
'settings.about.wikiHint': 'Dokumentace a návody',
|
||||||
|
'settings.about.supporters.badge': 'Měsíční podporovatelé',
|
||||||
|
'settings.about.supporters.title': 'Společníci na cestě s TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Zatímco plánuješ další trasu, tihle lidé plánují společně se mnou budoucnost TREK. Jejich měsíční příspěvek jde přímo na vývoj a reálně strávené hodiny — aby TREK zůstal Open Source.',
|
||||||
|
'settings.about.supporters.since': 'podporovatel od {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Buď první',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK je samohostovaný plánovač cest, který vám pomůže organizovat výlety od prvního nápadu po poslední vzpomínku. Denní plánování, rozpočet, balicí seznamy, fotky a mnoho dalšího — vše na jednom místě, na vašem vlastním serveru.',
|
'settings.about.description': 'TREK je samohostovaný plánovač cest, který vám pomůže organizovat výlety od prvního nápadu po poslední vzpomínku. Denní plánování, rozpočet, balicí seznamy, fotky a mnoho dalšího — vše na jednom místě, na vašem vlastním serveru.',
|
||||||
'settings.about.madeWith': 'Vytvořeno s',
|
'settings.about.madeWith': 'Vytvořeno s',
|
||||||
'settings.about.madeBy': 'Mauricem a rostoucí open-source komunitou.',
|
'settings.about.madeBy': 'Mauricem a rostoucí open-source komunitou.',
|
||||||
@@ -1021,6 +1031,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'Číslo letu',
|
'reservations.meta.flightNumber': 'Číslo letu',
|
||||||
'reservations.meta.from': 'Z',
|
'reservations.meta.from': 'Z',
|
||||||
'reservations.meta.to': 'Do',
|
'reservations.meta.to': 'Do',
|
||||||
|
'reservations.needsReview': 'Zkontrolovat',
|
||||||
|
'reservations.needsReviewHint': 'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
|
||||||
|
'reservations.searchLocation': 'Hledat stanici, přístav, adresu...',
|
||||||
|
'airport.searchPlaceholder': 'Kód letiště nebo město (např. FRA)',
|
||||||
|
'map.connections': 'Spojení',
|
||||||
|
'map.showConnections': 'Zobrazit trasy rezervací',
|
||||||
|
'map.hideConnections': 'Skrýt trasy rezervací',
|
||||||
|
'settings.bookingLabels': 'Popisky tras rezervací',
|
||||||
|
'settings.bookingLabelsHint': 'Zobrazuje názvy stanic / letišť na mapě. Pokud je vypnuto, zobrazí se pouze ikona.',
|
||||||
'reservations.meta.trainNumber': 'Číslo vlaku',
|
'reservations.meta.trainNumber': 'Číslo vlaku',
|
||||||
'reservations.meta.platform': 'Nástupiště',
|
'reservations.meta.platform': 'Nástupiště',
|
||||||
'reservations.meta.seat': 'Sedadlo',
|
'reservations.meta.seat': 'Sedadlo',
|
||||||
@@ -1039,7 +1058,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'Ubytování',
|
'reservations.type.hotel': 'Ubytování',
|
||||||
'reservations.type.restaurant': 'Restaurace',
|
'reservations.type.restaurant': 'Restaurace',
|
||||||
'reservations.type.train': 'Vlak',
|
'reservations.type.train': 'Vlak',
|
||||||
'reservations.type.car': 'Pronájem auta',
|
'reservations.type.car': 'Auto',
|
||||||
'reservations.type.cruise': 'Plavba',
|
'reservations.type.cruise': 'Plavba',
|
||||||
'reservations.type.event': 'Událost',
|
'reservations.type.event': 'Událost',
|
||||||
'reservations.type.tour': 'Prohlídka',
|
'reservations.type.tour': 'Prohlídka',
|
||||||
@@ -1759,7 +1778,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Nepřiřazeno',
|
'todo.unassigned': 'Nepřiřazeno',
|
||||||
'todo.noCategory': 'Bez kategorie',
|
'todo.noCategory': 'Bez kategorie',
|
||||||
'todo.hasDescription': 'Má popis',
|
'todo.hasDescription': 'Má popis',
|
||||||
'todo.addItem': 'Přidat nový úkol...',
|
'todo.addItem': 'Přidat nový úkol',
|
||||||
|
'todo.sidebar.sortBy': 'Řadit podle',
|
||||||
|
'todo.priority': 'Priorita',
|
||||||
|
'todo.newCategoryLabel': 'nová',
|
||||||
|
'budget.categoriesLabel': 'kategorie',
|
||||||
'todo.newCategory': 'Název kategorie',
|
'todo.newCategory': 'Název kategorie',
|
||||||
'todo.addCategory': 'Přidat kategorii',
|
'todo.addCategory': 'Přidat kategorii',
|
||||||
'todo.newItem': 'Nový úkol',
|
'todo.newItem': 'Nový úkol',
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.temperature': 'Temperatureinheit',
|
'settings.temperature': 'Temperatureinheit',
|
||||||
'settings.timeFormat': 'Zeitformat',
|
'settings.timeFormat': 'Zeitformat',
|
||||||
'settings.routeCalculation': 'Routenberechnung',
|
'settings.routeCalculation': 'Routenberechnung',
|
||||||
|
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
||||||
|
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
||||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||||
'settings.notifications': 'Benachrichtigungen',
|
'settings.notifications': 'Benachrichtigungen',
|
||||||
'settings.notifyTripInvite': 'Trip-Einladungen',
|
'settings.notifyTripInvite': 'Trip-Einladungen',
|
||||||
@@ -311,6 +313,16 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Feature vorschlagen',
|
'settings.about.featureRequest': 'Feature vorschlagen',
|
||||||
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
|
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
|
||||||
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
|
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
|
||||||
|
'settings.about.supporters.badge': 'Monatliche Unterstützer',
|
||||||
|
'settings.about.supporters.title': 'Reisebegleitung für TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Während du deine nächste Route planst, planen diese Leute mit, wie TREK weitergeht. Ihr monatlicher Beitrag fließt direkt in Entwicklung und echten Zeitaufwand — damit TREK Open Source bleibt.',
|
||||||
|
'settings.about.supporters.since': 'Unterstützer seit {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Sei die/der Erste',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
|
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
|
||||||
'settings.about.madeWith': 'Entwickelt mit',
|
'settings.about.madeWith': 'Entwickelt mit',
|
||||||
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
|
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
|
||||||
@@ -1023,6 +1035,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'Flugnr.',
|
'reservations.meta.flightNumber': 'Flugnr.',
|
||||||
'reservations.meta.from': 'Von',
|
'reservations.meta.from': 'Von',
|
||||||
'reservations.meta.to': 'Nach',
|
'reservations.meta.to': 'Nach',
|
||||||
|
'reservations.needsReview': 'Prüfen',
|
||||||
|
'reservations.needsReviewHint': 'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
|
||||||
|
'reservations.searchLocation': 'Bahnhof, Hafen, Adresse suchen…',
|
||||||
|
'airport.searchPlaceholder': 'Flughafencode oder Stadt (z. B. FRA)',
|
||||||
|
'map.connections': 'Verbindungen',
|
||||||
|
'map.showConnections': 'Buchungsrouten anzeigen',
|
||||||
|
'map.hideConnections': 'Buchungsrouten ausblenden',
|
||||||
'reservations.meta.trainNumber': 'Zugnr.',
|
'reservations.meta.trainNumber': 'Zugnr.',
|
||||||
'reservations.meta.platform': 'Gleis',
|
'reservations.meta.platform': 'Gleis',
|
||||||
'reservations.meta.seat': 'Sitzplatz',
|
'reservations.meta.seat': 'Sitzplatz',
|
||||||
@@ -1041,7 +1060,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'Unterkunft',
|
'reservations.type.hotel': 'Unterkunft',
|
||||||
'reservations.type.restaurant': 'Restaurant',
|
'reservations.type.restaurant': 'Restaurant',
|
||||||
'reservations.type.train': 'Zug',
|
'reservations.type.train': 'Zug',
|
||||||
'reservations.type.car': 'Mietwagen',
|
'reservations.type.car': 'Auto',
|
||||||
'reservations.type.cruise': 'Kreuzfahrt',
|
'reservations.type.cruise': 'Kreuzfahrt',
|
||||||
'reservations.type.event': 'Veranstaltung',
|
'reservations.type.event': 'Veranstaltung',
|
||||||
'reservations.type.tour': 'Tour',
|
'reservations.type.tour': 'Tour',
|
||||||
@@ -1762,7 +1781,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Nicht zugewiesen',
|
'todo.unassigned': 'Nicht zugewiesen',
|
||||||
'todo.noCategory': 'Keine Kategorie',
|
'todo.noCategory': 'Keine Kategorie',
|
||||||
'todo.hasDescription': 'Hat Beschreibung',
|
'todo.hasDescription': 'Hat Beschreibung',
|
||||||
'todo.addItem': 'Neue Aufgabe hinzufügen...',
|
'todo.addItem': 'Neue Aufgabe hinzufügen',
|
||||||
|
'todo.sidebar.sortBy': 'Sortieren nach',
|
||||||
|
'todo.priority': 'Priorität',
|
||||||
|
'todo.newCategoryLabel': 'neu',
|
||||||
|
'budget.categoriesLabel': 'Kategorien',
|
||||||
'todo.newCategory': 'Kategoriename',
|
'todo.newCategory': 'Kategoriename',
|
||||||
'todo.addCategory': 'Kategorie hinzufügen',
|
'todo.addCategory': 'Kategorie hinzufügen',
|
||||||
'todo.newItem': 'Neue Aufgabe',
|
'todo.newItem': 'Neue Aufgabe',
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.temperature': 'Temperature Unit',
|
'settings.temperature': 'Temperature Unit',
|
||||||
'settings.timeFormat': 'Time Format',
|
'settings.timeFormat': 'Time Format',
|
||||||
'settings.routeCalculation': 'Route Calculation',
|
'settings.routeCalculation': 'Route Calculation',
|
||||||
|
'settings.bookingLabels': 'Booking route labels',
|
||||||
|
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
|
||||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||||
'settings.notifications': 'Notifications',
|
'settings.notifications': 'Notifications',
|
||||||
'settings.notifyTripInvite': 'Trip invitations',
|
'settings.notifyTripInvite': 'Trip invitations',
|
||||||
@@ -370,6 +372,16 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Feature Request',
|
'settings.about.featureRequest': 'Feature Request',
|
||||||
'settings.about.featureRequestHint': 'Suggest a new feature',
|
'settings.about.featureRequestHint': 'Suggest a new feature',
|
||||||
'settings.about.wikiHint': 'Documentation & guides',
|
'settings.about.wikiHint': 'Documentation & guides',
|
||||||
|
'settings.about.supporters.badge': 'Monthly Supporters',
|
||||||
|
'settings.about.supporters.title': 'Travel companions for TREK',
|
||||||
|
'settings.about.supporters.subtitle': "While you're planning your next route, these folks are helping plan TREK's future. Their monthly contribution goes straight into development and real hours spent — so TREK stays Open Source.",
|
||||||
|
'settings.about.supporters.since': 'supporter since {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Be the first',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
|
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
|
||||||
'settings.about.madeWith': 'Made with',
|
'settings.about.madeWith': 'Made with',
|
||||||
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
|
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
|
||||||
@@ -1080,6 +1092,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'Flight No.',
|
'reservations.meta.flightNumber': 'Flight No.',
|
||||||
'reservations.meta.from': 'From',
|
'reservations.meta.from': 'From',
|
||||||
'reservations.meta.to': 'To',
|
'reservations.meta.to': 'To',
|
||||||
|
'reservations.needsReview': 'Review',
|
||||||
|
'reservations.needsReviewHint': 'Airport could not be matched automatically — please confirm the location.',
|
||||||
|
'reservations.searchLocation': 'Search station, port, address…',
|
||||||
|
'airport.searchPlaceholder': 'Airport code or city (e.g. FRA)',
|
||||||
|
'map.connections': 'Connections',
|
||||||
|
'map.showConnections': 'Show booking routes',
|
||||||
|
'map.hideConnections': 'Hide booking routes',
|
||||||
'reservations.meta.trainNumber': 'Train No.',
|
'reservations.meta.trainNumber': 'Train No.',
|
||||||
'reservations.meta.platform': 'Platform',
|
'reservations.meta.platform': 'Platform',
|
||||||
'reservations.meta.seat': 'Seat',
|
'reservations.meta.seat': 'Seat',
|
||||||
@@ -1098,7 +1117,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'Accommodation',
|
'reservations.type.hotel': 'Accommodation',
|
||||||
'reservations.type.restaurant': 'Restaurant',
|
'reservations.type.restaurant': 'Restaurant',
|
||||||
'reservations.type.train': 'Train',
|
'reservations.type.train': 'Train',
|
||||||
'reservations.type.car': 'Rental Car',
|
'reservations.type.car': 'Car',
|
||||||
'reservations.type.cruise': 'Cruise',
|
'reservations.type.cruise': 'Cruise',
|
||||||
'reservations.type.event': 'Event',
|
'reservations.type.event': 'Event',
|
||||||
'reservations.type.tour': 'Tour',
|
'reservations.type.tour': 'Tour',
|
||||||
@@ -1828,7 +1847,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Unassigned',
|
'todo.unassigned': 'Unassigned',
|
||||||
'todo.noCategory': 'No category',
|
'todo.noCategory': 'No category',
|
||||||
'todo.hasDescription': 'Has description',
|
'todo.hasDescription': 'Has description',
|
||||||
'todo.addItem': 'Add new task...',
|
'todo.addItem': 'Add new task',
|
||||||
|
'todo.sidebar.sortBy': 'Sort by',
|
||||||
|
'todo.priority': 'Priority',
|
||||||
|
'todo.newCategoryLabel': 'new',
|
||||||
|
'budget.categoriesLabel': 'categories',
|
||||||
'todo.newCategory': 'Category name',
|
'todo.newCategory': 'Category name',
|
||||||
'todo.addCategory': 'Add category',
|
'todo.addCategory': 'Add category',
|
||||||
'todo.newItem': 'New task',
|
'todo.newItem': 'New task',
|
||||||
|
|||||||
@@ -309,6 +309,16 @@ const es: Record<string, string> = {
|
|||||||
'settings.about.featureRequest': 'Solicitar función',
|
'settings.about.featureRequest': 'Solicitar función',
|
||||||
'settings.about.featureRequestHint': 'Sugiere una nueva función',
|
'settings.about.featureRequestHint': 'Sugiere una nueva función',
|
||||||
'settings.about.wikiHint': 'Documentación y guías',
|
'settings.about.wikiHint': 'Documentación y guías',
|
||||||
|
'settings.about.supporters.badge': 'Patrocinadores Mensuales',
|
||||||
|
'settings.about.supporters.title': 'Compañía de viaje para TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Mientras planeas tu próxima ruta, estas personas ayudan a planear el futuro de TREK. Su aporte mensual va directo al desarrollo y a las horas reales invertidas — para que TREK siga siendo Open Source.',
|
||||||
|
'settings.about.supporters.since': 'patrocinador desde {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Sé el primero',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
|
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
|
||||||
'settings.about.madeWith': 'Hecho con',
|
'settings.about.madeWith': 'Hecho con',
|
||||||
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
|
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
|
||||||
@@ -996,7 +1006,7 @@ const es: Record<string, string> = {
|
|||||||
'reservations.type.hotel': 'Alojamiento',
|
'reservations.type.hotel': 'Alojamiento',
|
||||||
'reservations.type.restaurant': 'Restaurante',
|
'reservations.type.restaurant': 'Restaurante',
|
||||||
'reservations.type.train': 'Tren',
|
'reservations.type.train': 'Tren',
|
||||||
'reservations.type.car': 'Coche de alquiler',
|
'reservations.type.car': 'Coche',
|
||||||
'reservations.type.cruise': 'Crucero',
|
'reservations.type.cruise': 'Crucero',
|
||||||
'reservations.type.event': 'Evento',
|
'reservations.type.event': 'Evento',
|
||||||
'reservations.type.tour': 'Excursión',
|
'reservations.type.tour': 'Excursión',
|
||||||
@@ -1624,6 +1634,15 @@ const es: Record<string, string> = {
|
|||||||
'reservations.meta.flightNumber': 'N° de vuelo',
|
'reservations.meta.flightNumber': 'N° de vuelo',
|
||||||
'reservations.meta.from': 'Desde',
|
'reservations.meta.from': 'Desde',
|
||||||
'reservations.meta.to': 'Hasta',
|
'reservations.meta.to': 'Hasta',
|
||||||
|
'reservations.needsReview': 'Revisar',
|
||||||
|
'reservations.needsReviewHint': 'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
|
||||||
|
'reservations.searchLocation': 'Buscar estación, puerto, dirección...',
|
||||||
|
'airport.searchPlaceholder': 'Código o ciudad del aeropuerto (ej. FRA)',
|
||||||
|
'map.connections': 'Conexiones',
|
||||||
|
'map.showConnections': 'Mostrar rutas de reservas',
|
||||||
|
'map.hideConnections': 'Ocultar rutas de reservas',
|
||||||
|
'settings.bookingLabels': 'Etiquetas de rutas de reservas',
|
||||||
|
'settings.bookingLabelsHint': 'Muestra nombres de estaciones / aeropuertos en el mapa. Desactivado, solo se muestra el icono.',
|
||||||
'reservations.meta.trainNumber': 'N° de tren',
|
'reservations.meta.trainNumber': 'N° de tren',
|
||||||
'reservations.meta.platform': 'Andén',
|
'reservations.meta.platform': 'Andén',
|
||||||
'reservations.meta.seat': 'Asiento',
|
'reservations.meta.seat': 'Asiento',
|
||||||
@@ -1764,7 +1783,11 @@ const es: Record<string, string> = {
|
|||||||
'todo.unassigned': 'Sin asignar',
|
'todo.unassigned': 'Sin asignar',
|
||||||
'todo.noCategory': 'Sin categoría',
|
'todo.noCategory': 'Sin categoría',
|
||||||
'todo.hasDescription': 'Con descripción',
|
'todo.hasDescription': 'Con descripción',
|
||||||
'todo.addItem': 'Añadir nueva tarea...',
|
'todo.addItem': 'Nueva tarea',
|
||||||
|
'todo.sidebar.sortBy': 'Ordenar por',
|
||||||
|
'todo.priority': 'Prioridad',
|
||||||
|
'todo.newCategoryLabel': 'nueva',
|
||||||
|
'budget.categoriesLabel': 'categorías',
|
||||||
'todo.newCategory': 'Nombre de la categoría',
|
'todo.newCategory': 'Nombre de la categoría',
|
||||||
'todo.addCategory': 'Añadir categoría',
|
'todo.addCategory': 'Añadir categoría',
|
||||||
'todo.newItem': 'Nueva tarea',
|
'todo.newItem': 'Nueva tarea',
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ const fr: Record<string, string> = {
|
|||||||
'settings.about.featureRequest': 'Proposer une fonctionnalité',
|
'settings.about.featureRequest': 'Proposer une fonctionnalité',
|
||||||
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
|
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
|
||||||
'settings.about.wikiHint': 'Documentation et guides',
|
'settings.about.wikiHint': 'Documentation et guides',
|
||||||
|
'settings.about.supporters.badge': 'Soutiens Mensuels',
|
||||||
|
'settings.about.supporters.title': 'Compagnons de voyage pour TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Pendant que tu planifies ton prochain itinéraire, ces personnes aident à planifier l\'avenir de TREK. Leur contribution mensuelle va directement au développement et aux heures réellement passées — pour que TREK reste Open Source.',
|
||||||
|
'settings.about.supporters.since': 'soutien depuis {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Sois le premier',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
|
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
|
||||||
'settings.about.madeWith': 'Fait avec',
|
'settings.about.madeWith': 'Fait avec',
|
||||||
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
|
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
|
||||||
@@ -1019,6 +1029,15 @@ const fr: Record<string, string> = {
|
|||||||
'reservations.meta.flightNumber': 'N° de vol',
|
'reservations.meta.flightNumber': 'N° de vol',
|
||||||
'reservations.meta.from': 'De',
|
'reservations.meta.from': 'De',
|
||||||
'reservations.meta.to': 'À',
|
'reservations.meta.to': 'À',
|
||||||
|
'reservations.needsReview': 'Vérifier',
|
||||||
|
'reservations.needsReviewHint': 'L\'aéroport n\'a pas pu être identifié automatiquement — veuillez confirmer l\'emplacement.',
|
||||||
|
'reservations.searchLocation': 'Rechercher une gare, un port, une adresse…',
|
||||||
|
'airport.searchPlaceholder': 'Code ou ville de l\'aéroport (ex. FRA)',
|
||||||
|
'map.connections': 'Connexions',
|
||||||
|
'map.showConnections': 'Afficher les itinéraires',
|
||||||
|
'map.hideConnections': 'Masquer les itinéraires',
|
||||||
|
'settings.bookingLabels': 'Étiquettes des itinéraires',
|
||||||
|
'settings.bookingLabelsHint': 'Affiche les noms des gares / aéroports sur la carte. Si désactivé, seule l\'icône est affichée.',
|
||||||
'reservations.meta.trainNumber': 'N° de train',
|
'reservations.meta.trainNumber': 'N° de train',
|
||||||
'reservations.meta.platform': 'Quai',
|
'reservations.meta.platform': 'Quai',
|
||||||
'reservations.meta.seat': 'Place',
|
'reservations.meta.seat': 'Place',
|
||||||
@@ -1037,7 +1056,7 @@ const fr: Record<string, string> = {
|
|||||||
'reservations.type.hotel': 'Hébergement',
|
'reservations.type.hotel': 'Hébergement',
|
||||||
'reservations.type.restaurant': 'Restaurant',
|
'reservations.type.restaurant': 'Restaurant',
|
||||||
'reservations.type.train': 'Train',
|
'reservations.type.train': 'Train',
|
||||||
'reservations.type.car': 'Voiture de location',
|
'reservations.type.car': 'Voiture',
|
||||||
'reservations.type.cruise': 'Croisière',
|
'reservations.type.cruise': 'Croisière',
|
||||||
'reservations.type.event': 'Événement',
|
'reservations.type.event': 'Événement',
|
||||||
'reservations.type.tour': 'Visite',
|
'reservations.type.tour': 'Visite',
|
||||||
@@ -1758,7 +1777,11 @@ const fr: Record<string, string> = {
|
|||||||
'todo.unassigned': 'Non assigné',
|
'todo.unassigned': 'Non assigné',
|
||||||
'todo.noCategory': 'Aucune catégorie',
|
'todo.noCategory': 'Aucune catégorie',
|
||||||
'todo.hasDescription': 'Avec description',
|
'todo.hasDescription': 'Avec description',
|
||||||
'todo.addItem': 'Ajouter une tâche...',
|
'todo.addItem': 'Nouvelle tâche',
|
||||||
|
'todo.sidebar.sortBy': 'Trier par',
|
||||||
|
'todo.priority': 'Priorité',
|
||||||
|
'todo.newCategoryLabel': 'nouvelle',
|
||||||
|
'budget.categoriesLabel': 'catégories',
|
||||||
'todo.newCategory': 'Nom de la catégorie',
|
'todo.newCategory': 'Nom de la catégorie',
|
||||||
'todo.addCategory': 'Ajouter une catégorie',
|
'todo.addCategory': 'Ajouter une catégorie',
|
||||||
'todo.newItem': 'Nouvelle tâche',
|
'todo.newItem': 'Nouvelle tâche',
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Funkció javaslat',
|
'settings.about.featureRequest': 'Funkció javaslat',
|
||||||
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
|
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
|
||||||
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
|
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
|
||||||
|
'settings.about.supporters.badge': 'Havi támogatók',
|
||||||
|
'settings.about.supporters.title': 'Útitársak a TREK mellett',
|
||||||
|
'settings.about.supporters.subtitle': 'Miközben te a következő útvonaladat tervezed, ők a TREK jövőjét tervezik velem együtt. Havi hozzájárulásuk közvetlenül fejlesztésre és valódi órákra fordítódik — hogy a TREK Open Source maradhasson.',
|
||||||
|
'settings.about.supporters.since': 'támogató {date} óta',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Légy az első',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
|
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
|
||||||
'settings.about.madeWith': 'Készítve',
|
'settings.about.madeWith': 'Készítve',
|
||||||
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
|
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
|
||||||
@@ -1021,6 +1031,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'Járatszám',
|
'reservations.meta.flightNumber': 'Járatszám',
|
||||||
'reservations.meta.from': 'Honnan',
|
'reservations.meta.from': 'Honnan',
|
||||||
'reservations.meta.to': 'Hová',
|
'reservations.meta.to': 'Hová',
|
||||||
|
'reservations.needsReview': 'Ellenőrzés',
|
||||||
|
'reservations.needsReviewHint': 'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
|
||||||
|
'reservations.searchLocation': 'Állomás, kikötő, cím keresése...',
|
||||||
|
'airport.searchPlaceholder': 'Repülőtér kódja vagy város (pl. FRA)',
|
||||||
|
'map.connections': 'Kapcsolatok',
|
||||||
|
'map.showConnections': 'Foglalási útvonalak megjelenítése',
|
||||||
|
'map.hideConnections': 'Foglalási útvonalak elrejtése',
|
||||||
|
'settings.bookingLabels': 'Útvonal-címkék a foglalásokhoz',
|
||||||
|
'settings.bookingLabelsHint': 'Állomás- / repülőtér-nevek megjelenítése a térképen. Ha ki van kapcsolva, csak az ikon látszik.',
|
||||||
'reservations.meta.trainNumber': 'Vonatszám',
|
'reservations.meta.trainNumber': 'Vonatszám',
|
||||||
'reservations.meta.platform': 'Vágány',
|
'reservations.meta.platform': 'Vágány',
|
||||||
'reservations.meta.seat': 'Ülés',
|
'reservations.meta.seat': 'Ülés',
|
||||||
@@ -1039,7 +1058,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'Szálloda',
|
'reservations.type.hotel': 'Szálloda',
|
||||||
'reservations.type.restaurant': 'Étterem',
|
'reservations.type.restaurant': 'Étterem',
|
||||||
'reservations.type.train': 'Vonat',
|
'reservations.type.train': 'Vonat',
|
||||||
'reservations.type.car': 'Autóbérlés',
|
'reservations.type.car': 'Autó',
|
||||||
'reservations.type.cruise': 'Hajóút',
|
'reservations.type.cruise': 'Hajóút',
|
||||||
'reservations.type.event': 'Esemény',
|
'reservations.type.event': 'Esemény',
|
||||||
'reservations.type.tour': 'Túra',
|
'reservations.type.tour': 'Túra',
|
||||||
@@ -1756,7 +1775,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Nem hozzárendelt',
|
'todo.unassigned': 'Nem hozzárendelt',
|
||||||
'todo.noCategory': 'Nincs kategória',
|
'todo.noCategory': 'Nincs kategória',
|
||||||
'todo.hasDescription': 'Van leírás',
|
'todo.hasDescription': 'Van leírás',
|
||||||
'todo.addItem': 'Új feladat hozzáadása...',
|
'todo.addItem': 'Új feladat',
|
||||||
|
'todo.sidebar.sortBy': 'Rendezés',
|
||||||
|
'todo.priority': 'Prioritás',
|
||||||
|
'todo.newCategoryLabel': 'új',
|
||||||
|
'budget.categoriesLabel': 'kategóriák',
|
||||||
'todo.newCategory': 'Kategória neve',
|
'todo.newCategory': 'Kategória neve',
|
||||||
'todo.addCategory': 'Kategória hozzáadása',
|
'todo.addCategory': 'Kategória hozzáadása',
|
||||||
'todo.newItem': 'Új feladat',
|
'todo.newItem': 'Új feladat',
|
||||||
|
|||||||
@@ -370,6 +370,16 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Permintaan Fitur',
|
'settings.about.featureRequest': 'Permintaan Fitur',
|
||||||
'settings.about.featureRequestHint': 'Sarankan fitur baru',
|
'settings.about.featureRequestHint': 'Sarankan fitur baru',
|
||||||
'settings.about.wikiHint': 'Dokumentasi & panduan',
|
'settings.about.wikiHint': 'Dokumentasi & panduan',
|
||||||
|
'settings.about.supporters.badge': 'Pendukung Bulanan',
|
||||||
|
'settings.about.supporters.title': 'Rekan perjalanan untuk TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Saat kamu merencanakan rute berikutnya, orang-orang ini ikut merencanakan masa depan TREK. Kontribusi bulanan mereka langsung masuk ke pengembangan dan jam kerja nyata — supaya TREK tetap Open Source.',
|
||||||
|
'settings.about.supporters.since': 'pendukung sejak {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Jadilah yang pertama',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK adalah perencana perjalanan self-hosted yang membantu kamu mengatur perjalanan dari ide pertama hingga kenangan terakhir. Perencanaan harian, anggaran, daftar bawaan, foto dan masih banyak lagi — semua di satu tempat, di servermu sendiri.',
|
'settings.about.description': 'TREK adalah perencana perjalanan self-hosted yang membantu kamu mengatur perjalanan dari ide pertama hingga kenangan terakhir. Perencanaan harian, anggaran, daftar bawaan, foto dan masih banyak lagi — semua di satu tempat, di servermu sendiri.',
|
||||||
'settings.about.madeWith': 'Dibuat dengan',
|
'settings.about.madeWith': 'Dibuat dengan',
|
||||||
'settings.about.madeBy': 'oleh Maurice dan komunitas open-source yang terus berkembang.',
|
'settings.about.madeBy': 'oleh Maurice dan komunitas open-source yang terus berkembang.',
|
||||||
@@ -1080,6 +1090,15 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'No. Penerbangan',
|
'reservations.meta.flightNumber': 'No. Penerbangan',
|
||||||
'reservations.meta.from': 'Dari',
|
'reservations.meta.from': 'Dari',
|
||||||
'reservations.meta.to': 'Ke',
|
'reservations.meta.to': 'Ke',
|
||||||
|
'reservations.needsReview': 'Tinjau',
|
||||||
|
'reservations.needsReviewHint': 'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
|
||||||
|
'reservations.searchLocation': 'Cari stasiun, pelabuhan, alamat...',
|
||||||
|
'airport.searchPlaceholder': 'Kode bandara atau kota (mis. FRA)',
|
||||||
|
'map.connections': 'Koneksi',
|
||||||
|
'map.showConnections': 'Tampilkan rute pemesanan',
|
||||||
|
'map.hideConnections': 'Sembunyikan rute pemesanan',
|
||||||
|
'settings.bookingLabels': 'Label rute pemesanan',
|
||||||
|
'settings.bookingLabelsHint': 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
|
||||||
'reservations.meta.trainNumber': 'No. Kereta',
|
'reservations.meta.trainNumber': 'No. Kereta',
|
||||||
'reservations.meta.platform': 'Peron',
|
'reservations.meta.platform': 'Peron',
|
||||||
'reservations.meta.seat': 'Kursi',
|
'reservations.meta.seat': 'Kursi',
|
||||||
@@ -1098,7 +1117,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'Akomodasi',
|
'reservations.type.hotel': 'Akomodasi',
|
||||||
'reservations.type.restaurant': 'Restoran',
|
'reservations.type.restaurant': 'Restoran',
|
||||||
'reservations.type.train': 'Kereta',
|
'reservations.type.train': 'Kereta',
|
||||||
'reservations.type.car': 'Mobil Sewa',
|
'reservations.type.car': 'Mobil',
|
||||||
'reservations.type.cruise': 'Kapal Pesiar',
|
'reservations.type.cruise': 'Kapal Pesiar',
|
||||||
'reservations.type.event': 'Acara',
|
'reservations.type.event': 'Acara',
|
||||||
'reservations.type.tour': 'Tur',
|
'reservations.type.tour': 'Tur',
|
||||||
@@ -1828,7 +1847,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Belum ditugaskan',
|
'todo.unassigned': 'Belum ditugaskan',
|
||||||
'todo.noCategory': 'Tanpa kategori',
|
'todo.noCategory': 'Tanpa kategori',
|
||||||
'todo.hasDescription': 'Ada deskripsi',
|
'todo.hasDescription': 'Ada deskripsi',
|
||||||
'todo.addItem': 'Tambah tugas baru...',
|
'todo.addItem': 'Tugas baru',
|
||||||
|
'todo.sidebar.sortBy': 'Urutkan',
|
||||||
|
'todo.priority': 'Prioritas',
|
||||||
|
'todo.newCategoryLabel': 'baru',
|
||||||
|
'budget.categoriesLabel': 'kategori',
|
||||||
'todo.newCategory': 'Nama kategori',
|
'todo.newCategory': 'Nama kategori',
|
||||||
'todo.addCategory': 'Tambah kategori',
|
'todo.addCategory': 'Tambah kategori',
|
||||||
'todo.newItem': 'Tugas baru',
|
'todo.newItem': 'Tugas baru',
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Richiedi funzionalità',
|
'settings.about.featureRequest': 'Richiedi funzionalità',
|
||||||
'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità',
|
'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità',
|
||||||
'settings.about.wikiHint': 'Documentazione e guide',
|
'settings.about.wikiHint': 'Documentazione e guide',
|
||||||
|
'settings.about.supporters.badge': 'Sostenitori Mensili',
|
||||||
|
'settings.about.supporters.title': 'Compagni di viaggio per TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Mentre pianifichi il tuo prossimo itinerario, queste persone aiutano a pianificare il futuro di TREK. Il loro contributo mensile va direttamente allo sviluppo e alle ore realmente investite — per mantenere TREK Open Source.',
|
||||||
|
'settings.about.supporters.since': 'sostenitore da {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Sii il primo',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
|
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
|
||||||
'settings.about.madeWith': 'Fatto con',
|
'settings.about.madeWith': 'Fatto con',
|
||||||
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
|
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
|
||||||
@@ -1020,6 +1030,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.meta.flightNumber': 'N. volo',
|
'reservations.meta.flightNumber': 'N. volo',
|
||||||
'reservations.meta.from': 'Da',
|
'reservations.meta.from': 'Da',
|
||||||
'reservations.meta.to': 'A',
|
'reservations.meta.to': 'A',
|
||||||
|
'reservations.needsReview': 'Verifica',
|
||||||
|
'reservations.needsReviewHint': 'L\'aeroporto non è stato riconosciuto automaticamente — conferma la posizione.',
|
||||||
|
'reservations.searchLocation': 'Cerca stazione, porto, indirizzo...',
|
||||||
|
'airport.searchPlaceholder': 'Codice o città dell\'aeroporto (es. FRA)',
|
||||||
|
'map.connections': 'Connessioni',
|
||||||
|
'map.showConnections': 'Mostra percorsi prenotati',
|
||||||
|
'map.hideConnections': 'Nascondi percorsi prenotati',
|
||||||
|
'settings.bookingLabels': 'Etichette percorsi prenotati',
|
||||||
|
'settings.bookingLabelsHint': 'Mostra i nomi di stazioni / aeroporti sulla mappa. Se disattivato, viene mostrata solo l\'icona.',
|
||||||
'reservations.meta.trainNumber': 'N. treno',
|
'reservations.meta.trainNumber': 'N. treno',
|
||||||
'reservations.meta.platform': 'Binario',
|
'reservations.meta.platform': 'Binario',
|
||||||
'reservations.meta.seat': 'Posto',
|
'reservations.meta.seat': 'Posto',
|
||||||
@@ -1038,7 +1057,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.hotel': 'Alloggio',
|
'reservations.type.hotel': 'Alloggio',
|
||||||
'reservations.type.restaurant': 'Ristorante',
|
'reservations.type.restaurant': 'Ristorante',
|
||||||
'reservations.type.train': 'Treno',
|
'reservations.type.train': 'Treno',
|
||||||
'reservations.type.car': 'Auto a noleggio',
|
'reservations.type.car': 'Auto',
|
||||||
'reservations.type.cruise': 'Crociera',
|
'reservations.type.cruise': 'Crociera',
|
||||||
'reservations.type.event': 'Evento',
|
'reservations.type.event': 'Evento',
|
||||||
'reservations.type.tour': 'Tour',
|
'reservations.type.tour': 'Tour',
|
||||||
@@ -1759,7 +1778,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Non assegnato',
|
'todo.unassigned': 'Non assegnato',
|
||||||
'todo.noCategory': 'Nessuna categoria',
|
'todo.noCategory': 'Nessuna categoria',
|
||||||
'todo.hasDescription': 'Ha descrizione',
|
'todo.hasDescription': 'Ha descrizione',
|
||||||
'todo.addItem': 'Aggiungi nuova attività...',
|
'todo.addItem': 'Nuova attività',
|
||||||
|
'todo.sidebar.sortBy': 'Ordina per',
|
||||||
|
'todo.priority': 'Priorità',
|
||||||
|
'todo.newCategoryLabel': 'nuova',
|
||||||
|
'budget.categoriesLabel': 'categorie',
|
||||||
'todo.newCategory': 'Nome categoria',
|
'todo.newCategory': 'Nome categoria',
|
||||||
'todo.addCategory': 'Aggiungi categoria',
|
'todo.addCategory': 'Aggiungi categoria',
|
||||||
'todo.newItem': 'Nuova attività',
|
'todo.newItem': 'Nuova attività',
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ const nl: Record<string, string> = {
|
|||||||
'settings.about.featureRequest': 'Feature aanvragen',
|
'settings.about.featureRequest': 'Feature aanvragen',
|
||||||
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
|
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
|
||||||
'settings.about.wikiHint': 'Documentatie en handleidingen',
|
'settings.about.wikiHint': 'Documentatie en handleidingen',
|
||||||
|
'settings.about.supporters.badge': 'Maandelijkse Steuners',
|
||||||
|
'settings.about.supporters.title': 'Reisgezelschap voor TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Terwijl jij je volgende route plant, plannen deze mensen mee aan de toekomst van TREK. Hun maandelijkse bijdrage gaat rechtstreeks naar ontwikkeling en echte uren — zodat TREK Open Source blijft.',
|
||||||
|
'settings.about.supporters.since': 'steuner sinds {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Wees de eerste',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
|
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
|
||||||
'settings.about.madeWith': 'Gemaakt met',
|
'settings.about.madeWith': 'Gemaakt met',
|
||||||
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
|
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
|
||||||
@@ -1019,6 +1029,15 @@ const nl: Record<string, string> = {
|
|||||||
'reservations.meta.flightNumber': 'Vluchtnr.',
|
'reservations.meta.flightNumber': 'Vluchtnr.',
|
||||||
'reservations.meta.from': 'Van',
|
'reservations.meta.from': 'Van',
|
||||||
'reservations.meta.to': 'Naar',
|
'reservations.meta.to': 'Naar',
|
||||||
|
'reservations.needsReview': 'Controleren',
|
||||||
|
'reservations.needsReviewHint': 'Luchthaven kon niet automatisch worden herkend — bevestig de locatie.',
|
||||||
|
'reservations.searchLocation': 'Station, haven, adres zoeken...',
|
||||||
|
'airport.searchPlaceholder': 'Luchthavencode of stad (bijv. FRA)',
|
||||||
|
'map.connections': 'Verbindingen',
|
||||||
|
'map.showConnections': 'Boekingsroutes tonen',
|
||||||
|
'map.hideConnections': 'Boekingsroutes verbergen',
|
||||||
|
'settings.bookingLabels': 'Routelabels voor boekingen',
|
||||||
|
'settings.bookingLabelsHint': 'Toon station- / luchthavennamen op de kaart. Indien uit, alleen het icoon.',
|
||||||
'reservations.meta.trainNumber': 'Treinnr.',
|
'reservations.meta.trainNumber': 'Treinnr.',
|
||||||
'reservations.meta.platform': 'Perron',
|
'reservations.meta.platform': 'Perron',
|
||||||
'reservations.meta.seat': 'Stoel',
|
'reservations.meta.seat': 'Stoel',
|
||||||
@@ -1037,7 +1056,7 @@ const nl: Record<string, string> = {
|
|||||||
'reservations.type.hotel': 'Accommodatie',
|
'reservations.type.hotel': 'Accommodatie',
|
||||||
'reservations.type.restaurant': 'Restaurant',
|
'reservations.type.restaurant': 'Restaurant',
|
||||||
'reservations.type.train': 'Trein',
|
'reservations.type.train': 'Trein',
|
||||||
'reservations.type.car': 'Huurauto',
|
'reservations.type.car': 'Auto',
|
||||||
'reservations.type.cruise': 'Cruise',
|
'reservations.type.cruise': 'Cruise',
|
||||||
'reservations.type.event': 'Evenement',
|
'reservations.type.event': 'Evenement',
|
||||||
'reservations.type.tour': 'Rondleiding',
|
'reservations.type.tour': 'Rondleiding',
|
||||||
@@ -1758,7 +1777,11 @@ const nl: Record<string, string> = {
|
|||||||
'todo.unassigned': 'Niet toegewezen',
|
'todo.unassigned': 'Niet toegewezen',
|
||||||
'todo.noCategory': 'Geen categorie',
|
'todo.noCategory': 'Geen categorie',
|
||||||
'todo.hasDescription': 'Heeft beschrijving',
|
'todo.hasDescription': 'Heeft beschrijving',
|
||||||
'todo.addItem': 'Nieuwe taak toevoegen...',
|
'todo.addItem': 'Nieuwe taak',
|
||||||
|
'todo.sidebar.sortBy': 'Sorteren op',
|
||||||
|
'todo.priority': 'Prioriteit',
|
||||||
|
'todo.newCategoryLabel': 'nieuw',
|
||||||
|
'budget.categoriesLabel': 'categorieën',
|
||||||
'todo.newCategory': 'Categorienaam',
|
'todo.newCategory': 'Categorienaam',
|
||||||
'todo.addCategory': 'Categorie toevoegen',
|
'todo.addCategory': 'Categorie toevoegen',
|
||||||
'todo.newItem': 'Nieuwe taak',
|
'todo.newItem': 'Nieuwe taak',
|
||||||
|
|||||||
@@ -281,6 +281,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.about.featureRequest': 'Zaproponuj funkcję',
|
'settings.about.featureRequest': 'Zaproponuj funkcję',
|
||||||
'settings.about.featureRequestHint': 'Zaproponuj nową funkcję',
|
'settings.about.featureRequestHint': 'Zaproponuj nową funkcję',
|
||||||
'settings.about.wikiHint': 'Dokumentacja i poradniki',
|
'settings.about.wikiHint': 'Dokumentacja i poradniki',
|
||||||
|
'settings.about.supporters.badge': 'Miesięczni Patroni',
|
||||||
|
'settings.about.supporters.title': 'Towarzystwo podróży dla TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Gdy planujesz kolejną trasę, te osoby planują razem ze mną przyszłość TREK. Ich comiesięczny wkład idzie bezpośrednio na rozwój i realnie przepracowane godziny — aby TREK pozostał Open Source.',
|
||||||
|
'settings.about.supporters.since': 'patron od {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Bądź pierwszy',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK to samodzielnie hostowany planer podróży, który pomaga organizować wyprawy od pierwszego pomysłu po ostatnie wspomnienie. Planowanie dzienne, budżet, listy pakowania, zdjęcia i wiele więcej — wszystko w jednym miejscu, na własnym serwerze.',
|
'settings.about.description': 'TREK to samodzielnie hostowany planer podróży, który pomaga organizować wyprawy od pierwszego pomysłu po ostatnie wspomnienie. Planowanie dzienne, budżet, listy pakowania, zdjęcia i wiele więcej — wszystko w jednym miejscu, na własnym serwerze.',
|
||||||
'settings.about.madeWith': 'Stworzone z',
|
'settings.about.madeWith': 'Stworzone z',
|
||||||
'settings.about.madeBy': 'przez Maurice\'a i rosnącą społeczność open-source.',
|
'settings.about.madeBy': 'przez Maurice\'a i rosnącą społeczność open-source.',
|
||||||
@@ -995,6 +1005,15 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'reservations.type.restaurant': 'Restauracja',
|
'reservations.type.restaurant': 'Restauracja',
|
||||||
'reservations.type.train': 'Pociąg',
|
'reservations.type.train': 'Pociąg',
|
||||||
'reservations.type.car': 'Samochód',
|
'reservations.type.car': 'Samochód',
|
||||||
|
'reservations.needsReview': 'Sprawdź',
|
||||||
|
'reservations.needsReviewHint': 'Nie udało się automatycznie dopasować lotniska — potwierdź lokalizację.',
|
||||||
|
'reservations.searchLocation': 'Szukaj stacji, portu, adresu...',
|
||||||
|
'airport.searchPlaceholder': 'Kod lotniska lub miasto (np. FRA)',
|
||||||
|
'map.connections': 'Połączenia',
|
||||||
|
'map.showConnections': 'Pokaż trasy rezerwacji',
|
||||||
|
'map.hideConnections': 'Ukryj trasy rezerwacji',
|
||||||
|
'settings.bookingLabels': 'Etykiety tras rezerwacji',
|
||||||
|
'settings.bookingLabelsHint': 'Pokazuje nazwy stacji / lotnisk na mapie. Gdy wyłączone, wyświetlana jest tylko ikona.',
|
||||||
'reservations.type.cruise': 'Rejs',
|
'reservations.type.cruise': 'Rejs',
|
||||||
'reservations.type.event': 'Wydarzenie',
|
'reservations.type.event': 'Wydarzenie',
|
||||||
'reservations.type.tour': 'Wycieczka',
|
'reservations.type.tour': 'Wycieczka',
|
||||||
@@ -1811,7 +1830,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'todo.unassigned': 'Nieprzypisane',
|
'todo.unassigned': 'Nieprzypisane',
|
||||||
'todo.noCategory': 'Brak kategorii',
|
'todo.noCategory': 'Brak kategorii',
|
||||||
'todo.hasDescription': 'Ma opis',
|
'todo.hasDescription': 'Ma opis',
|
||||||
'todo.addItem': 'Dodaj nowe zadanie...',
|
'todo.addItem': 'Nowe zadanie',
|
||||||
|
'todo.sidebar.sortBy': 'Sortuj wg',
|
||||||
|
'todo.priority': 'Priorytet',
|
||||||
|
'todo.newCategoryLabel': 'nowa',
|
||||||
|
'budget.categoriesLabel': 'kategorie',
|
||||||
'todo.newCategory': 'Nazwa kategorii',
|
'todo.newCategory': 'Nazwa kategorii',
|
||||||
'todo.addCategory': 'Dodaj kategorię',
|
'todo.addCategory': 'Dodaj kategorię',
|
||||||
'todo.newItem': 'Nowe zadanie',
|
'todo.newItem': 'Nowe zadanie',
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ const ru: Record<string, string> = {
|
|||||||
'settings.about.featureRequest': 'Предложить функцию',
|
'settings.about.featureRequest': 'Предложить функцию',
|
||||||
'settings.about.featureRequestHint': 'Предложите новую функцию',
|
'settings.about.featureRequestHint': 'Предложите новую функцию',
|
||||||
'settings.about.wikiHint': 'Документация и руководства',
|
'settings.about.wikiHint': 'Документация и руководства',
|
||||||
|
'settings.about.supporters.badge': 'Ежемесячные спонсоры',
|
||||||
|
'settings.about.supporters.title': 'Спутники TREK',
|
||||||
|
'settings.about.supporters.subtitle': 'Пока ты планируешь следующий маршрут, эти люди планируют вместе со мной будущее TREK. Их ежемесячный взнос идёт напрямую в разработку и реально потраченные часы — чтобы TREK оставался Open Source.',
|
||||||
|
'settings.about.supporters.since': 'спонсор с {date}',
|
||||||
|
'settings.about.supporters.tierEmpty': 'Стань первым',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK — это самостоятельно размещаемый планировщик путешествий, который помогает организовать поездки от первой идеи до последнего воспоминания. Планирование по дням, бюджет, списки вещей, фото и многое другое — всё в одном месте, на вашем собственном сервере.',
|
'settings.about.description': 'TREK — это самостоятельно размещаемый планировщик путешествий, который помогает организовать поездки от первой идеи до последнего воспоминания. Планирование по дням, бюджет, списки вещей, фото и многое другое — всё в одном месте, на вашем собственном сервере.',
|
||||||
'settings.about.madeWith': 'Сделано с',
|
'settings.about.madeWith': 'Сделано с',
|
||||||
'settings.about.madeBy': 'Морисом и растущим open-source сообществом.',
|
'settings.about.madeBy': 'Морисом и растущим open-source сообществом.',
|
||||||
@@ -1019,6 +1029,15 @@ const ru: Record<string, string> = {
|
|||||||
'reservations.meta.flightNumber': 'Номер рейса',
|
'reservations.meta.flightNumber': 'Номер рейса',
|
||||||
'reservations.meta.from': 'Откуда',
|
'reservations.meta.from': 'Откуда',
|
||||||
'reservations.meta.to': 'Куда',
|
'reservations.meta.to': 'Куда',
|
||||||
|
'reservations.needsReview': 'Проверить',
|
||||||
|
'reservations.needsReviewHint': 'Аэропорт не удалось определить автоматически — подтвердите местоположение.',
|
||||||
|
'reservations.searchLocation': 'Искать станцию, порт, адрес...',
|
||||||
|
'airport.searchPlaceholder': 'Код аэропорта или город (напр. FRA)',
|
||||||
|
'map.connections': 'Соединения',
|
||||||
|
'map.showConnections': 'Показать маршруты бронирований',
|
||||||
|
'map.hideConnections': 'Скрыть маршруты бронирований',
|
||||||
|
'settings.bookingLabels': 'Подписи маршрутов бронирований',
|
||||||
|
'settings.bookingLabelsHint': 'Отображает названия станций / аэропортов на карте. Если выключено, показывается только значок.',
|
||||||
'reservations.meta.trainNumber': 'Номер поезда',
|
'reservations.meta.trainNumber': 'Номер поезда',
|
||||||
'reservations.meta.platform': 'Платформа',
|
'reservations.meta.platform': 'Платформа',
|
||||||
'reservations.meta.seat': 'Место',
|
'reservations.meta.seat': 'Место',
|
||||||
@@ -1037,7 +1056,7 @@ const ru: Record<string, string> = {
|
|||||||
'reservations.type.hotel': 'Жильё',
|
'reservations.type.hotel': 'Жильё',
|
||||||
'reservations.type.restaurant': 'Ресторан',
|
'reservations.type.restaurant': 'Ресторан',
|
||||||
'reservations.type.train': 'Поезд',
|
'reservations.type.train': 'Поезд',
|
||||||
'reservations.type.car': 'Аренда авто',
|
'reservations.type.car': 'Автомобиль',
|
||||||
'reservations.type.cruise': 'Круиз',
|
'reservations.type.cruise': 'Круиз',
|
||||||
'reservations.type.event': 'Мероприятие',
|
'reservations.type.event': 'Мероприятие',
|
||||||
'reservations.type.tour': 'Экскурсия',
|
'reservations.type.tour': 'Экскурсия',
|
||||||
@@ -1755,7 +1774,11 @@ const ru: Record<string, string> = {
|
|||||||
'todo.unassigned': 'Не назначено',
|
'todo.unassigned': 'Не назначено',
|
||||||
'todo.noCategory': 'Без категории',
|
'todo.noCategory': 'Без категории',
|
||||||
'todo.hasDescription': 'Есть описание',
|
'todo.hasDescription': 'Есть описание',
|
||||||
'todo.addItem': 'Добавить новую задачу...',
|
'todo.addItem': 'Новая задача',
|
||||||
|
'todo.sidebar.sortBy': 'Сортировать по',
|
||||||
|
'todo.priority': 'Приоритет',
|
||||||
|
'todo.newCategoryLabel': 'новая',
|
||||||
|
'budget.categoriesLabel': 'категорий',
|
||||||
'todo.newCategory': 'Название категории',
|
'todo.newCategory': 'Название категории',
|
||||||
'todo.addCategory': 'Добавить категорию',
|
'todo.addCategory': 'Добавить категорию',
|
||||||
'todo.newItem': 'Новая задача',
|
'todo.newItem': 'Новая задача',
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ const zh: Record<string, string> = {
|
|||||||
'settings.about.featureRequest': '功能建议',
|
'settings.about.featureRequest': '功能建议',
|
||||||
'settings.about.featureRequestHint': '建议一个新功能',
|
'settings.about.featureRequestHint': '建议一个新功能',
|
||||||
'settings.about.wikiHint': '文档和指南',
|
'settings.about.wikiHint': '文档和指南',
|
||||||
|
'settings.about.supporters.badge': '月度支持者',
|
||||||
|
'settings.about.supporters.title': '与 TREK 同行的伙伴',
|
||||||
|
'settings.about.supporters.subtitle': '当你在规划下一段路线时,这些人也在一起规划 TREK 的未来。他们每月的支持直接用于开发与真实投入的时间——让 TREK 保持开源。',
|
||||||
|
'settings.about.supporters.since': '{date} 起的支持者',
|
||||||
|
'settings.about.supporters.tierEmpty': '成为第一个',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK 是一个自托管的旅行规划工具,帮助你从最初的想法到最后的回忆,全程组织你的旅行。日程规划、预算、行李清单、照片等——一切尽在一处,在你自己的服务器上。',
|
'settings.about.description': 'TREK 是一个自托管的旅行规划工具,帮助你从最初的想法到最后的回忆,全程组织你的旅行。日程规划、预算、行李清单、照片等——一切尽在一处,在你自己的服务器上。',
|
||||||
'settings.about.madeWith': '用',
|
'settings.about.madeWith': '用',
|
||||||
'settings.about.madeBy': '由 Maurice 和不断壮大的开源社区打造。',
|
'settings.about.madeBy': '由 Maurice 和不断壮大的开源社区打造。',
|
||||||
@@ -1019,6 +1029,15 @@ const zh: Record<string, string> = {
|
|||||||
'reservations.meta.flightNumber': '航班号',
|
'reservations.meta.flightNumber': '航班号',
|
||||||
'reservations.meta.from': '出发',
|
'reservations.meta.from': '出发',
|
||||||
'reservations.meta.to': '到达',
|
'reservations.meta.to': '到达',
|
||||||
|
'reservations.needsReview': '待确认',
|
||||||
|
'reservations.needsReviewHint': '无法自动匹配机场 — 请确认位置。',
|
||||||
|
'reservations.searchLocation': '搜索车站、港口、地址...',
|
||||||
|
'airport.searchPlaceholder': '机场代码或城市(如 FRA)',
|
||||||
|
'map.connections': '连接',
|
||||||
|
'map.showConnections': '显示预订路线',
|
||||||
|
'map.hideConnections': '隐藏预订路线',
|
||||||
|
'settings.bookingLabels': '预订路线标签',
|
||||||
|
'settings.bookingLabelsHint': '在地图上显示车站 / 机场名称。关闭时仅显示图标。',
|
||||||
'reservations.meta.trainNumber': '车次',
|
'reservations.meta.trainNumber': '车次',
|
||||||
'reservations.meta.platform': '站台',
|
'reservations.meta.platform': '站台',
|
||||||
'reservations.meta.seat': '座位',
|
'reservations.meta.seat': '座位',
|
||||||
@@ -1037,7 +1056,7 @@ const zh: Record<string, string> = {
|
|||||||
'reservations.type.hotel': '住宿',
|
'reservations.type.hotel': '住宿',
|
||||||
'reservations.type.restaurant': '餐厅',
|
'reservations.type.restaurant': '餐厅',
|
||||||
'reservations.type.train': '火车',
|
'reservations.type.train': '火车',
|
||||||
'reservations.type.car': '租车',
|
'reservations.type.car': '汽车',
|
||||||
'reservations.type.cruise': '邮轮',
|
'reservations.type.cruise': '邮轮',
|
||||||
'reservations.type.event': '活动',
|
'reservations.type.event': '活动',
|
||||||
'reservations.type.tour': '旅游团',
|
'reservations.type.tour': '旅游团',
|
||||||
@@ -1755,7 +1774,11 @@ const zh: Record<string, string> = {
|
|||||||
'todo.unassigned': '未分配',
|
'todo.unassigned': '未分配',
|
||||||
'todo.noCategory': '无分类',
|
'todo.noCategory': '无分类',
|
||||||
'todo.hasDescription': '有描述',
|
'todo.hasDescription': '有描述',
|
||||||
'todo.addItem': '添加新任务...',
|
'todo.addItem': '新建任务',
|
||||||
|
'todo.sidebar.sortBy': '排序方式',
|
||||||
|
'todo.priority': '优先级',
|
||||||
|
'todo.newCategoryLabel': '新建',
|
||||||
|
'budget.categoriesLabel': '类别',
|
||||||
'todo.newCategory': '分类名称',
|
'todo.newCategory': '分类名称',
|
||||||
'todo.addCategory': '添加分类',
|
'todo.addCategory': '添加分类',
|
||||||
'todo.newItem': '新任务',
|
'todo.newItem': '新任务',
|
||||||
|
|||||||
@@ -367,6 +367,16 @@ const zhTw: Record<string, string> = {
|
|||||||
'settings.about.featureRequest': '功能建議',
|
'settings.about.featureRequest': '功能建議',
|
||||||
'settings.about.featureRequestHint': '建議新功能',
|
'settings.about.featureRequestHint': '建議新功能',
|
||||||
'settings.about.wikiHint': '文件與指南',
|
'settings.about.wikiHint': '文件與指南',
|
||||||
|
'settings.about.supporters.badge': '月度支持者',
|
||||||
|
'settings.about.supporters.title': '與 TREK 同行的夥伴',
|
||||||
|
'settings.about.supporters.subtitle': '當你規劃下一段路線時,這些人也在一起規劃 TREK 的未來。他們每月的支持直接用於開發與實際投入的時間——讓 TREK 保持開源。',
|
||||||
|
'settings.about.supporters.since': '自 {date} 起的支持者',
|
||||||
|
'settings.about.supporters.tierEmpty': '成為第一個',
|
||||||
|
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||||
|
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||||
|
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||||
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||||
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||||
'settings.about.description': 'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
|
'settings.about.description': 'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
|
||||||
'settings.about.madeWith': '以',
|
'settings.about.madeWith': '以',
|
||||||
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
|
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
|
||||||
@@ -1079,6 +1089,15 @@ const zhTw: Record<string, string> = {
|
|||||||
'reservations.meta.flightNumber': '航班號',
|
'reservations.meta.flightNumber': '航班號',
|
||||||
'reservations.meta.from': '出發',
|
'reservations.meta.from': '出發',
|
||||||
'reservations.meta.to': '到達',
|
'reservations.meta.to': '到達',
|
||||||
|
'reservations.needsReview': '待確認',
|
||||||
|
'reservations.needsReviewHint': '無法自動匹配機場 — 請確認位置。',
|
||||||
|
'reservations.searchLocation': '搜尋車站、港口、地址...',
|
||||||
|
'airport.searchPlaceholder': '機場代碼或城市(例如 FRA)',
|
||||||
|
'map.connections': '連接',
|
||||||
|
'map.showConnections': '顯示預訂路線',
|
||||||
|
'map.hideConnections': '隱藏預訂路線',
|
||||||
|
'settings.bookingLabels': '預訂路線標籤',
|
||||||
|
'settings.bookingLabelsHint': '在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
|
||||||
'reservations.meta.trainNumber': '車次',
|
'reservations.meta.trainNumber': '車次',
|
||||||
'reservations.meta.platform': '站臺',
|
'reservations.meta.platform': '站臺',
|
||||||
'reservations.meta.seat': '座位',
|
'reservations.meta.seat': '座位',
|
||||||
@@ -1097,7 +1116,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'reservations.type.hotel': '住宿',
|
'reservations.type.hotel': '住宿',
|
||||||
'reservations.type.restaurant': '餐廳',
|
'reservations.type.restaurant': '餐廳',
|
||||||
'reservations.type.train': '火車',
|
'reservations.type.train': '火車',
|
||||||
'reservations.type.car': '租車',
|
'reservations.type.car': '汽車',
|
||||||
'reservations.type.cruise': '郵輪',
|
'reservations.type.cruise': '郵輪',
|
||||||
'reservations.type.event': '活動',
|
'reservations.type.event': '活動',
|
||||||
'reservations.type.tour': '旅遊團',
|
'reservations.type.tour': '旅遊團',
|
||||||
@@ -1776,7 +1795,11 @@ const zhTw: Record<string, string> = {
|
|||||||
'todo.unassigned': '未指派',
|
'todo.unassigned': '未指派',
|
||||||
'todo.noCategory': '無分類',
|
'todo.noCategory': '無分類',
|
||||||
'todo.hasDescription': '有說明',
|
'todo.hasDescription': '有說明',
|
||||||
'todo.addItem': '新增任務...',
|
'todo.addItem': '新增任務',
|
||||||
|
'todo.sidebar.sortBy': '排序方式',
|
||||||
|
'todo.priority': '優先順序',
|
||||||
|
'todo.newCategoryLabel': '新增',
|
||||||
|
'budget.categoriesLabel': '類別',
|
||||||
'todo.newCategory': '分類名稱',
|
'todo.newCategory': '分類名稱',
|
||||||
'todo.addCategory': '新增分類',
|
'todo.addCategory': '新增分類',
|
||||||
'todo.newItem': '新任務',
|
'todo.newItem': '新任務',
|
||||||
|
|||||||
@@ -36,36 +36,102 @@ import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
|||||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||||
import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
||||||
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
||||||
import { ListTodo } from 'lucide-react'
|
import { ListTodo, Upload, Plus } from 'lucide-react'
|
||||||
|
|
||||||
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
||||||
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
||||||
return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
|
return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
|
||||||
})
|
})
|
||||||
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
|
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
|
||||||
|
const [importPackingSignal, setImportPackingSignal] = useState(0)
|
||||||
|
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
|
||||||
|
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo, count: todoItems.length },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '4px 16px 0', borderBottom: '1px solid var(--border-faint)', marginBottom: 8 }}>
|
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||||
{([
|
<div style={{
|
||||||
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck },
|
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||||
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo },
|
padding: '14px 16px 14px 22px',
|
||||||
]).map(tab => (
|
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||||
<button key={tab.id} onClick={() => setSubTabPersist(tab.id)}
|
}}>
|
||||||
style={{
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, fontWeight: 500, padding: '8px 14px',
|
{t('trip.tabs.lists')}
|
||||||
border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: 'none',
|
</h2>
|
||||||
color: subTab === tab.id ? 'var(--text-primary)' : 'var(--text-faint)',
|
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||||
borderBottom: subTab === tab.id ? '2px solid var(--text-primary)' : '2px solid transparent',
|
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||||
marginBottom: -1, transition: 'color 0.15s',
|
{tabs.map(tab => {
|
||||||
}}>
|
const active = subTab === tab.id
|
||||||
<tab.icon size={14} />
|
const Icon = tab.icon
|
||||||
{tab.label}
|
return (
|
||||||
</button>
|
<button key={tab.id} onClick={() => setSubTabPersist(tab.id)}
|
||||||
))}
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
|
||||||
|
background: active ? 'var(--bg-card)' : 'transparent',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
fontWeight: active ? 500 : 400,
|
||||||
|
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={13} style={{ color: active ? 'var(--text-primary)' : 'var(--text-faint)' }} />
|
||||||
|
<span className="hidden sm:inline">{tab.label}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600,
|
||||||
|
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
|
||||||
|
color: 'var(--text-faint)',
|
||||||
|
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
|
||||||
|
}}>{tab.count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subTab === 'packing' && (
|
||||||
|
<button onClick={() => setImportPackingSignal(s => s + 1)} style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
>
|
||||||
|
<Upload size={14} strokeWidth={2.5} />
|
||||||
|
<span className="hidden sm:inline">{t('packing.import')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{subTab === 'todo' && (
|
||||||
|
<button onClick={() => setAddTodoSignal(s => s + 1)} style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||||
|
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
>
|
||||||
|
<Plus size={14} strokeWidth={2.5} />
|
||||||
|
<span className="hidden sm:inline">{t('todo.addItem')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px 28px 0' }} className="max-md:!px-4">
|
||||||
|
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} openImportSignal={importPackingSignal} inlineHeader={false} />}
|
||||||
|
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} addItemSignal={addTodoSignal} />}
|
||||||
</div>
|
</div>
|
||||||
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} />}
|
|
||||||
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -170,6 +236,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
|
||||||
|
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
|
||||||
|
if (typeof window === 'undefined' || !connectionsStorageKey) return []
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(connectionsStorageKey)
|
||||||
|
return stored ? JSON.parse(stored) as number[] : []
|
||||||
|
} catch { return [] }
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !connectionsStorageKey) return
|
||||||
|
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(visibleConnections))
|
||||||
|
}, [connectionsStorageKey, visibleConnections])
|
||||||
|
const toggleConnection = useCallback((id: number) => {
|
||||||
|
setVisibleConnections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||||
|
}, [])
|
||||||
|
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
|
||||||
|
|
||||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(max-width: 767px)')
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
@@ -628,6 +711,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||||
hasInspector={!!selectedPlace}
|
hasInspector={!!selectedPlace}
|
||||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||||
|
reservations={reservations}
|
||||||
|
showReservationStats={settings.route_calculation !== false}
|
||||||
|
visibleConnectionIds={visibleConnections}
|
||||||
|
onReservationClick={(rid) => {
|
||||||
|
const r = reservations.find(x => x.id === rid)
|
||||||
|
if (r) setMapTransportDetail(r)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
@@ -674,6 +764,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
|
visibleConnectionIds={visibleConnections}
|
||||||
|
onToggleConnection={toggleConnection}
|
||||||
|
externalTransportDetail={mapTransportDetail}
|
||||||
|
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
|
||||||
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
|
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
@@ -914,7 +1008,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'buchungen' && (
|
{activeTab === 'buchungen' && (
|
||||||
<div style={{ height: '100%', maxWidth: 1800, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<ReservationsPanel
|
<ReservationsPanel
|
||||||
tripId={tripId}
|
tripId={tripId}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
@@ -930,13 +1024,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'listen' && (
|
{activeTab === 'listen' && (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0', paddingBottom: 'calc(var(--bottom-nav-h) + 8px)' }}>
|
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<ListsContainer tripId={tripId} packingItems={packingItems} todoItems={todoItems} />
|
<ListsContainer tripId={tripId} packingItems={packingItems} todoItems={todoItems} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'finanzplan' && (
|
{activeTab === 'finanzplan' && (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0', paddingBottom: 'calc(var(--bottom-nav-h) + 8px)' }}>
|
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -137,6 +137,20 @@ export interface BudgetMember {
|
|||||||
paid: boolean
|
paid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReservationEndpoint {
|
||||||
|
id?: number
|
||||||
|
reservation_id?: number
|
||||||
|
role: 'from' | 'to' | 'stop'
|
||||||
|
sequence: number
|
||||||
|
name: string
|
||||||
|
code: string | null
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
timezone: string | null
|
||||||
|
local_time: string | null
|
||||||
|
local_date: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface Reservation {
|
export interface Reservation {
|
||||||
id: number
|
id: number
|
||||||
trip_id: number
|
trip_id: number
|
||||||
@@ -158,6 +172,8 @@ export interface Reservation {
|
|||||||
accommodation_id?: number | null
|
accommodation_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
|
needs_review?: number
|
||||||
|
endpoints?: ReservationEndpoint[]
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Generated
+8
-39
@@ -54,6 +54,7 @@
|
|||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
|
"tz-lookup": "^6.1.25",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1189,9 +1190,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1206,9 +1204,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1223,9 +1218,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1240,9 +1232,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1257,9 +1246,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1274,9 +1260,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1291,9 +1274,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1308,9 +1288,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1325,9 +1302,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1342,9 +1316,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1359,9 +1330,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1376,9 +1344,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1393,9 +1358,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5867,6 +5829,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tz-lookup": {
|
||||||
|
"version": "6.1.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/tz-lookup/-/tz-lookup-6.1.25.tgz",
|
||||||
|
"integrity": "sha512-fFewT9o1uDzsW1QnUU1ValqaihFnwiUiiHr1S79/fxOzKXYYvX+EHeRnpvQJ9B3Qg67wPXT6QF2Esc4pFOrvLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/undefsafe": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
|
"tz-lookup": "^6.1.25",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build server/data/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
|
||||||
|
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import https from 'node:https'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import tzLookup from 'tz-lookup'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const OUT = path.join(__dirname, '..', 'data', 'airports.json')
|
||||||
|
const SRC = 'https://davidmegginson.github.io/ourairports-data/airports.csv'
|
||||||
|
|
||||||
|
function fetchText(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, (res) => {
|
||||||
|
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`))
|
||||||
|
let data = ''
|
||||||
|
res.setEncoding('utf8')
|
||||||
|
res.on('data', chunk => { data += chunk })
|
||||||
|
res.on('end', () => resolve(data))
|
||||||
|
}).on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(text) {
|
||||||
|
const rows = []
|
||||||
|
let row = []
|
||||||
|
let cur = ''
|
||||||
|
let inQuotes = false
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i]
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (text[i + 1] === '"') { cur += '"'; i++ } else { inQuotes = false }
|
||||||
|
} else {
|
||||||
|
cur += ch
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ch === '"') inQuotes = true
|
||||||
|
else if (ch === ',') { row.push(cur); cur = '' }
|
||||||
|
else if (ch === '\n') { row.push(cur); rows.push(row); row = []; cur = '' }
|
||||||
|
else if (ch === '\r') { /* skip */ }
|
||||||
|
else cur += ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.length > 0 || row.length > 0) { row.push(cur); rows.push(row) }
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fetchText(SRC)
|
||||||
|
const rows = parseCsv(raw)
|
||||||
|
const header = rows[0]
|
||||||
|
const idx = (name) => header.indexOf(name)
|
||||||
|
const TYPE = idx('type')
|
||||||
|
const NAME = idx('name')
|
||||||
|
const LAT = idx('latitude_deg')
|
||||||
|
const LNG = idx('longitude_deg')
|
||||||
|
const COUNTRY = idx('iso_country')
|
||||||
|
const MUNICIPALITY = idx('municipality')
|
||||||
|
const SERVICE = idx('scheduled_service')
|
||||||
|
const ICAO = idx('icao_code')
|
||||||
|
const IATA = idx('iata_code')
|
||||||
|
|
||||||
|
const KEEP = new Set(['large_airport', 'medium_airport'])
|
||||||
|
const airports = []
|
||||||
|
let skippedNoTz = 0
|
||||||
|
|
||||||
|
for (let i = 1; i < rows.length; i++) {
|
||||||
|
const r = rows[i]
|
||||||
|
if (!r || r.length < header.length) continue
|
||||||
|
if (!KEEP.has(r[TYPE])) continue
|
||||||
|
const iata = r[IATA]?.trim().toUpperCase()
|
||||||
|
if (!iata || iata.length !== 3) continue
|
||||||
|
if (r[SERVICE] !== 'yes') continue
|
||||||
|
const lat = Number(r[LAT])
|
||||||
|
const lng = Number(r[LNG])
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||||
|
|
||||||
|
let tz = null
|
||||||
|
try { tz = tzLookup(lat, lng) } catch { skippedNoTz++; continue }
|
||||||
|
if (!tz) { skippedNoTz++; continue }
|
||||||
|
|
||||||
|
airports.push({
|
||||||
|
iata,
|
||||||
|
icao: r[ICAO]?.trim().toUpperCase() || null,
|
||||||
|
name: r[NAME],
|
||||||
|
city: r[MUNICIPALITY] || '',
|
||||||
|
country: r[COUNTRY] || '',
|
||||||
|
lat: Math.round(lat * 1e6) / 1e6,
|
||||||
|
lng: Math.round(lng * 1e6) / 1e6,
|
||||||
|
tz,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Map()
|
||||||
|
for (const a of airports) {
|
||||||
|
const existing = seen.get(a.iata)
|
||||||
|
if (!existing) { seen.set(a.iata, a); continue }
|
||||||
|
if (existing.icao && !a.icao) continue
|
||||||
|
if (!existing.icao && a.icao) seen.set(a.iata, a)
|
||||||
|
}
|
||||||
|
const unique = Array.from(seen.values()).sort((a, b) => a.iata.localeCompare(b.iata))
|
||||||
|
|
||||||
|
fs.writeFileSync(OUT, JSON.stringify(unique))
|
||||||
|
const size = fs.statSync(OUT).size
|
||||||
|
console.log(`Wrote ${unique.length} airports to ${OUT} (${(size / 1024).toFixed(1)} KB); skipped ${skippedNoTz} without timezone`)
|
||||||
@@ -23,6 +23,7 @@ import tagsRoutes from './routes/tags';
|
|||||||
import categoriesRoutes from './routes/categories';
|
import categoriesRoutes from './routes/categories';
|
||||||
import adminRoutes from './routes/admin';
|
import adminRoutes from './routes/admin';
|
||||||
import mapsRoutes from './routes/maps';
|
import mapsRoutes from './routes/maps';
|
||||||
|
import airportsRoutes from './routes/airports';
|
||||||
import filesRoutes from './routes/files';
|
import filesRoutes from './routes/files';
|
||||||
import reservationsRoutes from './routes/reservations';
|
import reservationsRoutes from './routes/reservations';
|
||||||
import dayNotesRoutes from './routes/dayNotes';
|
import dayNotesRoutes from './routes/dayNotes';
|
||||||
@@ -278,6 +279,7 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/integrations/memories', memoriesRoutes);
|
app.use('/api/integrations/memories', memoriesRoutes);
|
||||||
app.use('/api/photos', photoRoutes);
|
app.use('/api/photos', photoRoutes);
|
||||||
app.use('/api/maps', mapsRoutes);
|
app.use('/api/maps', mapsRoutes);
|
||||||
|
app.use('/api/airports', airportsRoutes);
|
||||||
app.use('/api/weather', weatherRoutes);
|
app.use('/api/weather', weatherRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/system-notices', systemNoticesRoutes);
|
app.use('/api/system-notices', systemNoticesRoutes);
|
||||||
|
|||||||
@@ -128,4 +128,11 @@ function isOwner(tripId: number | string, userId: number): boolean {
|
|||||||
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { backfillFlightEndpoints } = require('../services/airportService');
|
||||||
|
backfillFlightEndpoints();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Flight endpoint backfill failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
|
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
|
||||||
|
|||||||
@@ -1682,6 +1682,27 @@ function runMigrations(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at);
|
CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at);
|
||||||
`),
|
`),
|
||||||
|
// Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reservation_endpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
sequence INTEGER NOT NULL DEFAULT 0,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
timezone TEXT,
|
||||||
|
local_time TEXT,
|
||||||
|
local_date TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)');
|
||||||
|
try { db.exec('ALTER TABLE reservations ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { searchAirports, findByIata } from '../services/airportService';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/search', authenticate, (req: Request, res: Response) => {
|
||||||
|
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||||
|
if (!q) return res.json([]);
|
||||||
|
res.json(searchAirports(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:iata', authenticate, (req: Request, res: Response) => {
|
||||||
|
const airport = findByIata(req.params.iata);
|
||||||
|
if (!airport) return res.status(404).json({ error: 'Airport not found' });
|
||||||
|
res.json(airport);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -31,7 +31,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
|||||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
|
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
@@ -44,7 +44,8 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||||
title, reservation_time, reservation_end_time, location,
|
title, reservation_time, reservation_end_time, location,
|
||||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||||
status, type, accommodation_id, metadata, create_accommodation
|
status, type, accommodation_id, metadata, create_accommodation,
|
||||||
|
endpoints, needs_review
|
||||||
});
|
});
|
||||||
|
|
||||||
if (accommodationCreated) {
|
if (accommodationCreated) {
|
||||||
@@ -101,7 +102,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
|
|||||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
|
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
@@ -115,7 +116,8 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
|
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
|
||||||
title, reservation_time, reservation_end_time, location,
|
title, reservation_time, reservation_end_time, location,
|
||||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||||
status, type, accommodation_id, metadata, create_accommodation
|
status, type, accommodation_id, metadata, create_accommodation,
|
||||||
|
endpoints, needs_review
|
||||||
}, current);
|
}, current);
|
||||||
|
|
||||||
if (accommodationChanged) {
|
if (accommodationChanged) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
|
||||||
|
export interface Airport {
|
||||||
|
iata: string;
|
||||||
|
icao: string | null;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
tz: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: Airport[] | null = null;
|
||||||
|
let byIata: Map<string, Airport> | null = null;
|
||||||
|
|
||||||
|
function load(): Airport[] {
|
||||||
|
if (cache) return cache;
|
||||||
|
const file = path.join(__dirname, '..', '..', 'data', 'airports.json');
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
console.warn('[airports] airports.json missing — run `node scripts/build-airports.mjs`');
|
||||||
|
cache = [];
|
||||||
|
byIata = new Map();
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(file, 'utf8');
|
||||||
|
cache = JSON.parse(raw) as Airport[];
|
||||||
|
byIata = new Map(cache.map(a => [a.iata, a]));
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findByIata(code: string): Airport | null {
|
||||||
|
load();
|
||||||
|
return byIata!.get(code.toUpperCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchAirports(query: string, limit = 12): Airport[] {
|
||||||
|
const all = load();
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
|
||||||
|
const upper = q.toUpperCase();
|
||||||
|
if (q.length === 3) {
|
||||||
|
const exact = byIata!.get(upper);
|
||||||
|
if (exact) return [exact];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: Array<{ a: Airport; score: number }> = [];
|
||||||
|
for (const a of all) {
|
||||||
|
let score = 0;
|
||||||
|
if (a.iata === upper) score = 100;
|
||||||
|
else if (a.icao === upper) score = 90;
|
||||||
|
else if (a.iata.startsWith(upper)) score = 70;
|
||||||
|
else if (a.city.toLowerCase().startsWith(q)) score = 60;
|
||||||
|
else if (a.name.toLowerCase().startsWith(q)) score = 50;
|
||||||
|
else if (a.city.toLowerCase().includes(q)) score = 30;
|
||||||
|
else if (a.name.toLowerCase().includes(q)) score = 20;
|
||||||
|
if (score > 0) matches.push({ a, score });
|
||||||
|
}
|
||||||
|
matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata));
|
||||||
|
return matches.slice(0, limit).map(m => m.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backfillFlightEndpoints(): void {
|
||||||
|
const pending = db.prepare(`
|
||||||
|
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
|
||||||
|
FROM reservations r
|
||||||
|
WHERE r.type = 'flight'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id)
|
||||||
|
`).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[];
|
||||||
|
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
load();
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const markReview = db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?');
|
||||||
|
|
||||||
|
let filled = 0;
|
||||||
|
let flagged = 0;
|
||||||
|
for (const r of pending) {
|
||||||
|
if (!r.metadata) { markReview.run(r.id); flagged++; continue; }
|
||||||
|
let meta: any;
|
||||||
|
try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; }
|
||||||
|
|
||||||
|
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
|
||||||
|
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
|
||||||
|
|
||||||
|
if (!dep || !arr) { markReview.run(r.id); flagged++; continue; }
|
||||||
|
|
||||||
|
const split = (iso: string | null) => {
|
||||||
|
if (!iso) return { date: null as string | null, time: null as string | null };
|
||||||
|
const [date, time] = iso.split('T');
|
||||||
|
return { date: date || null, time: time ? time.slice(0, 5) : null };
|
||||||
|
};
|
||||||
|
const depParts = split(r.reservation_time);
|
||||||
|
const arrParts = split(r.reservation_end_time);
|
||||||
|
|
||||||
|
insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date);
|
||||||
|
insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date);
|
||||||
|
filled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[airports] Backfill: ${filled} filled, ${flagged} flagged for review`);
|
||||||
|
}
|
||||||
@@ -1,10 +1,59 @@
|
|||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../db/database';
|
||||||
import { Reservation } from '../types';
|
import { Reservation } from '../types';
|
||||||
|
|
||||||
|
export interface ReservationEndpoint {
|
||||||
|
id?: number;
|
||||||
|
reservation_id?: number;
|
||||||
|
role: 'from' | 'to' | 'stop';
|
||||||
|
sequence: number;
|
||||||
|
name: string;
|
||||||
|
code: string | null;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
timezone: string | null;
|
||||||
|
local_time: string | null;
|
||||||
|
local_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
|
||||||
|
|
||||||
export function verifyTripAccess(tripId: string | number, userId: number) {
|
export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||||
return canAccessTrip(tripId, userId);
|
return canAccessTrip(tripId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT e.* FROM reservation_endpoints e
|
||||||
|
JOIN reservations r ON e.reservation_id = r.id
|
||||||
|
WHERE r.trip_id = ?
|
||||||
|
ORDER BY e.reservation_id, e.sequence
|
||||||
|
`).all(tripId) as ReservationEndpoint[];
|
||||||
|
const map = new Map<number, ReservationEndpoint[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const list = map.get(r.reservation_id!) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
map.set(r.reservation_id!, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEndpoints(reservationId: number): ReservationEndpoint[] {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence'
|
||||||
|
).all(reservationId) as ReservationEndpoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
|
||||||
|
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
endpoints.forEach((e, i) => {
|
||||||
|
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export function listReservations(tripId: string | number) {
|
export function listReservations(tripId: string | number) {
|
||||||
const reservations = db.prepare(`
|
const reservations = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||||
@@ -18,7 +67,6 @@ export function listReservations(tripId: string | number) {
|
|||||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||||
`).all(tripId) as any[];
|
`).all(tripId) as any[];
|
||||||
|
|
||||||
// Attach per-day positions for multi-day reservations
|
|
||||||
const dayPositions = db.prepare(`
|
const dayPositions = db.prepare(`
|
||||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||||
FROM reservation_day_positions rdp
|
FROM reservation_day_positions rdp
|
||||||
@@ -32,15 +80,18 @@ export function listReservations(tripId: string | number) {
|
|||||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const endpointsMap = loadEndpointsByTrip(tripId);
|
||||||
|
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
r.day_positions = posMap.get(r.id) || null;
|
r.day_positions = posMap.get(r.id) || null;
|
||||||
|
r.endpoints = endpointsMap.get(r.id) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return reservations;
|
return reservations;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReservationWithJoins(id: string | number) {
|
export function getReservationWithJoins(id: string | number) {
|
||||||
return db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
|
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
|
||||||
FROM reservations r
|
FROM reservations r
|
||||||
@@ -49,7 +100,10 @@ export function getReservationWithJoins(id: string | number) {
|
|||||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||||
WHERE r.id = ?
|
WHERE r.id = ?
|
||||||
`).get(id);
|
`).get(id) as any;
|
||||||
|
if (!row) return undefined;
|
||||||
|
row.endpoints = loadEndpoints(row.id);
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateAccommodation {
|
interface CreateAccommodation {
|
||||||
@@ -76,13 +130,16 @@ interface CreateReservationData {
|
|||||||
accommodation_id?: number;
|
accommodation_id?: number;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
create_accommodation?: CreateAccommodation;
|
create_accommodation?: CreateAccommodation;
|
||||||
|
endpoints?: EndpointInput[];
|
||||||
|
needs_review?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
|
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
|
||||||
const {
|
const {
|
||||||
title, reservation_time, reservation_end_time, location,
|
title, reservation_time, reservation_end_time, location,
|
||||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||||
status, type, accommodation_id, metadata, create_accommodation
|
status, type, accommodation_id, metadata, create_accommodation,
|
||||||
|
endpoints, needs_review
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
let accommodationCreated = false;
|
let accommodationCreated = false;
|
||||||
@@ -101,8 +158,8 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
|
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
tripId,
|
tripId,
|
||||||
day_id || null,
|
day_id || null,
|
||||||
@@ -117,9 +174,14 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
|||||||
status || 'pending',
|
status || 'pending',
|
||||||
type || 'other',
|
type || 'other',
|
||||||
resolvedAccommodationId,
|
resolvedAccommodationId,
|
||||||
metadata ? JSON.stringify(metadata) : null
|
metadata ? JSON.stringify(metadata) : null,
|
||||||
|
needs_review ? 1 : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (endpoints && endpoints.length > 0) {
|
||||||
|
saveEndpoints(Number(result.lastInsertRowid), endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync check-in/out to accommodation if linked
|
// Sync check-in/out to accommodation if linked
|
||||||
if (accommodation_id && metadata) {
|
if (accommodation_id && metadata) {
|
||||||
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
||||||
@@ -187,13 +249,16 @@ interface UpdateReservationData {
|
|||||||
accommodation_id?: number;
|
accommodation_id?: number;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
create_accommodation?: CreateAccommodation;
|
create_accommodation?: CreateAccommodation;
|
||||||
|
endpoints?: EndpointInput[];
|
||||||
|
needs_review?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
|
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
|
||||||
const {
|
const {
|
||||||
title, reservation_time, reservation_end_time, location,
|
title, reservation_time, reservation_end_time, location,
|
||||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||||
status, type, accommodation_id, metadata, create_accommodation
|
status, type, accommodation_id, metadata, create_accommodation,
|
||||||
|
endpoints, needs_review
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
let accommodationChanged = false;
|
let accommodationChanged = false;
|
||||||
@@ -234,7 +299,8 @@ export function updateReservation(id: string | number, tripId: string | number,
|
|||||||
status = COALESCE(?, status),
|
status = COALESCE(?, status),
|
||||||
type = COALESCE(?, type),
|
type = COALESCE(?, type),
|
||||||
accommodation_id = ?,
|
accommodation_id = ?,
|
||||||
metadata = ?
|
metadata = ?,
|
||||||
|
needs_review = COALESCE(?, needs_review)
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
title || null,
|
title || null,
|
||||||
@@ -250,9 +316,14 @@ export function updateReservation(id: string | number, tripId: string | number,
|
|||||||
type || null,
|
type || null,
|
||||||
resolvedAccId,
|
resolvedAccId,
|
||||||
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata,
|
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata,
|
||||||
|
needs_review === undefined ? null : (needs_review ? 1 : 0),
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (endpoints !== undefined) {
|
||||||
|
saveEndpoints(Number(id), endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync check-in/out to accommodation if linked
|
// Sync check-in/out to accommodation if linked
|
||||||
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
||||||
if (resolvedAccId && resolvedMeta) {
|
if (resolvedAccId && resolvedMeta) {
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ export interface BudgetItemMember {
|
|||||||
budget_item_id?: number;
|
budget_item_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReservationEndpoint {
|
||||||
|
id: number;
|
||||||
|
reservation_id: number;
|
||||||
|
role: 'from' | 'to' | 'stop';
|
||||||
|
sequence: number;
|
||||||
|
name: string;
|
||||||
|
code: string | null;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
timezone: string | null;
|
||||||
|
local_time: string | null;
|
||||||
|
local_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Reservation {
|
export interface Reservation {
|
||||||
id: number;
|
id: number;
|
||||||
trip_id: number;
|
trip_id: number;
|
||||||
@@ -155,6 +169,8 @@ export interface Reservation {
|
|||||||
type: string;
|
type: string;
|
||||||
accommodation_id?: number | null;
|
accommodation_id?: number | null;
|
||||||
metadata?: string | null;
|
metadata?: string | null;
|
||||||
|
needs_review?: number;
|
||||||
|
endpoints?: ReservationEndpoint[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
day_number?: number;
|
day_number?: number;
|
||||||
place_name?: string;
|
place_name?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user