Add a note field to expenses (#1888)

Requested in discussion #1658: an expense carries an amount and a
category, and everything else about it ends up crammed into the title.

The column was already there. It just was not available, because the
costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an
expense split by receipt could never carry a note - and saving one any
other way wrote note=null, which quietly wiped whatever had been typed
into the budget table. The receipt gets its own column and note goes
back to being text. Old clients that still send the blob as a note are
routed to the right column instead of clobbering the note.

On desktop the row now has fixed columns, so the amount does not drift
left when one row has a note and the next does not. The category moved
to the card edge as a tab like the mobile card does, which is what frees
the line the note sits on. Collapsed it is one line, and the same
element grows in place when opened.

The dialog is two columns now: what the expense was on the left, how it
is shared on the right, grouped into panels the way the trip dialog
does it.

On the phone the eleven category pills became a dropdown, which is where
the room for the note field came from, and a note on a card unfolds when
you tap it.
This commit is contained in:
Maurice
2026-08-11 18:13:07 +02:00
committed by GitHub
parent a39c31cb48
commit 435b782c85
36 changed files with 712 additions and 126 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitCents, payerSum, payersBalanced, rebalancePayers } from './CostsPanel.helpers'
import { hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems } from './CostsPanel.helpers'
describe('splitCents', () => {
it('splits evenly when it divides cleanly', () => {
@@ -76,3 +76,59 @@ describe('rebalancePayers', () => {
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
})
})
// ── Notes vs. itemized receipts (#1658) ──────────────────────────────────────
describe('readTicketItems', () => {
const receipt = '{"items":[{"name":"Bread","price":"3.50","parts":[1,2]},{"name":"Wine","price":"12","parts":[2]}]}'
it('reads the receipt out of its own column', () => {
const items = readTicketItems({ ticket_json: receipt })
expect(items.map(i => i.name)).toEqual(['Bread', 'Wine'])
expect(items[0].participants).toEqual(new Set([1, 2]))
expect(items[1].price).toBe('12')
})
it('still reads a response cached before the column existed', () => {
const items = readTicketItems({ note: 'TICKETJSON:' + receipt })
expect(items).toHaveLength(2)
expect(items[1].participants).toEqual(new Set([2]))
})
it('prefers the column over the legacy note when both are present', () => {
const items = readTicketItems({ ticket_json: '{"items":[{"name":"Only","price":"1","parts":[]}]}', note: 'TICKETJSON:' + receipt })
expect(items.map(i => i.name)).toEqual(['Only'])
})
it('is empty for an expense with neither, and for malformed JSON', () => {
expect(readTicketItems({ note: 'just a note' })).toEqual([])
expect(readTicketItems(null)).toEqual([])
expect(readTicketItems({ ticket_json: '{oops' })).toEqual([])
})
it('round-trips through writeTicketItems', () => {
const items = readTicketItems({ ticket_json: receipt })
expect(readTicketItems({ ticket_json: writeTicketItems(items) })).toEqual(items)
})
})
describe('hasTicketSplit', () => {
it('recognises both storage shapes and nothing else', () => {
expect(hasTicketSplit({ ticket_json: '{"items":[]}' })).toBe(true)
expect(hasTicketSplit({ note: 'TICKETJSON:{"items":[]}' })).toBe(true)
expect(hasTicketSplit({ note: 'dinner with Ben' })).toBe(false)
expect(hasTicketSplit(undefined)).toBe(false)
})
})
describe('readUserNote', () => {
it('returns what the user typed', () => {
expect(readUserNote({ note: 'Lisa pays half back' })).toBe('Lisa pays half back')
})
it('never returns a receipt blob as if it were a note', () => {
expect(readUserNote({ note: 'TICKETJSON:{"items":[]}' })).toBe('')
expect(readUserNote({ note: null })).toBe('')
expect(readUserNote(null)).toBe('')
})
})
@@ -88,6 +88,53 @@ export interface TicketItem {
participants: Set<number>
}
/**
* Read the itemized receipt off an expense (#1658).
*
* It lives in `ticket_json` since migration 186. Before that it was smuggled
* through `note` behind a `TICKETJSON:` prefix, which is why an expense split by
* receipt could never carry a written note. The old shape is still read here so
* a response cached before the migration keeps rendering its split.
*/
export function readTicketItems(item: { ticket_json?: string | null; note?: string | null } | null | undefined): TicketItem[] {
const raw = item?.ticket_json ?? (item?.note?.startsWith('TICKETJSON:') ? item.note.slice(11) : null)
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return (parsed.items || []).map((line: { name?: string; price?: unknown; parts?: number[] }, i: number) => ({
id: `${i}`,
name: String(line.name ?? ''),
price: String(line.price ?? ''),
participants: new Set(line.parts || []),
}))
} catch {
return []
}
}
/** True when this expense is split line by line rather than equally or by amount. */
export function hasTicketSplit(item: { ticket_json?: string | null; note?: string | null } | null | undefined): boolean {
return Boolean(item?.ticket_json || item?.note?.startsWith('TICKETJSON:'))
}
/** Serialize the receipt lines for storage in `ticket_json`. */
export function writeTicketItems(items: TicketItem[]): string {
return JSON.stringify({ items: items.map(i => ({ name: i.name, price: i.price, parts: [...i.participants] })) })
}
/** Long enough for a receipt discrepancy or a reimbursement reminder, short
* enough that the row stays a row. */
export const NOTE_MAX = 500
/**
* The note a user actually typed, which is never the receipt blob. Only matters
* for data written before migration 186 moved the receipt out of the field.
*/
export function readUserNote(item: { note?: string | null } | null | undefined): string {
const note = item?.note
return note && !note.startsWith('TICKETJSON:') ? note : ''
}
/** Per-person totals for a receipt split line by line, plus the receipt total. */
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
@@ -171,6 +171,79 @@ describe('CostsPanel — settlements in the ledger', () => {
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
// ── Notes on an expense (#1658) ────────────────────────────────────────────
it('shows a note on the row and expands it on click', async () => {
const long = 'Bought the whole week of breakfasts here, Ben pays back his half once the card statement lands'
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Supermarket' }),
total_price: 60,
note: long,
payers: [{ user_id: 1, amount: 60, username: 'alice' }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
// Re-queried after each click: the row is re-created on every render of the
// panel, so a handle taken before the click points at a detached node.
const noteToggle = () => screen.getByRole('button', { name: new RegExp(long.slice(0, 20)) })
await screen.findByText('Supermarket')
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
await user.click(noteToggle())
expect(noteToggle()).toHaveAttribute('aria-expanded', 'true')
await user.click(noteToggle())
expect(noteToggle()).toHaveAttribute('aria-expanded', 'false')
})
it('never renders a receipt blob as a note', async () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Market' }),
total_price: 20,
// How the receipt was stored before it got its own column.
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"20","parts":[1]}]}',
payers: [{ user_id: 1, amount: 20, username: 'alice' }],
}
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Market')
expect(screen.queryByText(/TICKETJSON/)).toBeNull()
})
it('keeps an existing note when the expense is saved again', async () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Lunch' }),
total_price: 30,
note: 'split with the neighbours',
payers: [{ user_id: 1, amount: 30, username: 'alice' }],
}
let patched: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.put('/api/trips/1/budget/:id', async ({ request }) => {
patched = (await request.json()) as Record<string, unknown>
return HttpResponse.json({ item: { ...item, ...patched } })
}),
)
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await screen.findByText('Lunch')
await user.click(screen.getAllByTitle('Edit')[0])
await user.click(await screen.findByRole('button', { name: 'Save' }))
await waitFor(() => expect(patched).toBeTruthy())
expect(patched!.note).toBe('split with the neighbours')
})
it('shows the net hint on a settled expense row and hides it while unfinished', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
@@ -534,7 +607,9 @@ describe('CostsPanel — settlements in the ledger', () => {
expect.objectContaining({ user_id: 1, amount: 75 }),
expect.objectContaining({ user_id: 2, amount: 25 }),
]))
expect(posted!.note).toContain('TICKETJSON:')
// The receipt has its own column since #1658; `note` is the user's text.
expect(JSON.parse(posted!.ticket_json as string).items).toHaveLength(3)
expect(posted!.note).toBeNull()
})
// ── Display currency ───────────────────────────────────────────────────────
+144 -70
View File
@@ -1,6 +1,6 @@
import { 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 } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -16,7 +16,7 @@ import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
import { calculateTicketShares, payersBalanced, rebalancePayers, splitEqualShares, type TicketItem } from './CostsPanel.helpers'
import { 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 type { TripMember } from './BudgetPanelMemberChips'
@@ -80,6 +80,9 @@ 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)
// 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)
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
const [addingPayment, setAddingPayment] = useState(false)
@@ -246,8 +249,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
for (const e of items) {
const cur = curOf(e)
// Ticket notes carry the itemized-receipt JSON, not a human note.
const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : ''
const note = readUserNote(e)
rows.push([
esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)),
(e.total_price || 0).toFixed(currencyDecimals(cur)), cur,
@@ -502,6 +504,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
.costs-root .text-content-muted { color: var(--c-ink2) !important; }
.costs-root .text-content-faint { color: var(--c-ink3) !important; }
.costs-root .exp-actions { opacity: 1; }
.costs-root .exp-actions button { background: none; }
.costs-root .exp-actions button:hover { background: var(--bg-secondary); color: var(--c-ink); }
/* Destructive actions keep their own tint: the neutral one would read as
"safe" on the button that deletes the expense. */
.costs-root .exp-actions .exp-action-danger:hover { background: rgba(220,38,38,0.14); color: #dc2626; }
@media (max-width: 1100px) {
.costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; }
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
@@ -653,6 +660,28 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
// ── inline subcomponents (close over helpers) ────────────────────────────
/**
* Edit and delete, in a capsule beside the row rather than inside it — the
* mobile card has always done it this way, and out here the buttons stop
* competing with the amount for the right edge of the card.
*/
function RowActions({ onEdit, onDelete, deleteLabel }: { onEdit: () => void; onDelete: () => void; deleteLabel: string }) {
// No `background` here on purpose: an inline value would beat the :hover
// rule in the stylesheet block below, which is how the first attempt at
// this silently did nothing.
const btn: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 30, height: 30, borderRadius: 999, border: 0,
cursor: 'pointer', padding: 0,
}
return (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)
}
function ExpenseRow({ e }: { e: BudgetItem }) {
const c = catMeta(e.category)
const Icon = c.Icon
@@ -660,17 +689,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const payers = (e.payers || []).filter(p => p.amount > 0)
const net = round2(myPaidOf(e) - myShareOf(e))
const unfinished = isUnfinished(e)
const note = readUserNote(e)
const open = expandedNoteId === e.id
// Fixed columns, identical on every row, so the eye can run down the list
// instead of re-finding each field on each line. A row without a note leaves
// its column empty rather than letting everything after it drift left, which
// is what made the list read as unstructured.
const cols = isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto'
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
<div className="bg-surface-card border border-edge exp-row" style={{ position: 'relative', flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: cols, gap: 18, alignItems: 'center', borderRadius: 18, padding: isMobile ? '16px 20px' : '20px 20px 16px' }}>
{/* The category rides the card edge as a tab, the way the mobile card
does, so it stops taking up a slot inside the row itself. */}
{!isMobile && (
<span aria-hidden="true" style={{ position: 'absolute', left: -1, top: -1, display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 11px 4px 10px', borderRadius: '17px 0 12px 0', background: c.color, color: '#fff', fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 800, letterSpacing: '0.05em', textTransform: 'uppercase', lineHeight: 1.2 }}>
<Icon size={10} strokeWidth={2.4} />
{t(c.labelKey)}
</span>
)}
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} />
{isMobile && unfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
{/* What it was. */}
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{e.name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
<span className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span>
{unfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 800 }}>!</span>
@@ -678,8 +728,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</span>
)}
</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' }}>
{fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))}
</div>
)}
{/* Under the name, because a chip reading "Y 122,00" says nothing on
its own — the name above it is what gives it a meaning. */}
{payers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginTop: 6 }}>
{payers.map(p => (
<span key={p.user_id} className="bg-surface-secondary border border-edge" title={personName(p.user_id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))' }}>
<Avatar id={p.user_id} size={18} />
@@ -688,29 +745,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
))}
</div>
)}
{!isMobile && (
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
</div>
)}
</div>
{/* The note, marked by a rule in the category colour instead of a box, so
it reads as part of the row rather than something dropped onto it. */}
{!isMobile && (
note ? (
<button type="button" onClick={() => setExpandedNoteId(open ? null : e.id)}
aria-expanded={open} title={open ? t('costs.hideNote') : t('costs.showNote')}
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-colors exp-note-btn"
style={{ display: 'flex', alignItems: open ? 'flex-start' : 'center', gap: 9, minWidth: 0, width: '100%', padding: open ? '10px 14px 11px 13px' : '7px 12px 7px 11px', borderRadius: open ? 14 : 999, borderLeft: '3px solid ' + c.color, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', lineHeight: 1.5, textAlign: 'left' }}>
<span style={open
? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', minWidth: 0, flex: 1 }
: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, flex: 1 }}>{note}</span>
<ChevronDown size={13} style={{ flexShrink: 0, marginTop: open ? 3 : 0, transition: 'transform .18s ease', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
) : <span />
)}
{/* The money, and what to do with it. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4, whiteSpace: 'nowrap' }}>
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: isMobile ? '0' : '6px 13px', borderRadius: 999, border: isMobile ? 0 : undefined, background: isMobile ? 'none' : undefined, fontSize: isMobile ? 'calc(18px * var(--fs-scale-subtitle, 1))' : 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{fmt(baseTotal(e))}</span>
{!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', padding: isMobile ? 0 : '2px 9px', borderRadius: 999, background: isMobile ? 'none' : (net > 0 ? 'rgba(22,163,74,0.13)' : 'rgba(220,38,38,0.13)'), fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, color: net > 0 ? '#16a34a' : '#dc2626' }}>
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
</div>
</span>
)}
</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => { setEditing(e); setModalOpen(true) }} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('common.delete')} onClick={() => handleDelete(e.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)}
</div>
</div>
{canEdit && <RowActions onEdit={() => { setEditing(e); setModalOpen(true) }} onDelete={() => handleDelete(e.id)} deleteLabel={t('common.delete')} />}
</div>
)
}
@@ -720,7 +786,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
// Legacy transfers carry no currency and were entered in the display base.
const cur = (s.currency || base).toUpperCase()
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
<div className="bg-surface-card border border-edge exp-row" style={{ flex: 1, minWidth: 0, display: 'grid', gridTemplateColumns: isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto', gap: isMobile ? 16 : 18, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
<div style={{ minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
@@ -732,16 +799,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
</div>
</div>
{/* A payment carries no note, but it keeps the column so its amount lands
on the same axis as every expense above it. */}
{!isMobile && <span />}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(convert(s.amount, cur))}</div>
{canEdit && (
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
<span className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', padding: '6px 13px', borderRadius: 999, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmt(convert(s.amount, cur))}</span>
</div>
</div>
{canEdit && (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
)
}
@@ -936,6 +1007,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}) {
const { t, locale } = useTranslation()
const toast = useToast()
const isMobile = useIsMobile()
const { addBudgetItem, updateBudgetItem } = useTripStore()
const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
@@ -944,6 +1016,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [note, setNote] = useState(() => readUserNote(editing))
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
@@ -977,7 +1050,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
if (hasTicketSplit(editing)) {
return 'ticket'
}
if (editing && editing.members && editing.members.length > 0) {
@@ -987,22 +1060,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: any) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set(item.parts || [])
}))
} catch {
return []
}
}
return []
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => readTicketItems(editing))
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
@@ -1191,13 +1249,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
member_ids: [...participants],
expense_date: day || null,
total_price: totalNum,
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
items: ticketItems.map(item => ({
name: item.name,
price: item.price,
parts: [...item.participants]
}))
}) : null,
note: note.trim() || null,
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
@@ -1212,17 +1265,25 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}
const inputCls = 'w-full bg-surface-input border border-edge text-content'
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
const labelCls = 'block text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2'
// Borrowed from the trip dialog (TripFormModal): related fields sit together in
// a panel instead of running as one undifferentiated column of inputs.
const panelCls = 'rounded-2xl border border-edge bg-surface-secondary p-4'
return (
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size={isMobile ? '2xl' : '5xl'}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Two columns on desktop: what the expense was on the left, how it is
shared on the right. The split follows the two questions the dialog
actually asks, so neither side is a leftovers pile. */}
<div style={{ display: isMobile ? 'flex' : 'grid', flexDirection: 'column', gridTemplateColumns: '1fr 1fr', alignItems: 'start', gap: isMobile ? 14 : 28 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
<div className={panelCls} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className={labelCls}>{t('costs.whatFor')}</label>
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} />
@@ -1260,7 +1321,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
)}
<div>
</div>
<div className={panelCls}>
<label className={labelCls}>{t('costs.category')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
{COST_CATEGORY_LIST.map(c => {
@@ -1277,8 +1340,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
<div className={panelCls}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.whoPaid')}</label>
<button type="button" onClick={() => (multiPayer ? disableMultiPayer() : enableMultiPayer())}
className="text-content-muted"
@@ -1340,23 +1406,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
)}
</div>
<div>
<div className={panelCls}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.split') || 'Split'}</label>
<div className="bg-surface-card border border-edge" style={{ display: 'flex', borderRadius: 999, padding: 2 }}>
<button type="button" onClick={() => setSplitMode('equally')}
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
className={splitMode === 'equally' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'equally' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitEqually') || 'Equally'}
</button>
<button type="button" onClick={() => setSplitMode('custom')}
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
className={splitMode === 'custom' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'custom' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitCustom') || 'Custom'}
</button>
<button type="button" onClick={() => setSplitMode('ticket')}
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
className={splitMode === 'ticket' ? 'bg-surface-secondary text-content' : 'text-content-muted'}
style={{ padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', borderRadius: 999, fontWeight: 600, border: 0, background: splitMode === 'ticket' ? undefined : 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.splitTicket') || 'Ticket'}
</button>
</div>
@@ -1491,6 +1557,14 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</>
)}
</div>
<div className={panelCls}>
<label className={labelCls} htmlFor="expense-note">{t('costs.note')}</label>
<textarea id="expense-note" value={note} onChange={e => setNote(e.target.value)} rows={2}
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>
</div>
</Modal>
)
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Trash2, Wallet } from 'lucide-react'
import { Check, ChevronDown, Plus, Trash2, Wallet } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import CustomSelect from '../../../../components/shared/CustomSelect'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import { NumericInput } from '../../../../components/shared/NumericInput'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { useTranslation } from '../../../../i18n'
import { useToast } from '../../../../components/shared/Toast'
import { useTripStore } from '../../../../store/tripStore'
@@ -12,7 +12,7 @@ import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { formatMoney, localizeAmountInput } from '../../../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { calculateTicketShares, splitEqualShares, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers'
import GuestBadge from '../../../../components/shared/GuestBadge'
@@ -69,6 +69,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [catOpen, setCatOpen] = useState(false)
const [note, setNote] = useState(() => readUserNote(editing))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [total, setTotal] = useState<string>(() => {
@@ -96,7 +98,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) return 'ticket'
if (hasTicketSplit(editing)) return 'ticket'
if (editing && editing.members && editing.members.length > 0) {
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
return hasCustom ? 'custom' : 'equally'
@@ -104,22 +106,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
return 'equally'
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
try {
const parsed = JSON.parse(editing.note.slice(11))
return (parsed.items || []).map((item: { name: string; price: number | string; parts?: number[] }) => ({
id: String(Math.random()),
name: item.name,
price: String(item.price),
participants: new Set<number>(item.parts || []),
}))
} catch {
return []
}
}
return []
})
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => readTicketItems(editing))
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
@@ -268,9 +255,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
member_ids: [...participants],
expense_date: day || null,
total_price: totalNum,
note: splitMode === 'ticket'
? 'TICKETJSON:' + JSON.stringify({ items: ticketItems.map(item => ({ name: item.name, price: item.price, parts: [...item.participants] })) })
: null,
note: note.trim() || null,
ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null,
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
@@ -316,11 +302,6 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
</span>
)
const pillCls = (active: boolean) =>
`flex items-center gap-[5px] rounded-full px-[11px] py-[6px] text-[0.71875rem] font-semibold ${
active ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
}`
const submitLabel = saving ? t('common.saving') : editing ? t('common.save') : t('common.add')
return (
@@ -391,19 +372,45 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
</div>
)}
{/* CATEGORY */}
{/* CATEGORY — a dropdown rather than eleven pills, which took a third of
the sheet and pushed the split below the fold. Same shape as the
category filter on the tab behind it. */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('costs.category')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon
return (
<button key={c.key} type="button" onClick={() => setCat(c.key)} aria-pressed={cat === c.key} className={pillCls(cat === c.key)}>
<Icon size={12} strokeWidth={2.2} style={{ color: c.color }} />
{t(c.labelKey)}
</button>
)
})}
</div>
<button
type="button"
aria-expanded={catOpen}
onClick={() => setCatOpen(v => !v)}
className="flex w-full items-center gap-[10px] overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-card px-[13px] py-[11px] text-left"
>
{(() => {
const meta = catMeta(cat)
const Icon = meta.Icon
return <Icon size={15} strokeWidth={2} style={{ color: meta.color }} className="flex-none" />
})()}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{t(catMeta(cat).labelKey)}</span>
<ChevronDown size={14} strokeWidth={2} className={`flex-none text-m-faint transition-transform duration-200 ${catOpen ? 'rotate-180' : ''}`} />
</button>
{catOpen && (
<div className="mt-[6px] max-h-[240px] overflow-y-auto overscroll-contain rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-glass)]">
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon
const on = cat === c.key
return (
<button
key={c.key}
type="button"
aria-pressed={on}
onClick={() => { setCat(c.key); setCatOpen(false) }}
className="flex w-full items-center gap-[10px] border-b border-[color:var(--m-rowbr)] px-[13px] py-[11px] text-left last:border-b-0"
>
<Icon size={14} strokeWidth={2} style={{ color: c.color }} className="flex-none" />
<span className={`flex-1 text-[0.78125rem] ${on ? 'font-bold text-m-ink' : 'font-medium text-m-muted'}`}>{t(c.labelKey)}</span>
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-m-ink" />}
</button>
)
})}
</div>
)}
{/* WHO PAID */}
<div className="mb-[6px] mt-3 flex items-center justify-between">
@@ -609,6 +616,18 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
</div>
</>
)}
{/* NOTE — last, because it is the one field that is never required. The
room for it came from folding the category pills into a dropdown. */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('costs.note')}</Eyebrow>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={2}
maxLength={NOTE_MAX}
placeholder={t('costs.notePlaceholder')}
className={FIELD_AREA_CLS}
/>
</div>
<FormSheetFooter
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AlertCircle, ArrowDown, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp,
Layers, Pencil, Plus, Trash2,
Layers, Pencil, Plus, StickyNote, Trash2,
} from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { useAuthStore } from '../../../../store/authStore'
@@ -12,6 +12,7 @@ import { formatMoney } from '../../../../utils/formatters'
import { downloadBlob } from '../../../../utils/fileDownload'
import { budgetApi } from '../../../../api/client'
import MCostSheet from '../sheets/MCostSheet'
import { readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import MConfirmSheet from '../../settings/MConfirmSheet'
import MSheet from '../../../components/MSheet'
@@ -536,6 +537,10 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
const unfinished = isUnfinished(item, ctx)
const borderColor = tint(meta.color, 0.55)
const members = item.members || []
const note = readUserNote(item)
// A phone row has no width to spare for a note, so it stays folded away and
// the card itself is the handle — no extra control, no extra height.
const [noteOpen, setNoteOpen] = useState(false)
return (
<div className="mt-2 flex items-center gap-[6px]">
@@ -590,6 +595,25 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
{formatMoney(total, base, locale)}
</span>
</div>
{note && (
<button
type="button"
aria-expanded={noteOpen}
onClick={() => setNoteOpen(v => !v)}
className="mt-[7px] flex w-full items-center gap-[7px] rounded-xl bg-[color:var(--m-ic)] px-[9px] py-[6px] text-left"
>
<StickyNote size={11} strokeWidth={2} className="flex-none text-m-faint" />
<span className={`min-w-0 flex-1 text-[0.6875rem] leading-[1.5] text-m-muted ${noteOpen ? 'whitespace-pre-wrap [overflow-wrap:anywhere]' : 'truncate'}`}>
{note}
</span>
<ChevronDown
size={12}
strokeWidth={2}
className={`flex-none text-m-faint transition-transform duration-200 ${noteOpen ? 'rotate-180' : ''}`}
/>
</button>
)}
</div>
{canEdit && (
@@ -1,5 +1,5 @@
import type { CostCategory } from '@trek/shared'
import { splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { readUserNote, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories'
import { currencyDecimals } from '../../../../utils/formatters'
import type { BudgetItem } from '../../../../types'
@@ -233,8 +233,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''))
for (const e of sorted) {
const cur = currencyOf(e, opts.ctx)
// Ticket notes carry the itemized-receipt JSON, not a human note.
const note = e.note && !e.note.startsWith('TICKETJSON:') ? e.note : ''
const note = readUserNote(e)
rows.push(
[
esc(fmtDate(e.expense_date || '')),
@@ -64,6 +64,8 @@ function fillBasics(name: string, total: string) {
fireEvent.change(totalField(), { target: { value: total } })
}
const CAT_LABELS = ['Accommodation', 'Food & drink', 'Groceries', 'Transport', 'Flights', 'Activities', 'Sightseeing', 'Shopping', 'Fees & tickets', 'Health', 'Tips', 'Other']
describe('MCostSheet', () => {
beforeEach(() => {
resetAllStores()
@@ -84,11 +86,19 @@ describe('MCostSheet', () => {
delete (window as unknown as { __addToast?: unknown }).__addToast
})
// The category moved from a row of eleven pills to a dropdown: the trigger
// shows the current one, and the options only exist while it is open.
const catTrigger = () => screen.getByRole('button', { name: new RegExp(`^(${CAT_LABELS.join('|')})$`) })
const chooseCategory = (label: string) => {
fireEvent.click(catTrigger())
fireEvent.click(screen.getByRole('button', { name: label, pressed: false }))
}
it('FE-MOB-COSTSH-001: opens in add mode with everyone in an equal split and a blocked submit', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
expect(nameField()).toHaveValue('')
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'true')
expect(catTrigger()).toHaveTextContent('Food & drink')
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
expect(submit()).toBeDisabled()
@@ -115,15 +125,48 @@ describe('MCostSheet', () => {
expense_date: TODAY,
total_price: 85.5,
note: null,
ticket_json: null,
})
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
})
it('FE-MOB-COSTSH-020: the category dropdown opens, marks the current pick and closes on choose (#1658)', () => {
renderSheet()
expect(screen.queryByRole('button', { name: 'Groceries' })).toBeNull()
fireEvent.click(catTrigger())
expect(screen.getByRole('button', { name: 'Food & drink', pressed: true })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Groceries', pressed: false }))
expect(catTrigger()).toHaveTextContent('Groceries')
expect(screen.queryByRole('button', { name: 'Sightseeing' })).toBeNull()
})
it('FE-MOB-COSTSH-021: a note typed on the phone is saved with the expense (#1658)', async () => {
renderSheet()
fillBasics('Pharmacy', '18')
fireEvent.change(screen.getByPlaceholderText(/what it covered/i), { target: { value: 'Ben was ill, he pays this back' } })
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
note: 'Ben was ill, he pays this back',
})))
})
it('FE-MOB-COSTSH-022: an existing note is loaded for editing, and a receipt blob never is (#1658)', () => {
const { unmount } = renderSheet({ editing: buildBudgetItem({ id: 5, name: 'Chemist', note: 'reimburse from the kitty' }) })
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('reimburse from the kitty')
unmount()
renderSheet({ editing: buildBudgetItem({ id: 6, name: 'Market', note: 'TICKETJSON:{"items":[]}' }) })
expect(screen.getByPlaceholderText(/what it covered/i)).toHaveValue('')
})
it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => {
renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } })
expect(nameField()).toHaveValue('Ryokan')
expect(totalField()).toHaveValue('240')
expect(screen.getByRole('button', { name: 'Accommodation' })).toHaveAttribute('aria-pressed', 'true')
expect(catTrigger()).toHaveTextContent('Accommodation')
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
@@ -167,9 +210,8 @@ describe('MCostSheet', () => {
it('FE-MOB-COSTSH-006: switching the category updates the pressed pill and the payload', async () => {
renderSheet()
fillBasics('Metro pass', '20')
fireEvent.click(screen.getByRole('button', { name: 'Transport' }))
expect(screen.getByRole('button', { name: 'Transport' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'false')
chooseCategory('Transport')
expect(catTrigger()).toHaveTextContent('Transport')
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'transport' })))
@@ -323,7 +365,7 @@ describe('MCostSheet', () => {
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
total_price: 20,
members: [{ user_id: 1, amount: 10 }, { user_id: 2, amount: 10 }],
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
ticket_json: '{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
}))
})
@@ -369,7 +411,7 @@ describe('MCostSheet', () => {
expect(screen.getByRole('dialog', { name: 'Edit expense' })).toBeInTheDocument()
expect(nameField()).toHaveValue('Flight to Osaka')
expect(screen.getByRole('button', { name: 'Flights' })).toHaveAttribute('aria-pressed', 'true')
expect(catTrigger()).toHaveTextContent('Flights')
expect(screen.getByRole('button', { name: 'USD $' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act')
+15
View File
@@ -3841,6 +3841,21 @@ function runMigrations(db: Database.Database): void {
insert.run(key);
}
},
// Free the `note` column on budget items for its actual purpose (#1658).
// The costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which
// means an expense split by receipt can never carry a written note, and
// saving an expense any other way wipes whatever was typed in the budget
// table. The receipt moves to its own column and note becomes text again.
() => {
const hasTicket = db.prepare("SELECT 1 FROM pragma_table_info('budget_items') WHERE name = 'ticket_json'").get();
if (!hasTicket) db.exec('ALTER TABLE budget_items ADD COLUMN ticket_json TEXT');
db.exec(`
UPDATE budget_items
SET ticket_json = substr(note, 12), note = NULL
WHERE note LIKE 'TICKETJSON:%'
`);
},
];
if (currentVersion < migrations.length) {
+39 -3
View File
@@ -17,6 +17,29 @@ type SettlementRow = {
to_username: string; to_avatar: string | null;
};
/** How the costs UI used to smuggle an itemized receipt through the note field. */
const LEGACY_TICKET_PREFIX = 'TICKETJSON:';
/**
* Keep a written note and an itemized receipt out of each other's way (#1658).
*
* The receipt has its own column since migration 186, but a client that predates
* it still sends the blob as `note`. Such a payload says nothing about the note
* the user typed, so the note is reported as untouched (`undefined`) rather than
* overwritten — an old tab left open must not erase a note written elsewhere.
*
* `undefined` means "caller did not speak about this field"; `null` means "clear it".
*/
export function splitLegacyTicketNote(
note: string | null | undefined,
ticket: string | null | undefined,
): { note: string | null | undefined; ticket: string | null | undefined } {
if (typeof note === 'string' && note.startsWith(LEGACY_TICKET_PREFIX)) {
return { note: undefined, ticket: note.slice(LEGACY_TICKET_PREFIX.length) };
}
return { note, ticket };
}
/**
* Budget domain service — owns the budget SQL (moved from the legacy
* services/budgetService.ts: identical statements, the `||` falsy-coercion
@@ -277,6 +300,7 @@ export class BudgetService {
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
members?: { user_id: number; amount?: number | null }[];
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
ticket_json?: string | null;
reservation_id?: number | null;
},
) {
@@ -304,8 +328,10 @@ export class BudgetService {
const knownIds = data.member_ids ? this.knownUserIds(data.member_ids) : null;
const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined;
const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json);
const result = this.db.run(
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, ticket_json, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
tripId,
cat,
data.name,
@@ -314,7 +340,8 @@ export class BudgetService {
data.exchange_rate != null ? data.exchange_rate : 1,
memberIds ? memberIds.length : (data.persons != null ? data.persons : null),
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
note || null,
ticket || null,
sortOrder,
data.expense_date || null,
data.reservation_id != null ? data.reservation_id : null,
@@ -365,12 +392,19 @@ export class BudgetService {
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
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;
},
) {
return this.db.transaction(() => {
const item = this.db.get('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
if (!item) return null;
// An old client sending a receipt in `note` still lands in ticket_json, and
// its note is left untouched rather than clobbered with the receipt blob.
const { note, ticket } = splitLegacyTicketNote(data.note, data.ticket_json);
const noteTouched = data.note !== undefined && note !== undefined;
const ticketTouched = data.ticket_json !== undefined || ticket !== undefined;
this.db.run(`
UPDATE budget_items SET
category = COALESCE(?, category),
@@ -381,6 +415,7 @@ export class BudgetService {
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
days = CASE WHEN ? THEN ? ELSE days END,
note = CASE WHEN ? THEN ? ELSE note END,
ticket_json = CASE WHEN ? THEN ? ELSE ticket_json END,
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
WHERE id = ?
@@ -392,7 +427,8 @@ export class BudgetService {
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
noteTouched ? 1 : 0, noteTouched ? note : null,
ticketTouched ? 1 : 0, ticketTouched ? ticket : null,
data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
id,
+2
View File
@@ -135,6 +135,8 @@ export interface BudgetItem {
persons?: number | null;
days?: number | null;
note?: string | null;
/** Itemized receipt for a per-item split, as JSON. Its own column since #1658. */
ticket_json?: string | null;
reservation_id?: number | null;
paid_by_user_id?: number | null;
expense_date?: string | null;
@@ -471,6 +471,84 @@ describe('post-fold quirk fixes', () => {
expect(budget.listSettlements(trip.id)[0].from_username).toBe('Alice Displayed');
});
// ── Notes vs. itemized receipts (#1658) ────────────────────────────────────
it('BUDGET-SVC-DB-021: a note and a receipt are stored in their own columns', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, {
name: 'Groceries',
total_price: 40,
note: 'Lisa pays half of this back',
ticket_json: '{"items":[{"name":"Bread","price":"3","parts":[1]}]}',
});
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
expect(row.note).toBe('Lisa pays half of this back');
expect(JSON.parse(row.ticket_json!).items).toHaveLength(1);
});
it('BUDGET-SVC-DB-022: an update that omits note leaves the stored one alone', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 20, note: 'split with Ben' });
// What the mobile sheet sends: notes are written on desktop, so it never
// speaks about the field.
budget.updateBudgetItem(item!.id, trip.id, { total_price: 25 });
const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
expect(row.note).toBe('split with Ben');
});
it('BUDGET-SVC-DB-023: an explicit null clears the note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 12, note: 'airport run' });
budget.updateBudgetItem(item!.id, trip.id, { note: null });
const row = testDb.prepare('SELECT note FROM budget_items WHERE id = ?').get(item!.id) as { note: string | null };
expect(row.note).toBeNull();
});
it('BUDGET-SVC-DB-024: a pre-#1658 client sending the receipt as a note cannot erase the note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Supermarket', total_price: 30, note: 'reimburse from the kitty' });
// An old tab, still encoding the receipt into `note`.
budget.updateBudgetItem(item!.id, trip.id, {
note: 'TICKETJSON:{"items":[{"name":"Milk","price":"2","parts":[1]}]}',
});
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
expect(row.note).toBe('reimburse from the kitty');
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Milk');
});
it('BUDGET-SVC-DB-025: the same legacy payload on create lands in the receipt column', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, {
name: 'Market',
total_price: 9,
note: 'TICKETJSON:{"items":[{"name":"Cheese","price":"9","parts":[1]}]}',
});
const row = testDb.prepare('SELECT note, ticket_json FROM budget_items WHERE id = ?').get(item!.id) as {
note: string | null; ticket_json: string | null;
};
expect(row.note).toBeNull();
expect(JSON.parse(row.ticket_json!).items[0].name).toBe('Cheese');
});
it('BUDGET-SVC-DB-020: a failing item insert rolls back the category-order side write (quirk fix)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
+4
View File
@@ -113,6 +113,8 @@ export const budgetItemSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
/** Itemized receipt behind a per-item split, as JSON. Its own column since #1658. */
ticket_json: z.string().nullable().optional(),
reservation_id: z.number().nullable().optional(),
paid_by_user_id: z.number().nullable().optional(),
expense_date: z.string().nullable().optional(),
@@ -148,6 +150,7 @@ export const budgetCreateItemRequestSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
// Link this expense to a reservation (e.g. created from a booking's
// "add expense" flow). The server stores it on budget_items.reservation_id.
@@ -168,6 +171,7 @@ export const budgetUpdateItemRequestSchema = z.object({
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
ticket_json: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
});
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'تراجع',
'costs.whatFor': 'لأجل ماذا كان؟',
'costs.namePlaceholder': 'مثل: عشاء، هدايا تذكارية، وقود…',
'costs.note': 'ملاحظة',
'costs.notePlaceholder': 'ما الذي شملته، ومن يدين بماذا…',
'costs.addNote': 'إضافة ملاحظة',
'costs.showNote': 'عرض الملاحظة',
'costs.hideNote': 'إخفاء الملاحظة',
'costs.totalAmount': 'المبلغ الإجمالي',
'costs.currency': 'العملة',
'costs.day': 'اليوم',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Desfazer',
'costs.whatFor': 'Para que foi?',
'costs.namePlaceholder': 'ex.: jantar, lembranças, gasolina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'O que incluía, quem deve o quê…',
'costs.addNote': 'Adicionar nota',
'costs.showNote': 'Mostrar nota',
'costs.hideNote': 'Ocultar nota',
'costs.totalAmount': 'Valor total',
'costs.currency': 'Moeda',
'costs.day': 'Dia',
+5
View File
@@ -87,6 +87,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Desfer',
'costs.whatFor': 'Per a què ha estat?',
'costs.namePlaceholder': 'p. ex. Sopar, records, benzina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'Què incloïa, qui deu què…',
'costs.addNote': 'Afegeix una nota',
'costs.showNote': 'Mostra la nota',
'costs.hideNote': 'Amaga la nota',
'costs.totalAmount': 'Import total',
'costs.currency': 'Divisa',
'costs.day': 'Dia',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Vrátit zpět',
'costs.whatFor': 'Za co to bylo?',
'costs.namePlaceholder': 'např. večeře, suvenýry, benzín…',
'costs.note': 'Poznámka',
'costs.notePlaceholder': 'Co zahrnovalo, kdo co dluží…',
'costs.addNote': 'Přidat poznámku',
'costs.showNote': 'Zobrazit poznámku',
'costs.hideNote': 'Skrýt poznámku',
'costs.totalAmount': 'Celková částka',
'costs.currency': 'Měna',
'costs.day': 'Den',
+5
View File
@@ -92,6 +92,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Rückgängig',
'costs.whatFor': 'Wofür war es?',
'costs.namePlaceholder': 'z.B. Abendessen, Souvenirs, Benzin…',
'costs.note': 'Notiz',
'costs.notePlaceholder': 'Was darin enthalten war, wer was zurückzahlt…',
'costs.addNote': 'Notiz hinzufügen',
'costs.showNote': 'Notiz anzeigen',
'costs.hideNote': 'Notiz ausblenden',
'costs.totalAmount': 'Gesamtbetrag',
'costs.currency': 'Währung',
'costs.day': 'Tag',
+5
View File
@@ -92,6 +92,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Undo',
'costs.whatFor': 'What was it for?',
'costs.namePlaceholder': 'e.g. Dinner, souvenirs, gas…',
'costs.note': 'Note',
'costs.notePlaceholder': 'What it covered, who owes what back…',
'costs.addNote': 'Add note',
'costs.showNote': 'Show note',
'costs.hideNote': 'Hide note',
'costs.totalAmount': 'Total amount',
'costs.currency': 'Currency',
'costs.day': 'Day',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Deshacer',
'costs.whatFor': '¿Para qué fue?',
'costs.namePlaceholder': 'p. ej. Cena, souvenirs, gasolina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'Qué incluía, quién debe qué…',
'costs.addNote': 'Añadir nota',
'costs.showNote': 'Mostrar nota',
'costs.hideNote': 'Ocultar nota',
'costs.totalAmount': 'Importe total',
'costs.currency': 'Moneda',
'costs.day': 'Día',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Annuler',
'costs.whatFor': "C'était pour quoi ?",
'costs.namePlaceholder': 'ex. dîner, souvenirs, essence…',
'costs.note': 'Note',
'costs.notePlaceholder': 'Ce que ça couvrait, qui doit quoi…',
'costs.addNote': 'Ajouter une note',
'costs.showNote': 'Afficher la note',
'costs.hideNote': 'Masquer la note',
'costs.totalAmount': 'Montant total',
'costs.currency': 'Devise',
'costs.day': 'Jour',
+5
View File
@@ -90,6 +90,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Αναίρεση',
'costs.whatFor': 'Για τι ήταν;',
'costs.namePlaceholder': 'π.χ. Δείπνο, σουβενίρ, βενζίνη…',
'costs.note': 'Σημείωση',
'costs.notePlaceholder': 'Τι κάλυπτε, ποιος χρωστάει τι…',
'costs.addNote': 'Προσθήκη σημείωσης',
'costs.showNote': 'Εμφάνιση σημείωσης',
'costs.hideNote': 'Απόκρυψη σημείωσης',
'costs.totalAmount': 'Συνολικό ποσό',
'costs.currency': 'Νόμισμα',
'costs.day': 'Ημέρα',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Visszavonás',
'costs.whatFor': 'Mire volt?',
'costs.namePlaceholder': 'pl. vacsora, ajándékok, benzin…',
'costs.note': 'Megjegyzés',
'costs.notePlaceholder': 'Mit fedezett, ki tartozik mivel…',
'costs.addNote': 'Megjegyzés hozzáadása',
'costs.showNote': 'Megjegyzés megjelenítése',
'costs.hideNote': 'Megjegyzés elrejtése',
'costs.totalAmount': 'Teljes összeg',
'costs.currency': 'Pénznem',
'costs.day': 'Nap',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Urungkan',
'costs.whatFor': 'Untuk apa?',
'costs.namePlaceholder': 'mis. Makan malam, oleh-oleh, bensin…',
'costs.note': 'Catatan',
'costs.notePlaceholder': 'Apa saja yang termasuk, siapa berutang apa…',
'costs.addNote': 'Tambah catatan',
'costs.showNote': 'Tampilkan catatan',
'costs.hideNote': 'Sembunyikan catatan',
'costs.totalAmount': 'Jumlah total',
'costs.currency': 'Mata uang',
'costs.day': 'Hari',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Annulla',
'costs.whatFor': 'Per cosa era?',
'costs.namePlaceholder': 'es. Cena, souvenir, benzina…',
'costs.note': 'Nota',
'costs.notePlaceholder': 'Cosa comprendeva, chi deve cosa…',
'costs.addNote': 'Aggiungi nota',
'costs.showNote': 'Mostra nota',
'costs.hideNote': 'Nascondi nota',
'costs.totalAmount': 'Importo totale',
'costs.currency': 'Valuta',
'costs.day': 'Giorno',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': '元に戻す',
'costs.whatFor': '何の支出ですか?',
'costs.namePlaceholder': '例:夕食、お土産、ガソリン…',
'costs.note': 'メモ',
'costs.notePlaceholder': '内訳や立て替えのメモなど…',
'costs.addNote': 'メモを追加',
'costs.showNote': 'メモを表示',
'costs.hideNote': 'メモを非表示',
'costs.totalAmount': '合計金額',
'costs.currency': '通貨',
'costs.day': '日',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': '실행 취소',
'costs.whatFor': '무엇을 위한 것인가요?',
'costs.namePlaceholder': '예: 저녁 식사, 기념품, 주유…',
'costs.note': '메모',
'costs.notePlaceholder': '무엇을 샀는지, 누가 갚아야 하는지…',
'costs.addNote': '메모 추가',
'costs.showNote': '메모 보기',
'costs.hideNote': '메모 숨기기',
'costs.totalAmount': '총 금액',
'costs.currency': '통화',
'costs.day': '날짜',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Ongedaan maken',
'costs.whatFor': 'Waar was het voor?',
'costs.namePlaceholder': 'bijv. Diner, souvenirs, benzine…',
'costs.note': 'Notitie',
'costs.notePlaceholder': 'Wat het omvatte, wie wat terugbetaalt…',
'costs.addNote': 'Notitie toevoegen',
'costs.showNote': 'Notitie tonen',
'costs.hideNote': 'Notitie verbergen',
'costs.totalAmount': 'Totaalbedrag',
'costs.currency': 'Valuta',
'costs.day': 'Dag',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Cofnij',
'costs.whatFor': 'Na co to było?',
'costs.namePlaceholder': 'np. kolacja, pamiątki, paliwo…',
'costs.note': 'Notatka',
'costs.notePlaceholder': 'Co obejmowało, kto ile oddaje…',
'costs.addNote': 'Dodaj notatkę',
'costs.showNote': 'Pokaż notatkę',
'costs.hideNote': 'Ukryj notatkę',
'costs.totalAmount': 'Łączna kwota',
'costs.currency': 'Waluta',
'costs.day': 'Dzień',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Отменить',
'costs.whatFor': 'За что это было?',
'costs.namePlaceholder': 'напр. ужин, сувениры, бензин…',
'costs.note': 'Заметка',
'costs.notePlaceholder': 'Что входило, кто сколько должен…',
'costs.addNote': 'Добавить заметку',
'costs.showNote': 'Показать заметку',
'costs.hideNote': 'Скрыть заметку',
'costs.totalAmount': 'Общая сумма',
'costs.currency': 'Валюта',
'costs.day': 'День',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Ångra',
'costs.whatFor': 'Vad var det till för?',
'costs.namePlaceholder': 't.ex. middag, souvenirer, bensin…',
'costs.note': 'Anteckning',
'costs.notePlaceholder': 'Vad det omfattade, vem som ska betala tillbaka…',
'costs.addNote': 'Lägg till anteckning',
'costs.showNote': 'Visa anteckning',
'costs.hideNote': 'Dölj anteckning',
'costs.totalAmount': 'Totalt belopp',
'costs.currency': 'Valuta',
'costs.day': 'Dag',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Geri al',
'costs.whatFor': 'Ne içindi?',
'costs.namePlaceholder': 'örn. Akşam yemeği, hediyelik, benzin…',
'costs.note': 'Not',
'costs.notePlaceholder': 'Neyi kapsadığı, kimin ne borcu olduğu…',
'costs.addNote': 'Not ekle',
'costs.showNote': 'Notu göster',
'costs.hideNote': 'Notu gizle',
'costs.totalAmount': 'Toplam tutar',
'costs.currency': 'Para birimi',
'costs.day': 'Gün',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Скасувати',
'costs.whatFor': 'За що це було?',
'costs.namePlaceholder': 'напр. вечеря, сувеніри, пальне…',
'costs.note': 'Нотатка',
'costs.notePlaceholder': 'Що входило, хто скільки винен…',
'costs.addNote': 'Додати нотатку',
'costs.showNote': 'Показати нотатку',
'costs.hideNote': 'Сховати нотатку',
'costs.totalAmount': 'Загальна сума',
'costs.currency': 'Валюта',
'costs.day': 'День',
+5
View File
@@ -89,6 +89,11 @@ const budget: TranslationStrings = {
'costs.undo': 'Hoàn tác',
'costs.whatFor': 'Nó dùng để làm gì?',
'costs.namePlaceholder': 'ví dụ. Ăn tối, quà lưu niệm, xăng…',
'costs.note': 'Ghi chú',
'costs.notePlaceholder': 'Khoản này gồm những gì, ai nợ bao nhiêu…',
'costs.addNote': 'Thêm ghi chú',
'costs.showNote': 'Hiện ghi chú',
'costs.hideNote': 'Ẩn ghi chú',
'costs.totalAmount': 'Tổng số tiền',
'costs.currency': 'Tiền tệ',
'costs.day': 'Ngày',
+5
View File
@@ -88,6 +88,11 @@ const budget: TranslationStrings = {
'costs.undo': '復原',
'costs.whatFor': '這筆是什麼支出?',
'costs.namePlaceholder': '例如:晚餐、紀念品、油費…',
'costs.note': '備註',
'costs.notePlaceholder': '包含哪些內容、誰需要還款…',
'costs.addNote': '新增備註',
'costs.showNote': '顯示備註',
'costs.hideNote': '隱藏備註',
'costs.totalAmount': '總金額',
'costs.currency': '貨幣',
'costs.day': '日期',
+5
View File
@@ -88,6 +88,11 @@ const budget: TranslationStrings = {
'costs.undo': '撤销',
'costs.whatFor': '这笔花在哪了?',
'costs.namePlaceholder': '例如:晚餐、纪念品、油费…',
'costs.note': '备注',
'costs.notePlaceholder': '包含哪些内容、谁需要还款…',
'costs.addNote': '添加备注',
'costs.showNote': '显示备注',
'costs.hideNote': '隐藏备注',
'costs.totalAmount': '总金额',
'costs.currency': '货币',
'costs.day': '日期',