From bd2d91845b7511b74c2d9bb2bb34f6df39c38a9e Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 2 Jul 2026 15:44:06 +0200 Subject: [PATCH] feat(costs): filter expenses by category and by a single day Adds two filter dropdowns next to the Search Expenses field (height-matched to it): one filters by expense category, the other narrows to a single day. Selecting a day shows a prominent summary banner with that day's total, and hides the now-redundant per-day header. Both filters work on the desktop and mobile layouts and compose with the existing search + all/mine/owed filters. New i18n keys (costs.filter.allCategories / allDays, costs.expensesCount) across every locale. --- client/src/components/Budget/CostsPanel.tsx | 64 +++++++++++++++++++-- shared/src/i18n/ar/budget.ts | 3 + shared/src/i18n/br/budget.ts | 3 + shared/src/i18n/cs/budget.ts | 3 + shared/src/i18n/de/budget.ts | 3 + shared/src/i18n/en/budget.ts | 3 + shared/src/i18n/es/budget.ts | 3 + shared/src/i18n/fr/budget.ts | 3 + shared/src/i18n/gr/budget.ts | 3 + shared/src/i18n/hu/budget.ts | 3 + shared/src/i18n/id/budget.ts | 3 + shared/src/i18n/it/budget.ts | 3 + shared/src/i18n/ja/budget.ts | 3 + shared/src/i18n/ko/budget.ts | 3 + shared/src/i18n/nl/budget.ts | 3 + shared/src/i18n/pl/budget.ts | 3 + shared/src/i18n/ru/budget.ts | 3 + shared/src/i18n/sv/budget.ts | 3 + shared/src/i18n/tr/budget.ts | 3 + shared/src/i18n/uk/budget.ts | 3 + shared/src/i18n/vi/budget.ts | 3 + shared/src/i18n/zh-TW/budget.ts | 3 + shared/src/i18n/zh/budget.ts | 3 + 23 files changed, 125 insertions(+), 5 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index d51a4f0bb..2a35aba18 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -131,6 +131,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const [settlement, setSettlement] = useState(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(null) const [editingSettlement, setEditingSettlement] = useState(null) @@ -192,21 +194,27 @@ 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, me]) + }, [budgetItems, filter, search, catFilter, dayFilter, 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(() => { - if (search.trim()) return [] + // Payments carry no name or category, so a text/category filter hides them. + if (search.trim() || catFilter) 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, me]) + }, [settlement, filter, search, catFilter, dayFilter, me]) const dayGroups = useMemo(() => { const entries: LedgerEntry[] = [ @@ -229,6 +237,20 @@ 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: })), + ], [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 { @@ -283,6 +305,29 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return <>{parts.map((p, i) => {p.value})} } + // ── category + day filter controls (shared by both layouts) ────────────── + const filterControls = ( + <> + setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ minWidth: 148 }} /> + 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 ? ( +
+
+
{dayFilterLabel}
+
{t('costs.expensesCount', { count: filtered.length })}
+
+
{bigMoney(dayFilterTotal, 15, 'var(--text-muted)')}
+
+ ) : null + return (
{isMobile ? : ( @@ -348,12 +393,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps

{t('costs.expenses')}

-
+
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' }} />
+ {filterControls}
{(['all', 'mine', 'owed'] as const).map(f => (
+ {dayBanner} {dayGroups.length === 0 ? (
{search ? t('costs.noMatch') : t('costs.emptyText')} @@ -374,9 +421,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
+ {!dayFilter && (
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
+ )}
{g.entries.map(en => en.kind === 'expense' ? @@ -554,13 +603,18 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ))}
+
+ setCatFilter(String(v))} options={categoryOptions} size="sm" style={{ flex: 1, minWidth: 0 }} /> + setDayFilter(String(v))} options={dayOptions} size="sm" searchable style={{ flex: 1, minWidth: 0 }} /> +
+ {dayBanner} {dayGroups.length === 0 ?
{search ? t('costs.noMatch') : t('costs.emptyText')}
: dayGroups.map(g => { const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0) return (
-
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
+ {!dayFilter &&
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
}
{g.entries.map(en => en.kind === 'expense' ? : )}
diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index a42b0acc8..5820661bd 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -120,5 +120,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': 'كل الفئات', + 'costs.filter.allDays': 'كل الأيام', + 'costs.expensesCount': '{count} مصروف', }; export default budget; diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index acc661a9f..4926a5750 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index f746004e6..14cf35a7b 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index 3471efd13..0cac170b4 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -58,6 +58,9 @@ 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.', diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index f74cbec86..b4513bf6d 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -58,6 +58,9 @@ 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.', diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 2364dbbdf..e2fe4c672 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index 633152177..f6110abf2 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index 23ba4e452..aa4396e1d 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -121,5 +121,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': 'Όλες οι κατηγορίες', + 'costs.filter.allDays': 'Όλες οι ημέρες', + 'costs.expensesCount': '{count} έξοδα', }; export default budget; diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index a620bd48f..3152ff8f2 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index 26de2d551..257477df3 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index 6b515c76a..b94aedcf5 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index 11172bc28..b1e953fea 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -120,5 +120,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': 'すべてのカテゴリー', + 'costs.filter.allDays': 'すべての日', + 'costs.expensesCount': '{count}件の支出', }; export default budget; diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index 3cbae9224..8a8d9d67d 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -120,5 +120,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': '모든 카테고리', + 'costs.filter.allDays': '모든 날짜', + 'costs.expensesCount': '지출 {count}건', }; export default budget; diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index 398599fe2..e1cd11b81 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index d8a1b9052..995bce5f7 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index 037aa4bf7..ddf45fdea 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -120,5 +120,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': 'Все категории', + 'costs.filter.allDays': 'Все дни', + 'costs.expensesCount': 'Расходов: {count}', }; export default budget; diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index bbc51b349..a287402b0 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index 92f140589..0128e376b 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -120,5 +120,8 @@ 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; diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 8209ab418..76f76255f 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -120,5 +120,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': 'Усі категорії', + 'costs.filter.allDays': 'Усі дні', + 'costs.expensesCount': '{count} витрат', }; export default budget; diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index 8b4b7de8c..1964a9097 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -120,6 +120,9 @@ 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; diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index 691b2159c..4ca2b5223 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -119,5 +119,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': '所有類別', + 'costs.filter.allDays': '所有日期', + 'costs.expensesCount': '{count} 筆支出', }; export default budget; diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index 6fd115035..cb21c278e 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -119,5 +119,8 @@ const budget: TranslationStrings = { 'costs.splitEqually': "Equally", 'costs.splitCustom': "Custom", 'costs.splitTicket': "Ticket", + 'costs.filter.allCategories': '所有类别', + 'costs.filter.allDays': '所有日期', + 'costs.expensesCount': '{count} 笔支出', }; export default budget;