feat(budget): attach receipts and invoices to expenses

This commit is contained in:
dersimoezdag
2026-09-06 15:48:35 +02:00
committed by Maurice
parent b2a196d1e7
commit 06ecc5135c
37 changed files with 1293 additions and 34 deletions
+188 -6
View File
@@ -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<BudgetItem | null>(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<number | null>(null)
@@ -492,6 +494,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
)}
{previewReceipts && (
<ReceiptPreviewModal
receipts={previewReceipts.receipts}
initialIndex={previewReceipts.initialIndex}
onClose={() => setPreviewReceipts(null)}
/>
)}
<style>{`
.costs-root {
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
@@ -762,6 +772,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.unfinished')}
</span>
)}
{(e.receipts || []).length > 0 && (
<button
type="button"
onClick={(ev) => {
ev.stopPropagation()
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
}}
title={t('costs.viewReceipt')}
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 8px',
borderRadius: 999,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 600,
flexShrink: 0,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<Receipt size={12} className="text-content-muted" />
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
</button>
)}
</div>
{cur !== base && (
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
@@ -1122,6 +1158,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return m
})
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
if (!files || files.length === 0) return
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
}
const handleRemoveReceipt = (receiptId: number) => {
setReceipts(prev => prev.filter(r => r.id !== receiptId))
}
const handleRemovePendingReceipt = (index: number) => {
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
}
const [saving, setSaving] = useState(false)
const isTicketMode = splitMode === 'ticket'
@@ -1318,8 +1372,53 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
// Upload any pending receipt files
const uploadedFileIds: number[] = []
try {
for (const f of pendingReceiptFiles) {
const fd = new FormData()
fd.append('file', f)
if (editing) {
fd.append('budget_item_id', String(editing.id))
}
const res = await filesApi.upload(tripId, fd)
if (res?.file?.id) {
uploadedFileIds.push(res.file.id)
}
}
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
let createdOrUpdated: BudgetItem
if (editing) {
try {
createdOrUpdated = await updateBudgetItem(tripId, editing.id, {
...data,
receipt_file_ids: [...receipts.map(r => r.id), ...uploadedFileIds],
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
} else {
try {
createdOrUpdated = await addBudgetItem(tripId, {
...data,
receipt_file_ids: uploadedFileIds,
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
}
onSaved()
} catch {
toast.error(t('common.unknownError'))
@@ -1633,8 +1732,91 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
</div>
<div className={panelCls}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
<input
type="file"
multiple
accept="image/*,application/pdf"
style={{ display: 'none' }}
onChange={e => {
handleReceiptFileSelect(e.target.files)
e.target.value = ''
}}
/>
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
<Plus size={13} /> {t('costs.attachReceipt')}
</span>
</label>
</div>
{uploadingReceipt && (
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
{t('common.saving')}...
</div>
)}
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
{t('costs.noReceipts')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{receipts.map((r, rIdx) => (
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
<button
type="button"
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
className="text-content hover:underline"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
>
<Receipt size={14} className="text-content-faint flex-shrink-0" />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
</button>
<button
type="button"
onClick={() => handleRemoveReceipt(r.id)}
title={t('costs.deleteReceipt')}
className="text-content-muted hover:text-red-500 transition-colors"
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
>
<Trash2 size={14} />
</button>
</div>
))}
{pendingReceiptFiles.map((file, idx) => (
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
</div>
<button
type="button"
onClick={() => handleRemovePendingReceipt(idx)}
title={t('costs.deleteReceipt')}
className="text-content-muted hover:text-red-500 transition-colors"
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{modalPreviewReceipts && (
<ReceiptPreviewModal
receipts={modalPreviewReceipts.receipts}
initialIndex={modalPreviewReceipts.initialIndex}
onClose={() => setModalPreviewReceipts(null)}
/>
)}
</Modal>
)
}
@@ -0,0 +1,394 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { getAuthUrl } from '../../api/authUrl'
import { openFile as openFileUrl } from '../../utils/fileDownload'
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
import type { BudgetItemReceipt } from '../../types'
interface ReceiptPreviewModalProps {
receipts: BudgetItemReceipt[]
initialIndex?: number
onClose: () => void
}
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
const { t } = useTranslation()
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
const [signedUrl, setSignedUrl] = useState('')
const [loading, setLoading] = useState(false)
const current = receipts[index]
const isImg = isImage(current?.mime_type)
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
useEffect(() => {
if (!current) return
let cancelled = false
setLoading(true)
setSignedUrl('')
getAuthUrl(current.url, 'download')
.then(url => {
if (!cancelled) {
setSignedUrl(url)
setLoading(false)
}
})
.catch(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [current?.url])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [index, receipts.length, onClose])
if (!current) return null
const handleOpenTab = () => {
openFileUrl(current.url, current.original_name).catch(() => {})
}
const handleDownload = () => {
triggerDownload(current.url, current.original_name)
}
return createPortal(
<div
role="presentation"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
onClick={e => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
role="presentation"
style={{
width: '100%',
maxWidth: 950,
height: '94vh',
background: 'var(--bg-card)',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}
onClick={e => e.stopPropagation()}
>
{/* Header - identical to PdfPreviewModal */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
borderBottom: '1px solid var(--border-primary)',
flexShrink: 0,
}}
>
<span
style={{
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{current.original_name}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
type="button"
onClick={handleOpenTab}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
color: 'var(--text-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
type="button"
onClick={handleDownload}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
color: 'var(--text-muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button
type="button"
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-faint)',
display: 'flex',
padding: 4,
borderRadius: 6,
transition: 'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<X size={18} />
</button>
</div>
</div>
{/* Tab strip if multiple receipts */}
{receipts.length > 1 && (
<div
className="bg-surface-secondary"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
borderBottom: '1px solid var(--border-primary)',
overflowX: 'auto',
flexShrink: 0,
}}
>
{receipts.map((r, i) => {
const active = i === index
const isRImg = isImage(r.mime_type)
const Icon = isRImg ? FileImage : FileText
return (
<button
key={r.id}
type="button"
onClick={() => setIndex(i)}
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '5px 11px',
borderRadius: 8,
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
background: active ? 'var(--bg-card)' : 'none',
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
>
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{r.original_name}
</span>
<span
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
opacity: active ? 0.8 : 0.5,
marginLeft: 2,
}}
>
#{i + 1}
</span>
</button>
)
})}
</div>
)}
{/* Content Body */}
<div
style={{
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: isImg ? '#0f141c' : 'var(--bg-card)',
minHeight: 0,
}}
>
{receipts.length > 1 && index > 0 && (
<button
type="button"
onClick={() => setIndex(i => i - 1)}
style={{
position: 'absolute',
left: 14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<ChevronLeft size={20} />
</button>
)}
{receipts.length > 1 && index < receipts.length - 1 && (
<button
type="button"
onClick={() => setIndex(i => i + 1)}
style={{
position: 'absolute',
right: 14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
width: 36,
height: 36,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
}}
>
<ChevronRight size={20} />
</button>
)}
{isPdf ? (
<object
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
type="application/pdf"
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
title={current.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button
type="button"
onClick={handleOpenTab}
style={{
color: 'var(--text-primary)',
textDecoration: 'underline',
background: 'none',
border: 'none',
cursor: 'pointer',
font: 'inherit',
}}
>
{t('files.downloadPdf')}
</button>
</p>
</object>
) : isImg ? (
<div
style={{
padding: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
}}
>
{signedUrl ? (
<img
src={signedUrl}
alt={current.original_name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 6,
display: 'block',
}}
/>
) : (
<div className="text-content-faint" style={{ padding: 48 }}>
{loading ? `${t('common.loading')}...` : current.original_name}
</div>
)}
</div>
) : (
<div style={{ padding: 40, textAlign: 'center' }}>
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
{current.original_name}
</p>
<button
type="button"
onClick={handleDownload}
className="bg-primary text-white"
style={{
padding: '9px 18px',
borderRadius: 10,
border: 0,
cursor: 'pointer',
fontWeight: 600,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
}}
>
{t('files.download')}
</button>
</div>
)}
</div>
</div>
</div>,
document.body
)
}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Plus, Trash2, Wallet } from 'lucide-react'
import { Check, ChevronDown, Plus, Trash2, Wallet, Receipt, Paperclip } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import CustomSelect from '../../../../components/shared/CustomSelect'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
@@ -10,6 +10,8 @@ import { useToast } from '../../../../components/shared/Toast'
import { useTripStore } from '../../../../store/tripStore'
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters'
import { openFile } from '../../../../utils/fileDownload'
import { filesApi } from '../../../../api/client'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { localToday } from '../../../../components/Planner/today'
@@ -18,7 +20,8 @@ import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers'
import GuestBadge from '../../../../components/shared/GuestBadge'
import type { TripMember } from '../../../../components/Budget/BudgetPanelMemberChips'
import type { BudgetItem } from '../../../../types'
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
import type { BudgetItem, BudgetItemReceipt } from '../../../../types'
export interface MCostSheetProps {
tripId: number
@@ -123,6 +126,24 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
return m
})
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
if (!files || files.length === 0) return
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
}
const handleRemoveReceipt = (receiptId: number) => {
setReceipts(prev => prev.filter(r => r.id !== receiptId))
}
const handleRemovePendingReceipt = (index: number) => {
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
}
const [saving, setSaving] = useState(false)
const [deleteArmed, setDeleteArmed] = useState(false)
@@ -270,8 +291,52 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
else await addBudgetItem(tripId, data)
// Upload any pending receipt files
const uploadedFileIds: number[] = []
try {
for (const f of pendingReceiptFiles) {
const fd = new FormData()
fd.append('file', f)
if (editing) {
fd.append('budget_item_id', String(editing.id))
}
const res = await filesApi.upload(tripId, fd)
if (res?.file?.id) {
uploadedFileIds.push(res.file.id)
}
}
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
if (editing) {
try {
await updateBudgetItem(tripId, editing.id, {
...data,
receipt_file_ids: [...receipts.map(r => r.id), ...uploadedFileIds],
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
} else {
try {
await addBudgetItem(tripId, {
...data,
receipt_file_ids: uploadedFileIds,
})
} catch (err) {
for (const fid of uploadedFileIds) {
try { await filesApi.permanentDelete(tripId, fid) } catch {}
}
throw err
}
}
onSaved()
} catch {
toast.error(t('common.unknownError'))
@@ -641,6 +706,77 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
placeholder={t('costs.notePlaceholder')}
className={FIELD_AREA_CLS}
/>
{/* RECEIPTS */}
<div className="mb-[6px] mt-4 flex items-center justify-between">
<Eyebrow className="uppercase">{t('costs.receiptsTitle') || t('costs.receipts')}</Eyebrow>
<label className="flex cursor-pointer items-center gap-1 text-[0.75rem] font-semibold text-m-ink">
<input
type="file"
multiple
accept="image/*,application/pdf"
className="hidden"
onChange={e => {
handleReceiptFileSelect(e.target.files)
e.target.value = ''
}}
/>
<span className="flex items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-2.5 py-1 text-m-muted">
<Plus size={12} /> {t('costs.attachReceipt')}
</span>
</label>
</div>
{uploadingReceipt && (
<div className="mb-2 text-[0.75rem] text-m-faint">
{t('common.saving')}...
</div>
)}
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
<div className="py-1 text-[0.75rem] text-m-faint">
{t('costs.noReceipts')}
</div>
) : (
<div className="flex flex-col gap-1.5 pb-2">
{receipts.map((r, rIdx) => (
<div key={r.id} className={ROW_CLS}>
<button
type="button"
onClick={() => setPreviewReceipts({ receipts, initialIndex: rIdx })}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<Receipt size={14} className="flex-none text-m-faint" />
<span className="truncate text-[0.8125rem] font-medium text-m-ink">{r.original_name}</span>
</button>
<button
type="button"
onClick={() => handleRemoveReceipt(r.id)}
title={t('costs.deleteReceipt')}
className="flex-none p-1 text-m-muted hover:text-red-500"
>
<Trash2 size={14} />
</button>
</div>
))}
{pendingReceiptFiles.map((file, idx) => (
<div key={idx} className={ROW_CLS}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<Paperclip size={14} className="flex-none text-m-faint" />
<span className="truncate text-[0.8125rem] font-medium text-m-ink">{file.name}</span>
</div>
<button
type="button"
onClick={() => handleRemovePendingReceipt(idx)}
title={t('costs.deleteReceipt')}
className="flex-none p-1 text-m-muted hover:text-red-500"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
<FormSheetFooter
@@ -653,6 +789,14 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
submitLabel={submitLabel}
submitDisabled={!valid || saving}
/>
{previewReceipts && (
<ReceiptPreviewModal
receipts={previewReceipts.receipts}
initialIndex={previewReceipts.initialIndex}
onClose={() => setPreviewReceipts(null)}
/>
)}
</MSheet>
)
}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
Layers, Pencil, Plus, StickyNote, Trash2,
Layers, Pencil, Plus, StickyNote, Trash2, Receipt,
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { useAuthStore } from '../../../../store/authStore'
@@ -9,9 +9,10 @@ import { useSettingsStore } from '../../../../store/settingsStore'
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { useTranslation } from '../../../../i18n'
import { formatMoney } from '../../../../utils/formatters'
import { downloadBlob } from '../../../../utils/fileDownload'
import { downloadBlob, openFile } from '../../../../utils/fileDownload'
import { budgetApi } from '../../../../api/client'
import MCostSheet from '../sheets/MCostSheet'
import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal'
import { readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import MConfirmSheet from '../../settings/MConfirmSheet'
@@ -25,7 +26,7 @@ import {
dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, tint,
type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse,
} from './costsModel'
import type { BudgetItem, TripMember } from '../../../../types'
import type { BudgetItem, BudgetItemReceipt, TripMember } from '../../../../types'
type TFn = (key: string, params?: Record<string, string | number>) => string
@@ -78,6 +79,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
const [expenseModalOpen, setExpenseModalOpen] = useState(false)
const [editingExpense, setEditingExpense] = useState<BudgetItem | null>(null)
const [confirmDelete, setConfirmDelete] = useState<BudgetItem | null>(null)
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
const flows = useMemo(() => settlement?.flows || [], [settlement])
const totals = useMemo(() => computeTotals(budgetItems, flows, ctx), [budgetItems, flows, ctx])
@@ -455,6 +457,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
}}
onDelete={() => setConfirmDelete(en.item)}
onTogglePaid={(userId, paid) => handleTogglePaid(en.item.id, userId, paid)}
onPreviewReceipts={(receipts) => setPreviewReceipts({ receipts, initialIndex: 0 })}
/>
) : (
<PaymentRow
@@ -528,12 +531,20 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) {
if (item) handleDeleteExpense(item)
}}
/>
{previewReceipts && (
<ReceiptPreviewModal
receipts={previewReceipts.receipts}
initialIndex={previewReceipts.initialIndex}
onClose={() => setPreviewReceipts(null)}
/>
)}
</TabScroller>
)
}
/** One expense card (spec 03 §3.7): category ribbon, optional unfinished ribbon, member chips, total pill, edit/delete stack. */
function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onTogglePaid }: {
function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onTogglePaid, onPreviewReceipts }: {
item: BudgetItem
ctx: CostsCtx
base: string
@@ -543,6 +554,7 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
onEdit: () => void
onDelete: () => void
onTogglePaid: (userId: number, paid: boolean) => void
onPreviewReceipts: (receipts: BudgetItemReceipt[]) => void
}) {
const meta = catMeta(item.category)
const Icon = meta.Icon
@@ -577,7 +589,23 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
<div className="flex items-center gap-[10px]">
<div className="min-w-0 flex-1">
<div className="truncate text-[0.8125rem] font-bold text-m-ink">{item.name}</div>
<div className="flex items-center gap-1.5 min-w-0">
<span className="truncate text-[0.8125rem] font-bold text-m-ink">{item.name}</span>
{(item.receipts || []).length > 0 && (
<button
type="button"
onClick={(ev) => {
ev.stopPropagation()
onPreviewReceipts(item.receipts!)
}}
title={t('costs.viewReceipt')}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-2 py-0.5 text-[0.625rem] font-semibold text-m-muted active:scale-95 transition-all"
>
<Receipt size={11} className="text-m-faint" />
<span>{t('costs.receipts') || 'Beleg'}{item.receipts!.length > 1 ? ` (${item.receipts!.length})` : ''}</span>
</button>
)}
</div>
{cur !== base && (
<div className="mt-[1px] truncate font-geist text-[0.59375rem] text-m-faint">
{formatMoney(item.total_price, cur, locale)} {'→'} {formatMoney(total, base, locale)}
+2
View File
@@ -22,6 +22,7 @@ import type {
PackingBagMember,
BudgetItem,
BudgetItemMember,
BudgetItemReceipt,
Reservation,
ReservationEndpoint,
Accommodation,
@@ -45,6 +46,7 @@ export type {
PackingBagMember,
BudgetItem,
BudgetItemMember,
BudgetItemReceipt,
Reservation,
ReservationEndpoint,
Accommodation,
+20
View File
@@ -4415,6 +4415,26 @@ function runMigrations(db: Database.Database): void {
WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`,
).run();
},
/**
* Let a file hang off an expense, so a receipt or an invoice can be attached
* to what it paid for.
*
* A column on `file_links` rather than a table of its own: the row already
* ties one file to one thing, and every other attachment kind is a column
* here too. The unique index stops the same receipt being linked twice;
* SQLite treats NULLs as distinct, so the rows that exist today, which all
* carry a NULL here, do not collide with each other.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const flCols = db.prepare("SELECT name FROM pragma_table_info('file_links')").all() as Array<{ name: string }>;
if (!flCols.some((c) => c.name === 'budget_item_id')) {
db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE CASCADE');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)');
},
];
if (currentVersion < migrations.length) {
+151 -2
View File
@@ -4,7 +4,7 @@ import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../types';
import type { User, BudgetItem, BudgetItemMember, BudgetItemPayer, BudgetItemReceipt } from '../../types';
import { ExchangeRatesService } from './exchange-rates.service';
type Trip = TripAccess;
@@ -143,6 +143,32 @@ export class BudgetService {
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
}
private loadItemReceipts(itemId: number | string): BudgetItemReceipt[] {
const rows = this.db.all<{
id: number;
filename: string;
original_name: string;
file_size: number | null;
mime_type: string | null;
trip_id: number;
}>(`
SELECT f.id, f.filename, f.original_name, f.file_size, f.mime_type, f.trip_id
FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
ORDER BY f.created_at ASC
`, itemId);
return rows.map(f => ({
id: f.id,
filename: f.filename,
original_name: f.original_name,
file_size: f.file_size,
mime_type: f.mime_type,
url: `/api/trips/${f.trip_id}/files/${f.id}/download`,
}));
}
/**
* The subset of `userIds` that is actually on this trip. Used to be a plain
* existence check against `users`, which let any id on the instance become a
@@ -177,6 +203,37 @@ export class BudgetService {
return total;
}
/**
* Move a receipt file to trash if it is only linked to this budget item and
* not referenced by any other reservation, place, or day assignment.
*/
private trashOrphanReceipt(fileId: number | string, budgetItemId: number | string) {
const file = this.db.get<{ id: number; place_id: number | null; reservation_id: number | null }>(
'SELECT id, place_id, reservation_id FROM trip_files WHERE id = ? AND deleted_at IS NULL',
fileId,
);
if (!file) return;
// Check if file is tied directly to another entity via legacy columns
if (file.place_id || file.reservation_id) return;
// Check if file is linked via file_links to another reservation, place, assignment, or other budget item
const otherLinks = this.db.get<{ count: number }>(`
SELECT COUNT(*) as count FROM file_links
WHERE file_id = ? AND (
reservation_id IS NOT NULL OR
place_id IS NOT NULL OR
assignment_id IS NOT NULL OR
(budget_item_id IS NOT NULL AND budget_item_id != ?)
)
`, fileId, budgetItemId);
if (otherLinks && otherLinks.count > 0) return;
// It is an orphan receipt solely belonging to this budget item: move to trash
this.db.run('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?', fileId);
}
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
@@ -225,9 +282,42 @@ export class BudgetService {
}
}
const receiptsByItem: Record<number, BudgetItemReceipt[]> = {};
if (itemIds.length > 0) {
const placeholders = itemIds.map(() => '?').join(',');
const allReceipts = this.db.all<{
id: number;
filename: string;
original_name: string;
file_size: number | null;
mime_type: string | null;
trip_id: number;
budget_item_id: number;
}>(`
SELECT f.id, f.filename, f.original_name, f.file_size, f.mime_type, f.trip_id, fl.budget_item_id
FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id IN (${placeholders})
ORDER BY f.created_at ASC
`, ...itemIds);
for (const r of allReceipts) {
if (!receiptsByItem[r.budget_item_id]) receiptsByItem[r.budget_item_id] = [];
receiptsByItem[r.budget_item_id].push({
id: r.id,
filename: r.filename,
original_name: r.original_name,
file_size: r.file_size,
mime_type: r.mime_type,
url: `/api/trips/${r.trip_id}/files/${r.id}/download`,
});
}
}
items.forEach(item => {
item.members = membersByItem[item.id] || [];
item.payers = payersByItem[item.id] || [];
item.receipts = receiptsByItem[item.id] || [];
});
return items;
}
@@ -352,6 +442,7 @@ export class BudgetService {
ticket_json?: string | null;
reservation_id?: number | null;
place_id?: number | null;
receipt_file_ids?: number[];
},
) {
return this.db.transaction(() => {
@@ -409,19 +500,32 @@ export class BudgetService {
for (const uid of memberIds) insert.run(itemId, uid);
}
if (data.receipt_file_ids && data.receipt_file_ids.length > 0) {
const insertLink = this.db.prepare('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)');
for (const fid of data.receipt_file_ids) {
// Verify file belongs to this trip
const belongs = this.db.get('SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL', fid, tripId);
if (belongs) {
insertLink.run(fid, itemId);
}
}
}
const item = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ?', itemId)!;
item.members = this.loadItemMembers(itemId);
item.payers = this.loadItemPayers(itemId);
item.receipts = this.loadItemReceipts(itemId);
return item;
});
}
/** Fetch a single budget item hydrated with its members and payers, scoped to the trip. */
/** Fetch a single budget item hydrated with its members, payers, and receipts, scoped to the trip. */
getBudgetItem(id: string | number, tripId: string | number): BudgetItem | null {
const item = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
if (!item) return null;
item.members = this.loadItemMembers(id);
item.payers = this.loadItemPayers(id);
item.receipts = this.loadItemReceipts(id);
return item;
}
@@ -445,6 +549,7 @@ export class BudgetService {
members?: { user_id: number; amount?: number | null }[];
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
ticket_json?: string | null;
receipt_file_ids?: number[];
},
) {
return this.db.transaction(() => {
@@ -522,9 +627,39 @@ export class BudgetService {
}
}
if (data.receipt_file_ids !== undefined) {
// Find existing receipt file IDs for this budget item
const oldReceiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Sync receipts: delete previous links and add new ones
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
if (data.receipt_file_ids.length > 0) {
const insertLink = this.db.prepare('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)');
for (const fid of data.receipt_file_ids) {
const belongs = this.db.get('SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL', fid, tripId);
if (belongs) {
insertLink.run(fid, id);
}
}
}
// Any previously attached receipt that was unlinked now moves to trash if orphan
const newSet = new Set(data.receipt_file_ids);
for (const oldFid of oldReceiptFiles) {
if (!newSet.has(oldFid)) {
this.trashOrphanReceipt(oldFid, id);
}
}
}
const updated = this.db.get<BudgetItem>('SELECT * FROM budget_items WHERE id = ?', id)!;
updated.members = this.loadItemMembers(id);
updated.payers = this.loadItemPayers(id);
updated.receipts = this.loadItemReceipts(id);
return updated;
});
}
@@ -551,7 +686,21 @@ export class BudgetService {
);
if (!item) return false;
return this.db.transaction(() => {
// Find all receipts attached to this item before deleting it
const receiptFiles = this.db.all<{ id: number }>(`
SELECT f.id FROM trip_files f
JOIN file_links fl ON fl.file_id = f.id
WHERE f.deleted_at IS NULL AND fl.budget_item_id = ?
`, id).map(r => r.id);
// Move exclusively attached receipts to trash
for (const fid of receiptFiles) {
this.trashOrphanReceipt(fid, id);
}
this.db.run('DELETE FROM file_links WHERE budget_item_id = ?', id);
this.db.run('DELETE FROM budget_items WHERE id = ?', id);
// The booking keeps a copy of this expense's total in its metadata, and
// the reservation update path preserves that copy across edits. With the
// expense gone there is nothing left to mirror, so drop it here rather
+13 -7
View File
@@ -91,10 +91,10 @@ export class FilesController {
) {}
// A file may only point at reservations/assignments/places from its own trip.
// A file may only point at reservations/assignments/places/budget_items from its own trip.
// Reject cross-trip ids before they are stored — the reservation JOIN would
// otherwise leak the foreign reservation's title back to the caller.
private assertLinkTargets(tripId: string, body: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null }) {
private assertLinkTargets(tripId: string, body: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }) {
if (this.files.findForeignLinkTarget(tripId, body)) {
throw new HttpException({ error: 'Linked item does not belong to this trip' }, 400);
}
@@ -148,7 +148,7 @@ export class FilesController {
throw new HttpException({ error: 'File is too large' }, 400);
}
try {
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
} catch (err) {
cleanup();
throw err;
@@ -165,6 +165,7 @@ export class FilesController {
place_id: body.place_id,
description: body.description,
reservation_id: body.reservation_id,
budget_item_id: body.budget_item_id,
});
this.files.broadcast(tripId, 'file:created', { file: created }, socketId);
return { file: created };
@@ -180,8 +181,13 @@ export class FilesController {
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id });
const updated = this.files.updateFile(id, file, { description: body.description, place_id: body.place_id, reservation_id: body.reservation_id });
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
const updated = this.files.updateFile(id, file, {
description: body.description,
place_id: body.place_id,
reservation_id: body.reservation_id,
budget_item_id: body.budget_item_id,
});
this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId);
return { file: updated };
}
@@ -268,8 +274,8 @@ export class FilesController {
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id });
const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id });
this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id });
return { success: true, links };
}
+25 -8
View File
@@ -37,6 +37,7 @@ export interface FileLink {
file_id: number;
reservation_id: number | null;
place_id: number | null;
budget_item_id: number | null;
}
/**
@@ -137,8 +138,8 @@ export class FilesService {
*/
findForeignLinkTarget(
tripId: string | number,
opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null }
): 'reservation_id' | 'assignment_id' | 'place_id' | null {
opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }
): 'reservation_id' | 'assignment_id' | 'place_id' | 'budget_item_id' | null {
if (opts.reservation_id && !this.db.get('SELECT 1 FROM reservations WHERE id = ? AND trip_id = ?', opts.reservation_id, tripId)) {
return 'reservation_id';
}
@@ -148,6 +149,9 @@ export class FilesService {
if (opts.assignment_id && !this.db.get('SELECT 1 FROM day_assignments a JOIN days d ON a.day_id = d.id WHERE a.id = ? AND d.trip_id = ?', opts.assignment_id, tripId)) {
return 'assignment_id';
}
if (opts.budget_item_id && !this.db.get('SELECT 1 FROM budget_items WHERE id = ? AND trip_id = ?', opts.budget_item_id, tripId)) {
return 'budget_item_id';
}
return null;
}
@@ -229,7 +233,7 @@ export class FilesService {
const linksMap: Record<number, FileLink[]> = {};
if (fileIds.length > 0) {
const placeholders = fileIds.map(() => '?').join(',');
const links = this.db.all<FileLink>(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`, ...fileIds);
const links = this.db.all<FileLink>(`SELECT file_id, reservation_id, place_id, budget_item_id FROM file_links WHERE file_id IN (${placeholders})`, ...fileIds);
for (const link of links) {
if (!linksMap[link.file_id]) linksMap[link.file_id] = [];
linksMap[link.file_id].push(link);
@@ -242,6 +246,7 @@ export class FilesService {
...formatFile(f),
linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id),
linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id),
linked_budget_item_ids: fileLinks.filter(l => l.budget_item_id).map(l => l.budget_item_id),
};
});
}
@@ -250,7 +255,7 @@ export class FilesService {
tripId: string | number,
file: { filename: string; originalname: string; size: number; mimetype: string },
uploadedBy: number,
opts: { place_id?: string | number | null; reservation_id?: string | number | null; description?: string | null }
opts: { place_id?: string | number | null; reservation_id?: string | number | null; budget_item_id?: string | number | null; description?: string | null }
) {
const result = this.db.run(`
INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by)
@@ -267,6 +272,10 @@ export class FilesService {
uploadedBy
);
if (opts.budget_item_id) {
this.db.run('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)', result.lastInsertRowid, opts.budget_item_id);
}
const created = this.db.get<TripFile>(`${FILE_SELECT} WHERE f.id = ?`, result.lastInsertRowid)!;
return formatFile(created);
}
@@ -274,7 +283,7 @@ export class FilesService {
updateFile(
id: string | number,
current: TripFile,
updates: { description?: string; place_id?: string | number | null; reservation_id?: string | number | null }
updates: { description?: string; place_id?: string | number | null; reservation_id?: string | number | null; budget_item_id?: string | number | null }
) {
this.db.run(`
UPDATE trip_files SET
@@ -289,6 +298,14 @@ export class FilesService {
id
);
if (updates.budget_item_id !== undefined) {
if (updates.budget_item_id) {
this.db.run('INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)', id, updates.budget_item_id);
} else {
this.db.run('DELETE FROM file_links WHERE file_id = ? AND budget_item_id IS NOT NULL', id);
}
}
const updated = this.db.get<TripFile>(`${FILE_SELECT} WHERE f.id = ?`, id)!;
return formatFile(updated);
}
@@ -355,10 +372,10 @@ export class FilesService {
// of returning a success-shaped links list (the legacy catch swallowed it).
createFileLink(
fileId: string | number,
opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null }
opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }
) {
this.db.run('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)',
fileId, opts.reservation_id || null, opts.assignment_id || null, opts.place_id || null
this.db.run('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id, budget_item_id) VALUES (?, ?, ?, ?, ?)',
fileId, opts.reservation_id || null, opts.assignment_id || null, opts.place_id || null, opts.budget_item_id || null
);
return this.db.all('SELECT * FROM file_links WHERE file_id = ?', fileId);
}
+10
View File
@@ -127,6 +127,15 @@ export interface PackingItem {
created_at?: string;
}
export interface BudgetItemReceipt {
id: number;
filename: string;
original_name: string;
file_size?: number | null;
mime_type?: string | null;
url: string;
}
export interface BudgetItem {
id: number;
trip_id: number;
@@ -150,6 +159,7 @@ export interface BudgetItem {
created_at?: string;
members?: BudgetItemMember[];
payers?: BudgetItemPayer[];
receipts?: BudgetItemReceipt[];
}
export interface BudgetItemMember {
@@ -946,4 +946,112 @@ describe('an expense whose split leaves a remainder', () => {
expect(totalOf(item.id)).toBe(25);
});
})
it('attaches receipts created with receipt_file_ids and lists them', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// Insert a file for this trip
const res = testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?)').run(
trip.id, 'receipt-123.jpg', 'receipt.jpg', 'image/jpeg', 1024
);
const fileId = Number(res.lastInsertRowid);
const item = budget.createBudgetItem(trip.id, {
name: 'Restaurant with receipt',
total_price: 45,
receipt_file_ids: [fileId],
});
expect(item.receipts).toBeDefined();
expect(item.receipts!.length).toBe(1);
expect(item.receipts![0].id).toBe(fileId);
expect(item.receipts![0].original_name).toBe('receipt.jpg');
const listed = budget.listBudgetItems(trip.id);
const found = listed.find(i => i.id === item.id);
expect(found?.receipts?.length).toBe(1);
expect(found?.receipts?.[0].id).toBe(fileId);
});
it('updates receipts on updateBudgetItem, trashing orphans and keeping shared receipts', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// File 1: will be removed and is orphan -> should be trashed
const f1 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f1.jpg', 'f1.jpg').lastInsertRowid);
// File 2: will be kept
const f2 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f2.jpg', 'f2.jpg').lastInsertRowid);
// File 3: will be added
const f3 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f3.jpg', 'f3.jpg').lastInsertRowid);
// File 4: will be removed but is shared with another budget item -> should NOT be trashed
const f4 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'f4.jpg', 'f4.jpg').lastInsertRowid);
budget.createBudgetItem(trip.id, { name: 'Other', receipt_file_ids: [f4] });
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', receipt_file_ids: [f1, f2, f4] });
// Update item: remove f1 and f4, keep f2, add f3
const updated = budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [f2, f3] });
expect(updated?.receipts?.map(r => r.id).sort()).toEqual([f2, f3].sort());
// Check database state
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
expect(f1Row.deleted_at).not.toBeNull();
const f4Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f4) as { deleted_at: string | null };
expect(f4Row.deleted_at).toBeNull();
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
expect(f2Row.deleted_at).toBeNull();
});
it('trashes orphan receipts on deleteBudgetItem, but preserves shared files', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// File 1: orphan receipt
const f1 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'del1.jpg', 'del1.jpg').lastInsertRowid);
// File 2: linked to a place directly
const place = testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Hotel').lastInsertRowid;
const f2 = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, place_id) VALUES (?, ?, ?, ?)').run(trip.id, 'del2.jpg', 'del2.jpg', place).lastInsertRowid);
const item = budget.createBudgetItem(trip.id, { name: 'Lunch', receipt_file_ids: [f1, f2] });
const deleted = budget.deleteBudgetItem(item.id, trip.id);
expect(deleted).toBe(true);
const f1Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f1) as { deleted_at: string | null };
expect(f1Row.deleted_at).not.toBeNull();
const f2Row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(f2) as { deleted_at: string | null };
expect(f2Row.deleted_at).toBeNull();
const linkRows = testDb.prepare('SELECT * FROM file_links WHERE budget_item_id = ?').all(item.id);
expect(linkRows).toHaveLength(0);
});
it('handles all branches of trashOrphanReceipt safely', () => {
const { user: alice } = createUser(testDb);
const trip = createTrip(testDb, alice.id);
// Branch 1: non-existent file or already deleted
const item = budget.createBudgetItem(trip.id, { name: 'BranchTest' });
expect(() => budget.updateBudgetItem(item.id, trip.id, { receipt_file_ids: [] })).not.toThrow();
// Branch 2: file linked to reservation
const resId = Number(testDb.prepare('INSERT INTO reservations (trip_id, title) VALUES (?, ?)').run(trip.id, 'Flight').lastInsertRowid);
const fRes = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name, reservation_id) VALUES (?, ?, ?, ?)').run(trip.id, 'res.pdf', 'res.pdf', resId).lastInsertRowid);
const item2 = budget.createBudgetItem(trip.id, { name: 'ResItem', receipt_file_ids: [fRes] });
budget.deleteBudgetItem(item2.id, trip.id);
const fResRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fRes) as { deleted_at: string | null };
expect(fResRow.deleted_at).toBeNull();
// Branch 3: file linked via file_links to a place or assignment
const place2 = Number(testDb.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(trip.id, 'Cafe').lastInsertRowid);
const fLink = Number(testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'link.pdf', 'link.pdf').lastInsertRowid);
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(fLink, place2);
const item3 = budget.createBudgetItem(trip.id, { name: 'LinkItem', receipt_file_ids: [fLink] });
budget.deleteBudgetItem(item3.id, trip.id);
const fLinkRow = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(fLink) as { deleted_at: string | null };
expect(fLinkRow.deleted_at).toBeNull();
});
});
@@ -108,6 +108,25 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
expect(await rejected(fc(fsvc({ can: vi.fn().mockReturnValue(false) })).upload(user, '5', withPath, {}))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
});
it('passes budget_item_id to assertLinkTargets and createFile on upload, rejecting foreign targets', async () => {
const file = { filename: 'a.pdf' } as Express.Multer.File;
const rejectSvc = fsvc({ findForeignLinkTarget: vi.fn().mockReturnValue('budget_item_id') });
expect(await rejected(fc(rejectSvc).upload(user, '5', file, { budget_item_id: '99' }))).toEqual({
status: 400,
body: { error: 'Linked item does not belong to this trip' },
});
const createFile = vi.fn().mockReturnValue({ id: 10 });
const okSvc = fsvc({ createFile, broadcast: vi.fn() });
await fc(okSvc).upload(user, '5', file, { budget_item_id: '12' });
expect(createFile).toHaveBeenCalledWith('5', file, 1, {
place_id: undefined,
description: undefined,
reservation_id: undefined,
budget_item_id: '12',
});
});
it('500 with cleanup when the storage commit fails', async () => {
vi.mocked(storageStub.put).mockRejectedValueOnce(new Error('disk full'));
const withPath = { filename: 'a.pdf', path: '/nonexistent/zzz.pdf' } as Express.Multer.File;
@@ -123,6 +142,31 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
expect(fc(s).update(user, trip, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } });
});
it('passes budget_item_id to assertLinkTargets and updateFile on update, rejecting foreign targets', () => {
const rejectSvc = fsvc({
getFileById: vi.fn().mockReturnValue({ id: 9 }),
findForeignLinkTarget: vi.fn().mockReturnValue('budget_item_id'),
});
expect(thrown(() => fc(rejectSvc).update(user, trip, '5', '9', { budget_item_id: '99' }))).toEqual({
status: 400,
body: { error: 'Linked item does not belong to this trip' },
});
const updateFile = vi.fn().mockReturnValue({ id: 9 });
const okSvc = fsvc({
getFileById: vi.fn().mockReturnValue({ id: 9 }),
updateFile,
broadcast: vi.fn(),
});
fc(okSvc).update(user, trip, '5', '9', { budget_item_id: '12' });
expect(updateFile).toHaveBeenCalledWith('9', { id: 9 }, {
description: undefined,
place_id: undefined,
reservation_id: undefined,
budget_item_id: '12',
});
});
it('PATCH /:id/star 403/404, else toggles', () => {
expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).star(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
+15 -1
View File
@@ -97,10 +97,20 @@ export const budgetItemPayerSchema = z.object({
});
export type BudgetItemPayer = z.infer<typeof budgetItemPayerSchema>;
export const budgetItemReceiptSchema = z.object({
id: z.number(),
filename: z.string(),
original_name: z.string(),
file_size: z.number().nullable().optional(),
mime_type: z.string().nullable().optional(),
url: z.string(),
});
export type BudgetItemReceipt = z.infer<typeof budgetItemReceiptSchema>;
/**
* Budget item entity as returned by the budget list/create/update endpoints
* (server/src/services/budgetService.ts). Columns of the `budget_items` table
* plus the embedded `members` (equal-split participants) and `payers` arrays.
* plus the embedded `members` (equal-split participants), `payers` and `receipts` arrays.
* total_price is the sum of payer amounts in `currency`; `exchange_rate` converts
* that to the trip base currency (NULL currency + rate 1 = base currency).
*/
@@ -127,6 +137,7 @@ export const budgetItemSchema = z.object({
created_at: z.string().optional(),
members: z.array(budgetItemMemberSchema).optional(),
payers: z.array(budgetItemPayerSchema).optional(),
receipts: z.array(budgetItemReceiptSchema).optional(),
});
export type BudgetItem = z.infer<typeof budgetItemSchema>;
@@ -163,6 +174,8 @@ export const budgetCreateItemRequestSchema = z.object({
// The same for a place: the place form's "add expense" flow saves the place
// first, then creates the expense against it (#1298).
place_id: z.number().optional(),
// Receipt files to link to this expense
receipt_file_ids: z.array(z.number()).optional(),
});
export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>;
@@ -181,6 +194,7 @@ export const budgetUpdateItemRequestSchema = z.object({
note: z.string().nullable().optional(),
ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
receipt_file_ids: z.array(z.number()).optional(),
});
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
+3
View File
@@ -22,6 +22,7 @@ export const fileUploadRequestSchema = z.object({
place_id: z.string().optional(),
description: z.string().optional(),
reservation_id: z.string().optional(),
budget_item_id: z.string().optional(),
});
export type FileUploadRequest = z.infer<typeof fileUploadRequestSchema>;
@@ -29,6 +30,7 @@ export const fileUpdateRequestSchema = z.object({
description: z.string().optional(),
place_id: nullableIdField,
reservation_id: nullableIdField,
budget_item_id: nullableIdField,
});
export type FileUpdateRequest = z.infer<typeof fileUpdateRequestSchema>;
@@ -36,6 +38,7 @@ export const fileLinkRequestSchema = z.object({
reservation_id: nullableIdField,
assignment_id: nullableIdField,
place_id: nullableIdField,
budget_item_id: nullableIdField,
});
export type FileLinkRequest = z.infer<typeof fileLinkRequestSchema>;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'مجموع الحصص: {sum} من {total} (ناقص {diff})',
'costs.splitSumOver': 'مجموع الحصص: {sum} من {total} (زائد {diff})',
'costs.toggleSign': 'التبديل بين المصروف والاسترداد',
'costs.receipts': 'الإيصالات',
'costs.receiptsTitle': 'الإيصالات والفواتير',
'costs.attachReceipt': 'إرفاق إيصال / فاتورة',
'costs.noReceipts': 'لا توجد إيصالات مرفقة',
'costs.deleteReceipt': 'حذف الإيصال',
'costs.viewReceipt': 'عرض الإيصال',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Soma das partes: {sum} de {total} (faltam {diff})',
'costs.splitSumOver': 'Soma das partes: {sum} de {total} (sobram {diff})',
'costs.toggleSign': 'Alternar entre despesa e reembolso',
'costs.receipts': 'Comprovantes',
'costs.receiptsTitle': 'Comprovantes e notas',
'costs.attachReceipt': 'Anexar comprovante / nota',
'costs.noReceipts': 'Nenhum comprovante anexado',
'costs.deleteReceipt': 'Remover comprovante',
'costs.viewReceipt': 'Ver comprovante',
};
export default budget;
+6
View File
@@ -147,6 +147,12 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Suma de les parts: {sum} de {total} (en falten {diff})',
'costs.splitSumOver': 'Suma de les parts: {sum} de {total} ({diff} de més)',
'costs.toggleSign': 'Alterna entre despesa i devolució',
'costs.receipts': 'Rebuts',
'costs.receiptsTitle': 'Rebuts i factures',
'costs.attachReceipt': 'Adjuntar rebut / factura',
'costs.noReceipts': 'Cap rebut adjunt',
'costs.deleteReceipt': 'Eliminar rebut',
'costs.viewReceipt': 'Veure rebut',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Součet podílů: {sum} z {total} (chybí {diff})',
'costs.splitSumOver': 'Součet podílů: {sum} z {total} (o {diff} více)',
'costs.toggleSign': 'Přepnout mezi výdajem a vratkou',
'costs.receipts': 'Účtenky',
'costs.receiptsTitle': 'Účtenky a faktury',
'costs.attachReceipt': 'Připojit účtenku / fakturu',
'costs.noReceipts': 'Žádné připojené účtenky',
'costs.deleteReceipt': 'Odebrat účtenku',
'costs.viewReceipt': 'Zobrazit účtenku',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Summe der Anteile: {sum} von {total} (es fehlen {diff})',
'costs.splitSumOver': 'Summe der Anteile: {sum} von {total} ({diff} zu viel)',
'costs.toggleSign': 'Zwischen Ausgabe und Erstattung wechseln',
'costs.receipts': 'Belege',
'costs.receiptsTitle': 'Belege & Rechnungen',
'costs.attachReceipt': 'Beleg / Rechnung anhängen',
'costs.noReceipts': 'Keine Belege angehängt',
'costs.deleteReceipt': 'Beleg entfernen',
'costs.viewReceipt': 'Beleg ansehen',
};
export default budget;
+6
View File
@@ -147,6 +147,12 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Sum of splits: {sum} of {total} (under by {diff})',
'costs.splitSumOver': 'Sum of splits: {sum} of {total} (over by {diff})',
'costs.toggleSign': 'Switch between expense and refund',
'costs.receipts': 'Receipts',
'costs.receiptsTitle': 'Receipts & Invoices',
'costs.attachReceipt': 'Attach receipt / invoice',
'costs.noReceipts': 'No receipts attached',
'costs.deleteReceipt': 'Remove receipt',
'costs.viewReceipt': 'View receipt',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Suma de las partes: {sum} de {total} (faltan {diff})',
'costs.splitSumOver': 'Suma de las partes: {sum} de {total} (sobran {diff})',
'costs.toggleSign': 'Cambiar entre gasto y reembolso',
'costs.receipts': 'Recibos',
'costs.receiptsTitle': 'Recibos y facturas',
'costs.attachReceipt': 'Adjuntar recibo / factura',
'costs.noReceipts': 'No hay recibos adjuntos',
'costs.deleteReceipt': 'Eliminar recibo',
'costs.viewReceipt': 'Ver recibo',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Somme des parts : {sum} sur {total} (il manque {diff})',
'costs.splitSumOver': 'Somme des parts : {sum} sur {total} ({diff} de trop)',
'costs.toggleSign': 'Basculer entre dépense et remboursement',
'costs.receipts': 'Reçus',
'costs.receiptsTitle': 'Reçus et factures',
'costs.attachReceipt': 'Joindre un reçu / une facture',
'costs.noReceipts': 'Aucun reçu joint',
'costs.deleteReceipt': 'Supprimer le reçu',
'costs.viewReceipt': 'Voir le reçu',
};
export default budget;
+6
View File
@@ -148,5 +148,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Άθροισμα μεριδίων: {sum} από {total} (λείπουν {diff})',
'costs.splitSumOver': 'Άθροισμα μεριδίων: {sum} από {total} ({diff} παραπάνω)',
'costs.toggleSign': 'Εναλλαγή μεταξύ εξόδου και επιστροφής',
'costs.receipts': 'Αποδείξεις',
'costs.receiptsTitle': 'Αποδείξεις & Τιμολόγια',
'costs.attachReceipt': 'Επισύναψη απόδειξης / τιμολογίου',
'costs.noReceipts': 'Δεν υπάρχουν συνημμένες αποδείξεις',
'costs.deleteReceipt': 'Αφαίρεση απόδειξης',
'costs.viewReceipt': 'Προβολή απόδειξης',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'A részek összege: {sum} / {total} (hiányzik {diff})',
'costs.splitSumOver': 'A részek összege: {sum} / {total} ({diff} a többlet)',
'costs.toggleSign': 'Váltás kiadás és visszatérítés között',
'costs.receipts': 'Nyugták',
'costs.receiptsTitle': 'Nyugták és számlák',
'costs.attachReceipt': 'Nyugta / számla csatolása',
'costs.noReceipts': 'Nincsenek csatolt nyugták',
'costs.deleteReceipt': 'Nyugta eltávolítása',
'costs.viewReceipt': 'Nyugta megtekintése',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Jumlah bagian: {sum} dari {total} (kurang {diff})',
'costs.splitSumOver': 'Jumlah bagian: {sum} dari {total} (lebih {diff})',
'costs.toggleSign': 'Beralih antara pengeluaran dan pengembalian dana',
'costs.receipts': 'Kuitansi',
'costs.receiptsTitle': 'Kuitansi & Faktur',
'costs.attachReceipt': 'Lampirkan kuitansi / faktur',
'costs.noReceipts': 'Tidak ada kuitansi terlampir',
'costs.deleteReceipt': 'Hapus kuitansi',
'costs.viewReceipt': 'Lihat kuitansi',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Somma delle quote: {sum} di {total} (mancano {diff})',
'costs.splitSumOver': 'Somma delle quote: {sum} di {total} ({diff} in più)',
'costs.toggleSign': 'Passa da spesa a rimborso',
'costs.receipts': 'Ricevute',
'costs.receiptsTitle': 'Ricevute e fatture',
'costs.attachReceipt': 'Allega ricevuta / fattura',
'costs.noReceipts': 'Nessuna ricevuta allegata',
'costs.deleteReceipt': 'Rimuovi ricevuta',
'costs.viewReceipt': 'Visualizza ricevuta',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': '分割の合計: {total} のうち {sum}({diff} 不足)',
'costs.splitSumOver': '分割の合計: {total} のうち {sum}({diff} 超過)',
'costs.toggleSign': '支出と返金を切り替える',
'costs.receipts': 'レシート',
'costs.receiptsTitle': 'レシート・請求書',
'costs.attachReceipt': 'レシート・請求書を添付',
'costs.noReceipts': '添付されたレシートはありません',
'costs.deleteReceipt': 'レシートを削除',
'costs.viewReceipt': 'レシートを表示',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': '분할 합계: {total} 중 {sum} ({diff} 부족)',
'costs.splitSumOver': '분할 합계: {total} 중 {sum} ({diff} 초과)',
'costs.toggleSign': '지출과 환불 전환',
'costs.receipts': '영수증',
'costs.receiptsTitle': '영수증 및 청구서',
'costs.attachReceipt': '영수증 / 청구서 첨부',
'costs.noReceipts': '첨부된 영수증 없음',
'costs.deleteReceipt': '영수증 삭제',
'costs.viewReceipt': '영수증 보기',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Som van de delen: {sum} van {total} ({diff} te weinig)',
'costs.splitSumOver': 'Som van de delen: {sum} van {total} ({diff} te veel)',
'costs.toggleSign': 'Wisselen tussen uitgave en terugbetaling',
'costs.receipts': 'Bonnetjes',
'costs.receiptsTitle': 'Bonnetjes & facturen',
'costs.attachReceipt': 'Bon / factuur bijvoegen',
'costs.noReceipts': 'Geen bonnetjes bijgevoegd',
'costs.deleteReceipt': 'Bonnetje verwijderen',
'costs.viewReceipt': 'Bonnetje bekijken',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Suma podziałów: {sum} z {total} (brakuje {diff})',
'costs.splitSumOver': 'Suma podziałów: {sum} z {total} (o {diff} za dużo)',
'costs.toggleSign': 'Przełącz między wydatkiem a zwrotem',
'costs.receipts': 'Paragony',
'costs.receiptsTitle': 'Paragony i faktury',
'costs.attachReceipt': 'Załącz paragon / fakturę',
'costs.noReceipts': 'Brak załączonych paragonów',
'costs.deleteReceipt': 'Usuń paragon',
'costs.viewReceipt': 'Zobacz paragon',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Сумма долей: {sum} из {total} (не хватает {diff})',
'costs.splitSumOver': 'Сумма долей: {sum} из {total} (больше на {diff})',
'costs.toggleSign': 'Переключить между расходом и возвратом',
'costs.receipts': 'Чеки',
'costs.receiptsTitle': 'Чеки и счета',
'costs.attachReceipt': 'Прикрепить чек / счет',
'costs.noReceipts': 'Нет прикрепленных чеков',
'costs.deleteReceipt': 'Удалить чек',
'costs.viewReceipt': 'Просмотреть чек',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Summan av delarna: {sum} av {total} ({diff} saknas)',
'costs.splitSumOver': 'Summan av delarna: {sum} av {total} ({diff} för mycket)',
'costs.toggleSign': 'Växla mellan utgift och återbetalning',
'costs.receipts': 'Kvitton',
'costs.receiptsTitle': 'Kvitton & fakturor',
'costs.attachReceipt': 'Bifoga kvitto / faktura',
'costs.noReceipts': 'Inga bifogade kvitton',
'costs.deleteReceipt': 'Ta bort kvitto',
'costs.viewReceipt': 'Visa kvitto',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} eksik)',
'costs.splitSumOver': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} fazla)',
'costs.toggleSign': 'Gider ve iade arasında geçiş yap',
'costs.receipts': 'Fişler',
'costs.receiptsTitle': 'Fişler ve Faturalar',
'costs.attachReceipt': 'Fiş / fatura ekle',
'costs.noReceipts': 'Ekli fiş yok',
'costs.deleteReceipt': 'Fişi kaldır',
'costs.viewReceipt': 'Fişi görüntüle',
};
export default budget;
+6
View File
@@ -147,5 +147,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Сума часток: {sum} з {total} (бракує {diff})',
'costs.splitSumOver': 'Сума часток: {sum} з {total} (більше на {diff})',
'costs.toggleSign': 'Перемкнути між витратою та поверненням',
'costs.receipts': 'Чеки',
'costs.receiptsTitle': 'Чеки та рахунки',
'costs.attachReceipt': 'Додати чек / рахунок',
'costs.noReceipts': 'Немає доданих чеків',
'costs.deleteReceipt': 'Видалити чек',
'costs.viewReceipt': 'Переглянути чек',
};
export default budget;
+6
View File
@@ -147,6 +147,12 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': 'Tổng các phần: {sum} trên {total} (thiếu {diff})',
'costs.splitSumOver': 'Tổng các phần: {sum} trên {total} (thừa {diff})',
'costs.toggleSign': 'Chuyển giữa khoản chi và khoản hoàn',
'costs.receipts': 'Hóa đơn',
'costs.receiptsTitle': 'Hóa đơn & Biên lai',
'costs.attachReceipt': 'Đính kèm hóa đơn / biên lai',
'costs.noReceipts': 'Chưa đính kèm hóa đơn nào',
'costs.deleteReceipt': 'Xóa hóa đơn',
'costs.viewReceipt': 'Xem hóa đơn',
};
export default budget;
+6
View File
@@ -146,5 +146,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': '分攤合計:{total} 中的 {sum}(少 {diff})',
'costs.splitSumOver': '分攤合計:{total} 中的 {sum}(多 {diff})',
'costs.toggleSign': '在支出與退款之間切換',
'costs.receipts': '收據',
'costs.receiptsTitle': '收據與發票',
'costs.attachReceipt': '附加收據/發票',
'costs.noReceipts': '未附加收據',
'costs.deleteReceipt': '刪除收據',
'costs.viewReceipt': '查看收據',
};
export default budget;
+6
View File
@@ -146,5 +146,11 @@ const budget: TranslationStrings = {
'costs.splitSumUnder': '分摊合计:{total} 中的 {sum}(少 {diff})',
'costs.splitSumOver': '分摊合计:{total} 中的 {sum}(多 {diff})',
'costs.toggleSign': '在支出和退款之间切换',
'costs.receipts': '收据',
'costs.receiptsTitle': '收据与发票',
'costs.attachReceipt': '附加收据/发票',
'costs.noReceipts': '未附加收据',
'costs.deleteReceipt': '删除收据',
'costs.viewReceipt': '查看收据',
};
export default budget;