feat(ui): unified toolbar design + redesigned budget widgets + polish

Trip planner now has a consistent rounded toolbar across bookings, lists,
budget and files. Each panel shows title, inline filter pills (with
counts where useful) and an accent action button on the right. Moved
per-tab controls into the toolbar — lists import, todo add, budget
currency/add-category, files trash/filters — and dropped the redundant
in-panel headers.

Budget sidebar redesigned: total-budget card with indigo-ringed avatars
and coloured split bar; settlement flows as paired avatar cards;
by-category donut rebuilt in SVG with per-category gradients. Both cards
now follow dark/light mode via a widgetTheme helper.

Todo: add-new-task is a portalled modal on desktop, the add-task input
bar is gone; new SORT BY section in the sidebar; inline category
creation in the task editor.

Reservations: pending / confirmed sections remember their collapsed
state per trip (localStorage).

Misc: per-trip connections toggle moved into the day-plan sidebar,
booking endpoints fixed to show on map for trains/cruises/cars as well,
label localStorage persistence, RESMODAL test updated to the new
airport-select flow.

i18n: the new booking / map / todo / budget strings are translated into
all 15 supported languages.
This commit is contained in:
Maurice
2026-04-17 23:25:38 +02:00
parent 5e9c8d2c43
commit 530550455d
22 changed files with 894 additions and 316 deletions
+380 -134
View File
@@ -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>
+77 -21
View File
@@ -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' }} />
@@ -729,9 +729,10 @@ function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
interface PackingListPanelProps { interface PackingListPanelProps {
tripId: number tripId: number
items: PackingItem[] items: PackingItem[]
openImportSignal?: number
} }
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) { export default function PackingListPanel({ tripId, items, openImportSignal = 0 }: 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 +897,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,14 +1008,13 @@ 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: '0 0 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14 }}>
<div> {items.length > 0 ? (
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2> <p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}> {t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p> </p>
</div> ) : <span />}
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
{canEdit && abgehakt > 0 && ( {canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{ <button onClick={handleClearChecked} style={{
@@ -1017,15 +1025,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 +1150,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 +1164,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' }} />
@@ -517,35 +517,37 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{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>
{/* From / To endpoints for transport bookings */} {/* From / To endpoints for transport bookings */}
@@ -631,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)} />
@@ -645,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'
@@ -382,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={{
@@ -568,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>
)} )}
+89 -55
View File
@@ -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,19 +399,28 @@ 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) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}> className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 300, 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: 300, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
<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' } } }}>
<NewTaskPane <NewTaskPane
@@ -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>
+15 -2
View File
@@ -1017,6 +1017,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': 'المقعد',
@@ -1035,7 +1044,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': 'جولة',
@@ -1799,7 +1808,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': 'مهمة جديدة',
+15 -2
View File
@@ -986,6 +986,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',
@@ -1004,7 +1013,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',
@@ -1748,7 +1757,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',
+15 -2
View File
@@ -1015,6 +1015,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',
@@ -1033,7 +1042,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',
@@ -1753,7 +1762,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',
+6 -2
View File
@@ -1044,7 +1044,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',
@@ -1765,7 +1765,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',
+6 -2
View File
@@ -1097,7 +1097,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',
@@ -1827,7 +1827,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',
+15 -2
View File
@@ -990,7 +990,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',
@@ -1618,6 +1618,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',
@@ -1758,7 +1767,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',
+15 -2
View File
@@ -1013,6 +1013,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',
@@ -1031,7 +1040,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',
@@ -1752,7 +1761,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',
+15 -2
View File
@@ -1015,6 +1015,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',
@@ -1033,7 +1042,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',
@@ -1750,7 +1759,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',
+15 -2
View File
@@ -1070,6 +1070,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',
@@ -1088,7 +1097,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',
@@ -1818,7 +1827,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',
+15 -2
View File
@@ -1014,6 +1014,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',
@@ -1032,7 +1041,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',
@@ -1753,7 +1762,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à',
+15 -2
View File
@@ -1013,6 +1013,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',
@@ -1031,7 +1040,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',
@@ -1752,7 +1761,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',
+14 -1
View File
@@ -989,6 +989,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',
@@ -1801,7 +1810,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',
+15 -2
View File
@@ -1013,6 +1013,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': 'Место',
@@ -1031,7 +1040,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': 'Экскурсия',
@@ -1749,7 +1758,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': 'Новая задача',
+15 -2
View File
@@ -1013,6 +1013,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': '座位',
@@ -1031,7 +1040,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': '旅游团',
@@ -1749,7 +1758,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': '新任务',
+15 -2
View File
@@ -1069,6 +1069,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': '座位',
@@ -1087,7 +1096,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': '旅遊團',
@@ -1766,7 +1775,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': '新任務',
+89 -23
View File
@@ -35,36 +35,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} />}
{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>
) )
} }
@@ -940,7 +1006,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}
@@ -956,13 +1022,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>
)} )}