From 06ecc5135cc4fba59a92a400871a0383ca5d1b75 Mon Sep 17 00:00:00 2001 From: dersimoezdag Date: Sun, 6 Sep 2026 15:48:35 +0200 Subject: [PATCH] feat(budget): attach receipts and invoices to expenses --- client/src/components/Budget/CostsPanel.tsx | 194 ++++++++- .../components/Budget/ReceiptPreviewModal.tsx | 394 ++++++++++++++++++ .../mobile/screens/trip/sheets/MCostSheet.tsx | 152 ++++++- .../mobile/screens/trip/tabs/MCostsTab.tsx | 38 +- client/src/types.ts | 2 + server/src/db/migrations.ts | 20 + server/src/nest/budget/budget.service.ts | 153 ++++++- server/src/nest/files/files.controller.ts | 20 +- server/src/nest/files/files.service.ts | 33 +- server/src/types.ts | 10 + .../tests/unit/nest/budget.service.db.test.ts | 110 ++++- .../tests/unit/nest/files.controller.test.ts | 44 ++ shared/src/budget/budget.schema.ts | 16 +- shared/src/file/file.schema.ts | 3 + shared/src/i18n/ar/budget.ts | 6 + shared/src/i18n/br/budget.ts | 6 + shared/src/i18n/ca/budget.ts | 6 + shared/src/i18n/cs/budget.ts | 6 + shared/src/i18n/de/budget.ts | 6 + shared/src/i18n/en/budget.ts | 6 + shared/src/i18n/es/budget.ts | 6 + shared/src/i18n/fr/budget.ts | 6 + shared/src/i18n/gr/budget.ts | 6 + shared/src/i18n/hu/budget.ts | 6 + shared/src/i18n/id/budget.ts | 6 + shared/src/i18n/it/budget.ts | 6 + shared/src/i18n/ja/budget.ts | 6 + shared/src/i18n/ko/budget.ts | 6 + shared/src/i18n/nl/budget.ts | 6 + shared/src/i18n/pl/budget.ts | 6 + shared/src/i18n/ru/budget.ts | 6 + shared/src/i18n/sv/budget.ts | 6 + shared/src/i18n/tr/budget.ts | 6 + shared/src/i18n/uk/budget.ts | 6 + shared/src/i18n/vi/budget.ts | 6 + shared/src/i18n/zh-TW/budget.ts | 6 + shared/src/i18n/zh/budget.ts | 6 + 37 files changed, 1293 insertions(+), 34 deletions(-) create mode 100644 client/src/components/Budget/ReceiptPreviewModal.tsx diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index e2f548757..820651cb0 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -1,17 +1,17 @@ import { Fragment, useState, useEffect, useMemo, useCallback } from 'react' import { useSearchParams } from 'react-router' -import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react' +import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' -import { budgetApi } from '../../api/client' +import { budgetApi, filesApi } from '../../api/client' import { useExchangeRates } from '../../hooks/useExchangeRates' import { useIsMobile } from '../../hooks/useIsMobile' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' -import { downloadBlob } from '../../utils/fileDownload' +import { downloadBlob, openFile } from '../../utils/fileDownload' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' @@ -19,7 +19,8 @@ import { localToday } from '../Planner/today' import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' -import type { BudgetItem } from '../../types' +import { ReceiptPreviewModal } from './ReceiptPreviewModal' +import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' import { NumericInput } from '../shared/NumericInput' @@ -84,6 +85,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) + const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null) // One note open at a time: two expanded rows next to each other read as a mess, // and the point of the collapse is that the list stays scannable. const [expandedNoteId, setExpandedNoteId] = useState(null) @@ -492,6 +494,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> )} + {previewReceipts && ( + setPreviewReceipts(null)} + /> + )} +