mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(budget): attach receipts and invoices to expenses
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user