Compare commits

..

3 Commits

Author SHA1 Message Date
Maurice abe6f12890 fix(admin): use TREK's CustomSelect for the invite trip picker
The trip binding dropdown in the admin create-invite dialog was a native
<select>; swap it for the shared CustomSelect so it matches the rest of the UI
(and searchable once there are many trips).
2026-07-02 15:26:48 +02:00
Maurice d546e79500 fix(join): extract JoinTripPage state into a useJoinTrip hook
The page container held useState/useEffect directly, tripping the CI
page-pattern check. Move the token preview + accept logic into a co-located
useJoinTrip() hook; the page is now a thin presentational shell.
2026-07-02 13:47:36 +02:00
Maurice 80e7c2b3bb feat(trips): trip invite links + optional trip binding on admin invites
Trip invite links (#1143): each trip can have one rotating invite link in its
Share panel. An existing, logged-in user who opens /join/<token> is added to
the trip as a member; an anonymous visitor is sent to the login page and
returned to the invite afterwards — there is no registration from this link.
Reading, rotating or disabling the link all require the share_manage permission.

Admin invite trip binding (#1402): the admin create-invite dialog can now bind
a registration invite to a trip. Someone who registers via that link is
auto-added to the trip as a member (password and OIDC paths), inside the same
atomic step that consumes the invite.

Adds a trip_invite_tokens table and a nullable invite_tokens.trip_id, a shared
owner-safe/idempotent add-by-id helper, the manage + join endpoints, the
JoinTripPage and Share-panel section, the admin trip picker, and the new i18n
keys across every locale. Wiki updated.

Discussion #1143
2026-07-02 13:21:56 +02:00
23 changed files with 5 additions and 125 deletions
+5 -59
View File
@@ -131,8 +131,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const [settlement, setSettlement] = useState<SettlementData | null>(null)
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
const [search, setSearch] = useState('')
const [catFilter, setCatFilter] = useState('') // '' = all categories
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<BudgetItem | null>(null)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
@@ -194,27 +192,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
let list = budgetItems.slice()
if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0)
if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0)
// catMeta normalises legacy/free-text categories to the fixed keys, so the
// filter matches rows saved before the category rework too.
if (catFilter) list = list.filter(e => catMeta(e.category).key === catFilter)
if (dayFilter) list = list.filter(e => (e.expense_date || '') === dayFilter)
const q = search.trim().toLowerCase()
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
return list
}, [budgetItems, filter, search, catFilter, dayFilter, me])
}, [budgetItems, filter, search, me])
// Settlements ("payments") shown inline in the ledger. They have no name, so a
// text search hides them; they're excluded from the "owed" expense filter and,
// under "mine", only show transfers I'm part of.
const filteredSettlements = useMemo(() => {
// Payments carry no name or category, so a text/category filter hides them.
if (search.trim() || catFilter) return []
if (search.trim()) return []
if (filter === 'owed') return []
let list = settlement?.settlements || []
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
return list
}, [settlement, filter, search, catFilter, dayFilter, me])
}, [settlement, filter, search, me])
const dayGroups = useMemo(() => {
const entries: LedgerEntry[] = [
@@ -237,20 +229,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return groups
}, [filtered, filteredSettlements, locale, t])
// ── filter dropdown options (category + single day) ──────────────────────
const categoryOptions = useMemo(() => [
{ value: '', label: t('costs.filter.allCategories') },
...COST_CATEGORY_LIST.map(c => ({ value: c.key, label: t(c.labelKey), icon: <c.Icon size={14} style={{ color: c.color }} /> })),
], [t])
const dayOptions = useMemo(() => {
const days = Array.from(new Set(budgetItems.map(e => e.expense_date).filter(Boolean) as string[])).sort((a, b) => b.localeCompare(a))
const fmtDay = (d: string) => {
try { return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return d }
}
return [{ value: '', label: t('costs.filter.allDays') }, ...days.map(d => ({ value: d, label: fmtDay(d) }))]
}, [budgetItems, locale, t])
// ── settle actions ──────────────────────────────────────────────────────
const settleFlow = async (fromId: number, toId: number, amount: number) => {
try {
@@ -305,29 +283,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
}
// ── category + day filter controls (shared by both layouts) ──────────────
const filterControls = (
<>
<CustomSelect value={catFilter} onChange={v => setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ minWidth: 148 }} />
<CustomSelect value={dayFilter} onChange={v => setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ minWidth: 140 }} />
</>
)
// A prominent summary shown when a single day is selected: the day + its total.
const dayFilterTotal = dayFilter ? filtered.reduce((a, e) => a + baseTotal(e), 0) : 0
const dayFilterLabel = dayFilter
? (() => { try { return new Date(dayFilter + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) } catch { return dayFilter } })()
: ''
const dayBanner = dayFilter ? (
<div className={cardCls} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, borderRadius: 16, padding: '16px 20px', marginBottom: 16 }}>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.01em' }}>{dayFilterLabel}</div>
<div className="text-content-muted" style={{ marginTop: 3, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.expensesCount', { count: filtered.length })}</div>
</div>
<div className="text-content" style={{ fontSize: 'calc(26px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: '-0.02em', whiteSpace: 'nowrap' }}>{bigMoney(dayFilterTotal, 15, 'var(--text-muted)')}</div>
</div>
) : null
return (
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
{isMobile ? <MobileBody /> : (
@@ -393,13 +348,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<h3 className="text-content" style={{ fontSize: 'calc(24px * var(--fs-scale-title, 1))', fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
{t('costs.expenses')}
</h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
<Search size={15} className="text-content-faint" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', width: 150, fontFamily: 'inherit' }} />
</div>
{filterControls}
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
@@ -412,7 +366,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
</div>
{dayBanner}
{dayGroups.length === 0 ? (
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
{search ? t('costs.noMatch') : t('costs.emptyText')}
@@ -421,11 +374,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return (
<div key={g.day} style={{ marginBottom: 22 }}>
{!dayFilter && (
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
@@ -603,18 +554,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect value={catFilter} onChange={v => setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ flex: 1, minWidth: 0 }} />
<CustomSelect value={dayFilter} onChange={v => setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ flex: 1, minWidth: 0 }} />
</div>
{dayBanner}
{dayGroups.length === 0
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
: dayGroups.map(g => {
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
return (
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!dayFilter && <div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>}
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'كل الفئات',
'costs.filter.allDays': 'كل الأيام',
'costs.expensesCount': '{count} مصروف',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Todas as categorias',
'costs.filter.allDays': 'Todos os dias',
'costs.expensesCount': '{count} despesas',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Všechny kategorie',
'costs.filter.allDays': 'Všechny dny',
'costs.expensesCount': '{count} výdajů',
};
export default budget;
-3
View File
@@ -58,9 +58,6 @@ const budget: TranslationStrings = {
'costs.filter.all': 'Alle',
'costs.filter.mine': 'Von mir bezahlt',
'costs.filter.owed': 'Mir geschuldet',
'costs.filter.allCategories': 'Alle Kategorien',
'costs.filter.allDays': 'Alle Tage',
'costs.expensesCount': '{count} Ausgaben',
'costs.addExpense': 'Ausgabe hinzufügen',
'costs.editExpense': 'Ausgabe bearbeiten',
'costs.noMatch': 'Keine Ausgaben passen zur Suche.',
-3
View File
@@ -58,9 +58,6 @@ const budget: TranslationStrings = {
'costs.filter.all': 'All',
'costs.filter.mine': 'Paid by me',
'costs.filter.owed': "I'm owed",
'costs.filter.allCategories': 'All categories',
'costs.filter.allDays': 'All days',
'costs.expensesCount': '{count} expenses',
'costs.addExpense': 'Add expense',
'costs.editExpense': 'Edit expense',
'costs.noMatch': 'No expenses match your search.',
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Todas las categorías',
'costs.filter.allDays': 'Todos los días',
'costs.expensesCount': '{count} gastos',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Toutes les catégories',
'costs.filter.allDays': 'Tous les jours',
'costs.expensesCount': '{count} dépenses',
};
export default budget;
-3
View File
@@ -121,8 +121,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Όλες οι κατηγορίες',
'costs.filter.allDays': 'Όλες οι ημέρες',
'costs.expensesCount': '{count} έξοδα',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Összes kategória',
'costs.filter.allDays': 'Összes nap',
'costs.expensesCount': '{count} kiadás',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Semua kategori',
'costs.filter.allDays': 'Semua hari',
'costs.expensesCount': '{count} pengeluaran',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Tutte le categorie',
'costs.filter.allDays': 'Tutti i giorni',
'costs.expensesCount': '{count} spese',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'すべてのカテゴリー',
'costs.filter.allDays': 'すべての日',
'costs.expensesCount': '{count}件の支出',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': '모든 카테고리',
'costs.filter.allDays': '모든 날짜',
'costs.expensesCount': '지출 {count}건',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Alle categorieën',
'costs.filter.allDays': 'Alle dagen',
'costs.expensesCount': '{count} uitgaven',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Wszystkie kategorie',
'costs.filter.allDays': 'Wszystkie dni',
'costs.expensesCount': '{count} wydatków',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Все категории',
'costs.filter.allDays': 'Все дни',
'costs.expensesCount': 'Расходов: {count}',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Alla kategorier',
'costs.filter.allDays': 'Alla dagar',
'costs.expensesCount': '{count} utgifter',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Tüm kategoriler',
'costs.filter.allDays': 'Tüm günler',
'costs.expensesCount': '{count} harcama',
};
export default budget;
-3
View File
@@ -120,8 +120,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': 'Усі категорії',
'costs.filter.allDays': 'Усі дні',
'costs.expensesCount': '{count} витрат',
};
export default budget;
-3
View File
@@ -120,9 +120,6 @@ const budget: TranslationStrings = {
'costs.splitEqually': 'Chia đều',
'costs.splitCustom': 'Tùy chỉnh',
'costs.splitTicket': 'Hóa đơn',
'costs.filter.allCategories': 'Tất cả danh mục',
'costs.filter.allDays': 'Tất cả các ngày',
'costs.expensesCount': '{count} chi phí',
};
export default budget;
-3
View File
@@ -119,8 +119,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': '所有類別',
'costs.filter.allDays': '所有日期',
'costs.expensesCount': '{count} 筆支出',
};
export default budget;
-3
View File
@@ -119,8 +119,5 @@ const budget: TranslationStrings = {
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
'costs.filter.allCategories': '所有类别',
'costs.filter.allDays': '所有日期',
'costs.expensesCount': '{count} 笔支出',
};
export default budget;