mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
a70eea5bf2
Travellers who track what a trip costs but never split it saw an Unfinished warning on every expense and a settle-up they had no use for. A trip now has a costs mode (appended migration, trips.costs_mode, split by default); totals leaves payers, shares and settling up out of Costs on desktop and phone through the same gate as a trip of one, and saves new expenses without a payer. PUT /api/trips/:id and update_trip take it on budget_edit. The split chips move into their own component.
496 lines
20 KiB
TypeScript
496 lines
20 KiB
TypeScript
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory, TripCostsMode } from '@trek/shared';
|
|
import { readDateFormat } from '../../hooks/useDateFormat';
|
|
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
|
|
import type { BudgetItem } from '../../types';
|
|
import { formatDisplayDate } from '../../utils/dateFormat';
|
|
import { currencyDecimals } from '../../utils/formatters';
|
|
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
|
|
import { hasTicketSplit, paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
|
|
import { catMeta, COST_CATEGORY_LIST } from './costsCategories';
|
|
|
|
/**
|
|
* Costs view-model: the ledger maths both Costs surfaces run on, the desktop
|
|
* `CostsPanel.tsx` and the phone `MCostsTab.tsx` (baseTotal, myPaidOf, the
|
|
* shares, isUnfinished, totals, filters, payments, the category breakdown and
|
|
* the CSV), so the two never disagree. Decoupled from React so it stays
|
|
* testable. Money is converted to the display/base currency via an injected
|
|
* `convert` (from `useExchangeRates`), never computed here.
|
|
*/
|
|
|
|
/** Per-render context every money computation needs — cheap to build, memoize in the caller. */
|
|
export interface CostsCtx {
|
|
me: number;
|
|
/** The trip's own currency — what a NULL `budget_items.currency` means. */
|
|
tripCurrency: string;
|
|
/** The currency everything is shown in, the base `convert` converts to. */
|
|
displayCurrency: string;
|
|
convert: (amount: number, currency: string | null | undefined) => number;
|
|
}
|
|
|
|
/** An expense's own currency, defaulting to the trip currency (NULL column = trip currency). */
|
|
export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
|
|
return (e.currency || ctx.tripCurrency).toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* An amount of this expense in the display currency, at the rate the expense was booked at;
|
|
* as typed when it was entered in the display currency (#2965).
|
|
*/
|
|
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
|
|
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
|
|
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
|
|
}
|
|
|
|
/**
|
|
* The line under an amount of this expense the list shows converted, what was entered and
|
|
* where it went (#2525); null when there is nothing to explain. `shown` is the display
|
|
* value printed beside it.
|
|
*/
|
|
export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) {
|
|
return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown);
|
|
}
|
|
|
|
/** Expense total converted to the display/base currency, at its booked rate. */
|
|
export function baseTotal(e: BudgetItem, ctx: CostsCtx): number {
|
|
return booked(e.total_price || 0, e, ctx);
|
|
}
|
|
|
|
/** How much `ctx.me` personally fronted for this expense, in the base currency. */
|
|
export function myPaidOf(e: BudgetItem, ctx: CostsCtx): number {
|
|
return booked(paidByUser(e, ctx.me), e, ctx);
|
|
}
|
|
|
|
/** A given member's share of this expense (explicit custom amount, else equal split), base currency. */
|
|
export function memberShareOf(e: BudgetItem, userId: number, ctx: CostsCtx): number {
|
|
const member = (e.members || []).find((m) => m.user_id === userId);
|
|
if (!member) return 0;
|
|
if (member.amount !== null && member.amount !== undefined) {
|
|
return booked(member.amount, e, ctx);
|
|
}
|
|
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id);
|
|
return booked(shares[userId] || 0, e, ctx);
|
|
}
|
|
|
|
/** `ctx.me`'s own share — the common case of {@link memberShareOf}. */
|
|
export function myShareOf(e: BudgetItem, ctx: CostsCtx): number {
|
|
return memberShareOf(e, ctx.me, ctx);
|
|
}
|
|
|
|
/** How a ledger counts `ctx.me`'s share of one expense, for the totals and the "owed" filter. */
|
|
export type ShareOf = (e: BudgetItem, ctx: CostsCtx) => number;
|
|
|
|
/**
|
|
* A recorded total nobody has actually paid yet — counts toward the trip total
|
|
* but stays out of settlement. A negative total (a refund, #2176) is just as
|
|
* unfinished until its recipient is recorded as the (negative) payer.
|
|
*/
|
|
export function isUnfinished(e: BudgetItem, ctx: CostsCtx): boolean {
|
|
return baseTotal(e, ctx) !== 0 && (e.payers || []).filter((p) => p.amount !== 0).length === 0;
|
|
}
|
|
|
|
/**
|
|
* `ctx.me`'s share as the desktop ledger counts it: nobody paid, so nobody owes.
|
|
* The ledger skips an unfinished expense entirely (#2225), and counting it here
|
|
* left the tile contradicting the balances right beside it.
|
|
*/
|
|
export function settledShareOf(e: BudgetItem, ctx: CostsCtx): number {
|
|
return isUnfinished(e, ctx) ? 0 : myShareOf(e, ctx);
|
|
}
|
|
|
|
// ── settlement (server-computed; these types describe what both ledgers read from it) ──
|
|
|
|
export interface CostsSettlementFlow {
|
|
from: { user_id: number; username: string; avatar_url?: string | null };
|
|
to: { user_id: number; username: string; avatar_url?: string | null };
|
|
amount: number;
|
|
}
|
|
|
|
export interface CostsBalance {
|
|
user_id: number;
|
|
username: string;
|
|
avatar_url: string | null;
|
|
balance: number;
|
|
}
|
|
|
|
/** A recorded settle-up transfer ("Ajouter un paiement") — history, not a suggestion like {@link CostsSettlementFlow}. */
|
|
export interface CostsSettlement {
|
|
id: number;
|
|
from_user_id: number;
|
|
to_user_id: number;
|
|
amount: number;
|
|
// Legacy rows predate this column (null) and are read as the display currency.
|
|
currency?: string | null;
|
|
// The rate frozen when the transfer was settled, units of `currency` per 1 trip
|
|
// currency (#1445). Absent, or exactly 1, on rows written before the freeze existed.
|
|
exchange_rate?: number;
|
|
created_at?: string;
|
|
// The day the transfer actually happened; editable, unlike created_at (when it
|
|
// was recorded). Null/absent on rows predating this field.
|
|
settled_at?: string | null;
|
|
/** A free-text note on the payment (#2340). */
|
|
note?: string | null;
|
|
}
|
|
|
|
/** The currency a recorded payment was entered in: legacy rows without one were in the display currency. */
|
|
function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
|
|
return (s.currency || ctx.displayCurrency).toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
|
|
* as entered when it was made in the display currency (#2965).
|
|
*/
|
|
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
|
|
const currency = paymentCurrency(s, ctx);
|
|
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
|
|
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
|
}
|
|
|
|
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
|
|
export function paymentLineOf(s: CostsSettlement, ctx: CostsCtx, shown: number) {
|
|
return convertedLine(
|
|
s.amount,
|
|
paymentCurrency(s, ctx),
|
|
s.exchange_rate,
|
|
ctx.tripCurrency,
|
|
ctx.displayCurrency,
|
|
shown
|
|
);
|
|
}
|
|
|
|
export interface CostsSettlementResponse {
|
|
balances: CostsBalance[];
|
|
flows: CostsSettlementFlow[];
|
|
settlements: CostsSettlement[];
|
|
/** What the trip ends up costing each participant — netted server-side off the same ledger as `balances`. */
|
|
finalBudgets: BudgetParticipantFinal[];
|
|
/** The currency the figures are in: the display currency, or the trip's own without a quote for it. */
|
|
currency?: string;
|
|
/** Rows no rate could convert, left out of every figure above. */
|
|
unconverted?: BudgetUnconverted;
|
|
}
|
|
|
|
// ── who paid and the shares: when Costs shows them ─────────────────────────
|
|
|
|
/**
|
|
* Whether Costs shows the splitting side: who paid, the shares, the balances
|
|
* and settling up. A trip that keeps its costs as totals only leaves it out
|
|
* (#1714), whatever is stored. A trip of one has nothing to split, since every
|
|
* share is the whole amount and nobody can owe anybody (#2689), so it is left
|
|
* out there too, unless the ledger still names someone else: a traveller who
|
|
* left the trip with money open, or members that have not loaded yet.
|
|
*/
|
|
export function showsSplitting(
|
|
costsMode: TripCostsMode | undefined,
|
|
memberCount: number,
|
|
items: BudgetItem[],
|
|
settlement: Pick<CostsSettlementResponse, 'flows' | 'balances'> | null,
|
|
me: number
|
|
): boolean {
|
|
if (costsMode === 'totals') return false;
|
|
if (memberCount > 1) return true;
|
|
const namesOthers = items.some((e) => [...(e.payers || []), ...(e.members || [])].some((p) => p.user_id !== me));
|
|
const moneyOpen = (settlement?.balances || []).some((b) => Math.abs(b.balance) >= 0.005);
|
|
return namesOthers || moneyOpen || (settlement?.flows || []).length > 0;
|
|
}
|
|
|
|
/**
|
|
* Whether an expense's payers and split hold nothing beyond the obvious, so an
|
|
* editor that leaves them out of sight loses nothing by it: at most one payer,
|
|
* an equal split, no receipt lines, and only people still on the trip. A new
|
|
* expense (null) has nothing yet.
|
|
*/
|
|
export function isPlainSplit(e: BudgetItem | null, memberIds: number[]): boolean {
|
|
if (!e) return true;
|
|
const payers = (e.payers || []).filter((p) => p.amount !== 0);
|
|
const members = e.members || [];
|
|
const people = [...payers, ...members].map((p) => p.user_id);
|
|
return (
|
|
payers.length <= 1 &&
|
|
!hasTicketSplit(e) &&
|
|
members.every((m) => m.amount === null || m.amount === undefined) &&
|
|
people.every((id) => memberIds.includes(id))
|
|
);
|
|
}
|
|
|
|
// ── hero / tile totals (spec §3.1-§3.3) ────────────────────────────────────
|
|
|
|
export interface CostsTotals {
|
|
totalSpend: number;
|
|
myPaid: number;
|
|
myShare: number;
|
|
owe: number;
|
|
owed: number;
|
|
outstanding: number;
|
|
outstandingCount: number;
|
|
}
|
|
|
|
export function computeTotals(
|
|
items: BudgetItem[],
|
|
flows: CostsSettlementFlow[],
|
|
ctx: CostsCtx,
|
|
shareOf: ShareOf = myShareOf
|
|
): CostsTotals {
|
|
const totalSpend = items.reduce((a, e) => a + baseTotal(e, ctx), 0);
|
|
const myPaid = items.reduce((a, e) => a + myPaidOf(e, ctx), 0);
|
|
const myShare = items.reduce((a, e) => a + shareOf(e, ctx), 0);
|
|
const owe = flows.filter((f) => f.from.user_id === ctx.me).reduce((a, f) => a + f.amount, 0);
|
|
const owed = flows.filter((f) => f.to.user_id === ctx.me).reduce((a, f) => a + f.amount, 0);
|
|
const outstandingItems = items.filter((e) => isUnfinished(e, ctx));
|
|
const outstanding = outstandingItems.reduce((a, e) => a + baseTotal(e, ctx), 0);
|
|
return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount: outstandingItems.length };
|
|
}
|
|
|
|
// ── expenses list: filter + group (spec §3.6-§3.7) ─────────────────────────
|
|
|
|
export type CostsSegment = 'all' | 'mine' | 'owed';
|
|
|
|
export interface CostsFilterState {
|
|
search: string;
|
|
segment: CostsSegment;
|
|
/** '' = all categories */
|
|
categoryKey: string;
|
|
/** '' = all days, else the expense's own YYYY-MM-DD */
|
|
dayKey: string;
|
|
}
|
|
|
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
|
|
/**
|
|
* `mine`/`owed` use the real balance, not the demo's `payer==='you'` shortcut
|
|
* (spec 03 §3.6): `mine` = I fronted money on it, `owed` = I'm net owed on it.
|
|
*/
|
|
export function filterBudgetItems(
|
|
items: BudgetItem[],
|
|
f: CostsFilterState,
|
|
ctx: CostsCtx,
|
|
shareOf: ShareOf = myShareOf
|
|
): BudgetItem[] {
|
|
let list = items.slice();
|
|
if (f.segment === 'mine') list = list.filter((e) => myPaidOf(e, ctx) > 0);
|
|
if (f.segment === 'owed') list = list.filter((e) => round2(myPaidOf(e, ctx) - shareOf(e, ctx)) > 0);
|
|
if (f.categoryKey) list = list.filter((e) => catMeta(e.category).key === f.categoryKey);
|
|
if (f.dayKey) list = list.filter((e) => (e.expense_date || '') === f.dayKey);
|
|
const q = f.search.trim().toLowerCase();
|
|
if (q) list = list.filter((e) => e.name.toLowerCase().includes(q));
|
|
return list;
|
|
}
|
|
|
|
export interface CostsDayGroup {
|
|
/** '' = no date (spec's "NO DATE" group) */
|
|
dateKey: string;
|
|
items: BudgetItem[];
|
|
}
|
|
|
|
/** Groups by `expense_date`, newest first, the no-date bucket sinking to the end (spec §3.7). */
|
|
export function groupByDay(items: BudgetItem[]): CostsDayGroup[] {
|
|
const byDate = new Map<string, BudgetItem[]>();
|
|
for (const e of items) {
|
|
const key = e.expense_date || '';
|
|
const bucket = byDate.get(key);
|
|
if (bucket) bucket.push(e);
|
|
else byDate.set(key, [e]);
|
|
}
|
|
const keys = Array.from(byDate.keys()).sort((a, b) => {
|
|
if (a === b) return 0;
|
|
if (a === '') return 1;
|
|
if (b === '') return -1;
|
|
return b.localeCompare(a);
|
|
});
|
|
return keys.map((dateKey) => ({ dateKey, items: byDate.get(dateKey) as BudgetItem[] }));
|
|
}
|
|
|
|
/**
|
|
* Settlements ("payments") shown inline in the ledger: they have no name or
|
|
* category, so a text or category filter hides them; "owed" excludes them;
|
|
* "mine" keeps only transfers the current user is part of.
|
|
*/
|
|
export function filterSettlements(settlements: CostsSettlement[], f: CostsFilterState, me: number): CostsSettlement[] {
|
|
if (f.search.trim() || f.categoryKey) return [];
|
|
if (f.segment === 'owed') return [];
|
|
let list = settlements.slice();
|
|
if (f.segment === 'mine') list = list.filter((s) => s.from_user_id === me || s.to_user_id === me);
|
|
if (f.dayKey) list = list.filter((s) => settlementDate(s) === f.dayKey);
|
|
return list;
|
|
}
|
|
|
|
export type CostsLedgerEntry =
|
|
{ kind: 'expense'; date: string; item: BudgetItem } | { kind: 'payment'; date: string; settlement: CostsSettlement };
|
|
|
|
export interface CostsLedgerDayGroup {
|
|
/** '' = no date (spec's "NO DATE" group) */
|
|
dateKey: string;
|
|
entries: CostsLedgerEntry[];
|
|
}
|
|
|
|
/**
|
|
* Like {@link groupByDay}, but also folds in settlement payments (see
|
|
* {@link filterSettlements}) as their own ledger entries, keyed by
|
|
* {@link settlementDate} — the mobile counterpart to desktop's unified
|
|
* `LedgerEntry` grouping, so a payment shows up even on a day with no expense.
|
|
*/
|
|
export function groupLedgerByDay(items: BudgetItem[], settlements: CostsSettlement[]): CostsLedgerDayGroup[] {
|
|
const entries: CostsLedgerEntry[] = [
|
|
...items.map((item) => ({ kind: 'expense' as const, date: item.expense_date || '', item })),
|
|
...settlements.map((settlement) => ({ kind: 'payment' as const, date: settlementDate(settlement), settlement })),
|
|
];
|
|
const byDate = new Map<string, CostsLedgerEntry[]>();
|
|
for (const en of entries) {
|
|
const bucket = byDate.get(en.date);
|
|
if (bucket) bucket.push(en);
|
|
else byDate.set(en.date, [en]);
|
|
}
|
|
const keys = Array.from(byDate.keys()).sort((a, b) => {
|
|
if (a === b) return 0;
|
|
if (a === '') return 1;
|
|
if (b === '') return -1;
|
|
return b.localeCompare(a);
|
|
});
|
|
return keys.map((dateKey) => ({ dateKey, entries: byDate.get(dateKey) as CostsLedgerEntry[] }));
|
|
}
|
|
|
|
/** Categories present among `items`, canonical order — the dropdown only lists categories in use (spec §3.6). */
|
|
export function categoryFilterKeys(items: BudgetItem[]): CostCategory[] {
|
|
const present = new Set(items.map((e) => catMeta(e.category).key));
|
|
return COST_CATEGORY_LIST.map((c) => c.key).filter((k) => present.has(k));
|
|
}
|
|
|
|
/** Distinct expense dates, ascending (spec §3.6: "Tage mit Ausgaben aufsteigend"). */
|
|
export function dayFilterKeys(items: BudgetItem[]): string[] {
|
|
const dates = new Set(items.map((e) => e.expense_date).filter((d): d is string => Boolean(d)));
|
|
return Array.from(dates).sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
// ── by-category breakdown (spec §3.5) ───────────────────────────────────────
|
|
|
|
export interface CostsCategoryBar {
|
|
key: CostCategory;
|
|
amount: number;
|
|
/** 0-100, relative to the largest category (not the grand total) — spec §3.5. */
|
|
widthPct: number;
|
|
}
|
|
|
|
export function categoryBreakdown(items: BudgetItem[], ctx: CostsCtx): CostsCategoryBar[] {
|
|
// Categories net refunds against spend (#2176): a negative entry lowers its
|
|
// category's sum. A category that nets negative keeps its own row at the
|
|
// bottom, with widthPct 0 — the bars rank positive spend, and a negative
|
|
// CSS width would be dropped and render as a full bar.
|
|
const totals = new Map<CostCategory, number>();
|
|
for (const e of items) {
|
|
const key = catMeta(e.category).key;
|
|
totals.set(key, (totals.get(key) || 0) + baseTotal(e, ctx));
|
|
}
|
|
const rows = COST_CATEGORY_LIST.map((c) => ({ key: c.key, amount: totals.get(c.key) || 0 }))
|
|
.filter((r) => r.amount !== 0)
|
|
.sort((a, b) => b.amount - a.amount);
|
|
const max = Math.max(0, ...rows.map((r) => r.amount));
|
|
return rows.map((r) => ({ ...r, widthPct: max > 0 && r.amount > 0 ? (r.amount / max) * 100 : 0 }));
|
|
}
|
|
|
|
// ── presentation helpers ─────────────────────────────────────────────────
|
|
|
|
/** Category colour at a fixed alpha — the expense card's border tint (spec §3.10: "brC"). */
|
|
export function tint(hex: string, alpha: number): string {
|
|
const clean = hex.replace('#', '');
|
|
const r = Number.parseInt(clean.slice(0, 2), 16);
|
|
const g = Number.parseInt(clean.slice(2, 4), 16);
|
|
const b = Number.parseInt(clean.slice(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
|
|
// ── CSV export (spec §3.8 `csvGo`; string-building only — the caller turns this into a download) ──
|
|
|
|
export interface CsvBuildOptions {
|
|
base: string;
|
|
ctx: CostsCtx;
|
|
locale: string;
|
|
tripTitle?: string | null;
|
|
t: (key: string) => string;
|
|
/** The Currency column; {@link currencyOf} unless the caller writes it as stored. */
|
|
currencyOf?: (e: BudgetItem) => string;
|
|
}
|
|
|
|
/** The Costs CSV export's rows, one per expense, oldest first; the Blob/download is a DOM concern left to the caller. */
|
|
export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { filename: string; content: string } {
|
|
const sep = ';';
|
|
// A cell starting with =, +, -, @, TAB or CR is evaluated as a formula by Excel
|
|
// and Sheets, and the name/note columns are free text any trip member can write.
|
|
const esc = (v: unknown) => {
|
|
let s = String(v ?? '');
|
|
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
|
|
return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
};
|
|
const fmtDate = (iso: string) => {
|
|
if (!iso) return '';
|
|
try {
|
|
return formatDisplayDate(
|
|
new Date(iso + 'T00:00:00Z'),
|
|
opts.locale,
|
|
{
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
timeZone: 'UTC',
|
|
},
|
|
readDateFormat()
|
|
);
|
|
} catch {
|
|
return iso;
|
|
}
|
|
};
|
|
|
|
// Read in another currency than the trip's, what each row counts as in the trip currency
|
|
// too, the figure every sum is built from (#2525).
|
|
const trip = opts.ctx.tripCurrency.toUpperCase();
|
|
const tripCol = trip !== opts.base.toUpperCase();
|
|
const header = [
|
|
'Date',
|
|
'Name',
|
|
'Category',
|
|
'Amount',
|
|
'Currency',
|
|
...(tripCol ? [`Amount (${trip})`] : []),
|
|
`Amount (${opts.base})`,
|
|
'Note',
|
|
];
|
|
const rows = [header.join(sep)];
|
|
const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || ''));
|
|
for (const e of sorted) {
|
|
const cur = opts.currencyOf ? opts.currencyOf(e) : currencyOf(e, opts.ctx);
|
|
const note = readUserNote(e);
|
|
const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert);
|
|
rows.push(
|
|
[
|
|
esc(fmtDate(e.expense_date || '')),
|
|
esc(e.name),
|
|
esc(opts.t(catMeta(e.category).labelKey)),
|
|
(e.total_price || 0).toFixed(currencyDecimals(cur)),
|
|
cur,
|
|
...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []),
|
|
baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)),
|
|
esc(note),
|
|
].join(sep)
|
|
);
|
|
}
|
|
|
|
const safeName = (opts.tripTitle || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim();
|
|
return { filename: `costs-${safeName}.csv`, content: rows.join('\r\n') };
|
|
}
|
|
|
|
/**
|
|
* The currency picker of every Costs form (expense and payment, desktop and
|
|
* phone): the supported currencies plus the one already chosen, each labelled
|
|
* with its code and, when it has one, its symbol. `pinned` ones lead the list
|
|
* in the order given, so the few a trip really uses are not buried in the
|
|
* alphabet (#2807).
|
|
*/
|
|
export function currencyOptions(
|
|
current?: string | null,
|
|
pinned: readonly string[] = []
|
|
): { value: string; label: string }[] {
|
|
const all = currenciesWith(current);
|
|
const lead = [...new Set(pinned.map((c) => c.toUpperCase()))].filter((c) => all.includes(c));
|
|
const ordered = [...lead, ...all.filter((c) => !lead.includes(c))];
|
|
return ordered.map((c) => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }));
|
|
}
|