mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link GET /api/journeys/:id/share-link now requires journey access (canAccessJourney), matching the create/delete share-link routes and the get_journey_share_link MCP tool. Returns no link when the caller lacks access to the journey. * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/ Splitwise-style cost tracker: multiple payers per expense, equal split across chosen members, settle-up with persisted history + undo, 12 fixed categories, per-expense currency with live FX conversion to a user-set display currency (Settings -> Display), and locale-correct money formatting. Adds a desktop and a dedicated mobile layout. A migration backfills existing budget items (single payer, split members, currency). Closes #551 (per-expense currency). Also switches the app font to self-hosted Poppins (Geist for secondary subtext), replacing the Google Fonts CDN dependency. * fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge - Dark mode used a warm oklch palette that read brownish; switch to the neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a subtle backdrop-blur glass on cards. - Costs now uses the full available page width on desktop instead of a 1280px cap. - Render the expense count next to the Expenses title as a badge. - Adapt budget/journey unit tests to the new payer-based settlement model and the Costs rename (category default 'other', Costs tab/CostsPanel). * fix(costs): drop the entry-count badge, always show row edit/delete actions Removes the count badge next to the Expenses title and makes the per-row edit/delete actions permanently visible (no longer hover-only) on desktop too. * feat(costs): currency-native money formatting, custom select/date, rename addon to Costs - Format every amount in its own currency convention (symbol position, grouping and decimal separators) regardless of app language, via a currency->locale map (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the app locale, so EUR showed the symbol in front under an English UI. - Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the add/edit expense modal instead of the native <select>/<input type=date>. - Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only; id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs. * feat(auth): configurable session duration via SESSION_DURATION Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling how long a session stays valid before re-login. It drives both the trek_session JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid values warn at startup and fall back to the default (24h — unchanged). The MFA challenge token and MCP OAuth tokens keep their own TTL. Implements the request from discussion #946. Documented in the env-var wiki page, .env.example and docker-compose.yml.
This commit is contained in:
@@ -19,7 +19,6 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
|
|
||||||
<!-- Leaflet -->
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/geist-sans": "^5.2.5",
|
||||||
|
"@fontsource/poppins": "^5.2.7",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
@@ -551,8 +551,11 @@ export const budgetApi = {
|
|||||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds } satisfies BudgetUpdateMembersRequest).then(r => r.data),
|
||||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid } satisfies BudgetToggleMemberPaidRequest).then(r => r.data),
|
||||||
|
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||||
|
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||||
|
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,814 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { budgetApi } from '../../api/client'
|
||||||
|
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||||
|
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
|
||||||
|
import Modal from '../shared/Modal'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||||
|
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
import type { TripMember } from './BudgetPanelMemberChips'
|
||||||
|
|
||||||
|
interface CostsPanelProps {
|
||||||
|
tripId: number
|
||||||
|
tripMembers?: TripMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settlement {
|
||||||
|
id: number
|
||||||
|
from_user_id: number
|
||||||
|
to_user_id: number
|
||||||
|
amount: number
|
||||||
|
created_at?: string
|
||||||
|
from_username?: string
|
||||||
|
to_username?: string
|
||||||
|
}
|
||||||
|
interface SettlementData {
|
||||||
|
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
|
||||||
|
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
|
||||||
|
settlements: Settlement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||||
|
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||||
|
|
||||||
|
export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps) {
|
||||||
|
const { trip, budgetItems, deleteBudgetItem, loadBudgetItems } = useTripStore()
|
||||||
|
const me = useAuthStore(s => s.user?.id ?? -1)
|
||||||
|
const can = useCanDo()
|
||||||
|
const canEdit = can('budget_edit', trip)
|
||||||
|
const toast = useToast()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
// Display/base currency = the user's preferred currency (Settings), falling back
|
||||||
|
// to the trip's own currency. Everything in Costs is converted to and shown in it.
|
||||||
|
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||||
|
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||||
|
// Pre-rework rows stored currency = NULL, meaning "the trip's own currency".
|
||||||
|
const tripCurrency = (trip?.currency || base).toUpperCase()
|
||||||
|
const { convert } = useExchangeRates(base)
|
||||||
|
const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency])
|
||||||
|
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||||
|
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [histOpen, setHistOpen] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||||
|
|
||||||
|
const people = tripMembers
|
||||||
|
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||||
|
const personName = useCallback((id: number) => id === me ? t('costs.you') : (personById(id)?.username || '?'), [me, personById, t])
|
||||||
|
const colorFor = useCallback((id: number) => {
|
||||||
|
const idx = people.findIndex(p => p.id === id)
|
||||||
|
return SPLIT_COLORS[(idx >= 0 ? idx : 0) % SPLIT_COLORS.length].gradient
|
||||||
|
}, [people])
|
||||||
|
const initial = useCallback((id: number) => id === me ? t('costs.youShort') : (personById(id)?.username || '?').charAt(0).toUpperCase(), [me, personById, t])
|
||||||
|
|
||||||
|
const fmt = useCallback((v: number, c = base) => formatMoney(v, c, locale), [base, locale])
|
||||||
|
const fmt0 = useCallback((v: number, c = base) => formatMoney(v, c, locale, { decimals: 0 }), [base, locale])
|
||||||
|
|
||||||
|
const loadSettlement = useCallback(() => {
|
||||||
|
budgetApi.settlement(tripId, base).then(setSettlement).catch(() => {})
|
||||||
|
}, [tripId, base])
|
||||||
|
|
||||||
|
useEffect(() => { loadBudgetItems(tripId); loadSettlement() }, [tripId])
|
||||||
|
useEffect(() => { loadSettlement() }, [budgetItems.length, base])
|
||||||
|
|
||||||
|
// The bottom-nav "+" on the Costs tab opens the add-expense modal via ?create=expense.
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('create') === 'expense') {
|
||||||
|
setEditing(null); setModalOpen(true)
|
||||||
|
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
// ── derived expense maths (everything converted to the base currency) ────
|
||||||
|
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||||
|
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||||
|
const myShareOf = (e: BudgetItem) => {
|
||||||
|
const n = (e.members || []).length
|
||||||
|
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
|
||||||
|
return baseTotal(e) / n
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||||
|
const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0)
|
||||||
|
const myShare = budgetItems.reduce((a, e) => a + myShareOf(e), 0)
|
||||||
|
const owe = (settlement?.flows || []).filter(f => f.from.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||||
|
const owed = (settlement?.flows || []).filter(f => f.to.user_id === me).reduce((a, f) => a + f.amount, 0)
|
||||||
|
return { totalSpend, myPaid, myShare, owe, owed }
|
||||||
|
}, [budgetItems, settlement, me])
|
||||||
|
|
||||||
|
// ── filtering + day grouping ────────────────────────────────────────────
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = budgetItems.slice()
|
||||||
|
if (filter === 'mine') list = list.filter(e => myPaidOf(e) > 0)
|
||||||
|
if (filter === 'owed') list = list.filter(e => round2(myPaidOf(e) - myShareOf(e)) > 0)
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
if (q) list = list.filter(e => e.name.toLowerCase().includes(q))
|
||||||
|
return list
|
||||||
|
}, [budgetItems, filter, search, me])
|
||||||
|
|
||||||
|
const dayGroups = useMemo(() => {
|
||||||
|
const groups: { day: string; items: BudgetItem[] }[] = []
|
||||||
|
const labelOf = (e: BudgetItem) => {
|
||||||
|
if (!e.expense_date) return t('costs.noDate')
|
||||||
|
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
||||||
|
}
|
||||||
|
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
||||||
|
for (const e of sorted) {
|
||||||
|
const day = labelOf(e)
|
||||||
|
let g = groups.find(x => x.day === day)
|
||||||
|
if (!g) { g = { day, items: [] }; groups.push(g) }
|
||||||
|
g.items.push(e)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}, [filtered, locale, t])
|
||||||
|
|
||||||
|
// ── settle actions ──────────────────────────────────────────────────────
|
||||||
|
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||||
|
try {
|
||||||
|
await budgetApi.createSettlement(tripId, { from_user_id: fromId, to_user_id: toId, amount })
|
||||||
|
loadSettlement()
|
||||||
|
} catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
const undoSettlement = async (id: number) => {
|
||||||
|
try { await budgetApi.deleteSettlement(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
const settleAll = async () => {
|
||||||
|
const flows = settlement?.flows || []
|
||||||
|
if (!flows.length) return
|
||||||
|
try {
|
||||||
|
for (const f of flows) await budgetApi.createSettlement(tripId, { from_user_id: f.from.user_id, to_user_id: f.to.user_id, amount: f.amount })
|
||||||
|
loadSettlement()
|
||||||
|
} catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMeta = useMemo(() => {
|
||||||
|
if (!trip?.start_date || !trip?.end_date) return null
|
||||||
|
try {
|
||||||
|
const s = new Date(trip.start_date + 'T00:00:00Z'), e = new Date(trip.end_date + 'T00:00:00Z')
|
||||||
|
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1
|
||||||
|
const opt = { day: 'numeric', month: 'short', timeZone: 'UTC' } as const
|
||||||
|
return { range: `${s.toLocaleDateString(locale, opt)} – ${e.toLocaleDateString(locale, opt)}`, days }
|
||||||
|
} catch { return null }
|
||||||
|
}, [trip?.start_date, trip?.end_date, locale])
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try { await deleteBudgetItem(tripId, id); loadSettlement() } catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small presentational helpers ────────────────────────────────────────
|
||||||
|
const Avatar = ({ id, size = 24 }: { id: number; size?: number }) => {
|
||||||
|
const url = personById(id)?.avatar_url
|
||||||
|
if (url) return <img src={url} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, display: 'block' }} />
|
||||||
|
return <span style={{ width: size, height: size, borderRadius: '50%', background: colorFor(id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: size * 0.4, fontWeight: 700, flexShrink: 0 }}>{initial(id)}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardCls = 'bg-surface-card border border-edge'
|
||||||
|
const labelCls = 'text-[11px] font-semibold uppercase tracking-[0.12em] text-content-faint'
|
||||||
|
|
||||||
|
// Big money number with the design's muted symbol/decimals, locale-correct via Intl.
|
||||||
|
const bigMoney = (amount: number, smallSize: number, mutedColor: string) => {
|
||||||
|
let parts: Intl.NumberFormatPart[] | null = null
|
||||||
|
try {
|
||||||
|
const d = currencyDecimals(base)
|
||||||
|
parts = new Intl.NumberFormat(currencyLocale(base), { style: 'currency', currency: base, minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
||||||
|
} catch { return <>{formatMoney(amount, base, locale)}</> }
|
||||||
|
const isBig = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
||||||
|
return <>{parts.map((p, i) => <span key={i} style={isBig(p) ? undefined : { fontSize: smallSize, fontWeight: 500, color: mutedColor }}>{p.value}</span>)}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="costs-root" style={{ minHeight: '100%', background: 'var(--c-bg)', padding: isMobile ? '6px 14px 28px' : '40px 24px 48px' }}>
|
||||||
|
{isMobile ? <MobileBody /> : (
|
||||||
|
<div style={{ maxWidth: '100%', margin: '0 auto' }}>
|
||||||
|
{/* ── Header bar ── */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{dateMeta && (
|
||||||
|
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 999, fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||||
|
{dateMeta.range} · <b className="text-content">{t('costs.daysCount', { count: dateMeta.days })}</b>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px 8px 10px', borderRadius: 999, fontSize: 13, fontWeight: 500 }}>
|
||||||
|
<span style={{ display: 'inline-flex' }}>
|
||||||
|
{people.slice(0, 4).map((p, i) => {
|
||||||
|
const common = { width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--bg-card)', marginLeft: i ? -8 : 0, flexShrink: 0 } as const
|
||||||
|
return p.avatar_url
|
||||||
|
? <img key={p.id} src={p.avatar_url} alt="" style={{ ...common, objectFit: 'cover', display: 'block' }} />
|
||||||
|
: <span key={p.id} style={{ ...common, background: colorFor(p.id), color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<b className="text-content">{t('costs.travelers', { count: people.length })}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
|
||||||
|
className="bg-surface-card border border-edge text-content disabled:opacity-40"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Check size={16} /> {t('costs.settleUp')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditing(null); setModalOpen(true) }}
|
||||||
|
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 14, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={16} /> {t('costs.addExpense')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Summary cards ── */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.15fr', gap: 16, marginBottom: 36 }} className="costs-summary">
|
||||||
|
<SummaryCard label={t('costs.youOwe')} sub={t('costs.youOweSub')} amount={totals.owe} currency={base} locale={locale}
|
||||||
|
icon={<ArrowDown size={18} />} tone="owe"
|
||||||
|
foot={totals.owe > 0.01
|
||||||
|
? <FlowPills ids={(settlement?.flows || []).filter(f => f.from.user_id === me).map(f => f.to.user_id)} lead={t('costs.to')} Avatar={Avatar} name={personName} />
|
||||||
|
: <span className="text-content-faint">{t('costs.allSettled')}</span>} />
|
||||||
|
<SummaryCard label={t('costs.youreOwed')} sub={t('costs.youreOwedSub')} amount={totals.owed} currency={base} locale={locale}
|
||||||
|
icon={<ArrowUp size={18} />} tone="owed"
|
||||||
|
foot={totals.owed > 0.01
|
||||||
|
? <FlowPills ids={(settlement?.flows || []).filter(f => f.to.user_id === me).map(f => f.from.user_id)} lead={t('costs.from')} Avatar={Avatar} name={personName} />
|
||||||
|
: <span className="text-content-faint">{t('costs.nothingOwed')}</span>} />
|
||||||
|
<SummaryCard label={t('costs.totalSpend')} sub={t('costs.totalSpendSub')} amount={totals.totalSpend} currency={base} locale={locale}
|
||||||
|
icon={<BarChart3 size={18} />} tone="total"
|
||||||
|
foot={<span style={{ display: 'flex', gap: 16 }}><span>{t('costs.yourShare')} · <b>{fmt0(totals.myShare)}</b></span><span>{t('costs.youPaid')} · <b>{fmt0(totals.myPaid)}</b></span></span>} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main grid ── */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }} className="costs-grid">
|
||||||
|
{/* expenses */}
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<h3 className="text-content" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.025em', margin: 0 }}>
|
||||||
|
{t('costs.expenses')}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 6, borderRadius: 10, padding: '0 10px', height: 34 }}>
|
||||||
|
<Search size={15} className="text-content-faint" />
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')}
|
||||||
|
className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 13, width: 150, fontFamily: 'inherit' }} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
|
||||||
|
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||||
|
<button key={f} onClick={() => setFilter(f)}
|
||||||
|
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||||
|
style={{ padding: '6px 11px', fontSize: 12, borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
{t('costs.filter.' + f)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dayGroups.length === 0 ? (
|
||||||
|
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||||
|
</div>
|
||||||
|
) : dayGroups.map(g => {
|
||||||
|
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||||
|
return (
|
||||||
|
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||||
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||||
|
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* sidebar */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* settle up */}
|
||||||
|
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
|
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||||
|
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
||||||
|
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SettleFlows />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* balances */}
|
||||||
|
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||||
|
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||||
|
<BalancesList balances={settlement?.balances || []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* by category */}
|
||||||
|
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||||
|
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||||
|
<CategoryBreakdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{modalOpen && (
|
||||||
|
<ExpenseModal tripId={tripId} base={base} people={people} me={me} editing={editing}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
||||||
|
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.costs-root {
|
||||||
|
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||||
|
--c-surface: #ffffff; --c-surface2: oklch(0.985 0.006 78);
|
||||||
|
--c-ink: oklch(0.22 0.012 65); --c-ink2: oklch(0.42 0.012 65); --c-ink3: oklch(0.62 0.01 65);
|
||||||
|
--c-line: oklch(0.92 0.008 70);
|
||||||
|
}
|
||||||
|
html.dark .costs-root {
|
||||||
|
--c-bg: #121215; --c-bg2: #18181c;
|
||||||
|
--c-surface: #1a1a1e; --c-surface2: #202027;
|
||||||
|
--c-ink: #f4f4f5; --c-ink2: #a1a1aa; --c-ink3: #71717a;
|
||||||
|
--c-line: #2a2a31;
|
||||||
|
}
|
||||||
|
.costs-root .bg-surface-card { background: var(--c-surface) !important; }
|
||||||
|
.costs-root .bg-surface-secondary, .costs-root .bg-surface-input { background: var(--c-surface2) !important; }
|
||||||
|
.costs-root .border-edge { border-color: var(--c-line) !important; }
|
||||||
|
/* dark = neutral zinc + a touch of liquid glass, matching the dashboard */
|
||||||
|
html.dark .costs-root .bg-surface-card {
|
||||||
|
background: rgba(255,255,255,0.035) !important;
|
||||||
|
border-color: rgba(255,255,255,0.08) !important;
|
||||||
|
backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||||
|
}
|
||||||
|
html.dark .costs-root .bg-surface-secondary,
|
||||||
|
html.dark .costs-root .bg-surface-input { background: rgba(255,255,255,0.05) !important; }
|
||||||
|
html.dark .costs-root .border-edge { border-color: rgba(255,255,255,0.08) !important; }
|
||||||
|
.costs-root .text-content { color: var(--c-ink) !important; }
|
||||||
|
.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; }
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.costs-root .costs-summary { grid-template-columns: 1fr !important; }
|
||||||
|
.costs-root .costs-grid { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── shared settle-flow list ──────────────────────────────────────────────
|
||||||
|
function SettleFlows() {
|
||||||
|
const flows = settlement?.flows || []
|
||||||
|
if (flows.length === 0) return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '14px 8px' }}>
|
||||||
|
<div style={{ width: 46, height: 46, borderRadius: '50%', margin: '0 auto 10px', display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><Check size={22} /></div>
|
||||||
|
<div className="text-content" style={{ fontSize: 14.5, fontWeight: 600 }}>{t('costs.everyoneSquare')}</div>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('costs.nothingOutstanding')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{flows.map((f, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${personName(f.from.user_id)} → ${f.to.user_id === me ? t('costs.youLower') : personName(f.to.user_id)}`}>
|
||||||
|
<Avatar id={f.from.user_id} size={32} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={f.to.user_id} size={32} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 700 }}>{fmt(f.amount)}</span>
|
||||||
|
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 12, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mobile layout (Budget1Mobile.html): single flat column, total card on top ──
|
||||||
|
function MobileBody() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
|
||||||
|
{/* Total card */}
|
||||||
|
<section style={{ background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff', borderRadius: 22, padding: '20px 20px 16px', boxShadow: '0 8px 24px -8px rgba(0,0,0,0.28)' }}>
|
||||||
|
<div style={{ fontSize: 11.5, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>{t('costs.totalSpend')}</div>
|
||||||
|
<div style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.04em', lineHeight: 1, marginTop: 8, display: 'flex', alignItems: 'baseline' }}>{bigMoney(totals.totalSpend, 24, 'rgba(255,255,255,0.6)')}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 18, marginTop: 12, fontSize: 12, color: 'rgba(255,255,255,0.6)', flexWrap: 'wrap' }}>
|
||||||
|
<span>{t('costs.yourShare')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myShare)}</b></span>
|
||||||
|
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={17} /> {t('costs.addExpense')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Owe / Owed */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
|
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#dc262622', color: '#dc2626' }}><ArrowDown size={17} /></div>
|
||||||
|
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youOwe')}</div>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youOweSub')}</div>
|
||||||
|
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#dc2626' }}>{bigMoney(totals.owe, 16, 'var(--c-ink3)')}</div>
|
||||||
|
</div>
|
||||||
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
|
<div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', marginBottom: 10, background: '#16a34a22', color: '#16a34a' }}><ArrowUp size={17} /></div>
|
||||||
|
<div className="text-content" style={{ fontSize: 12.5, fontWeight: 600 }}>{t('costs.youreOwed')}</div>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 10.5 }}>{t('costs.youreOwedSub')}</div>
|
||||||
|
<div style={{ fontSize: 27, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, marginTop: 12, display: 'flex', alignItems: 'baseline', color: '#16a34a' }}>{bigMoney(totals.owed, 16, 'var(--c-ink3)')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settle up */}
|
||||||
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||||
|
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
||||||
|
</div>
|
||||||
|
<SettleFlows />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expenses */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
|
||||||
|
<div className="bg-surface-card border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 8, borderRadius: 12, padding: '0 12px', height: 42 }}>
|
||||||
|
<Search size={16} className="text-content-faint" />
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder={t('costs.searchPlaceholder')} className="text-content" style={{ border: 0, background: 'none', outline: 'none', fontSize: 14, width: '100%', fontFamily: 'inherit' }} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
|
||||||
|
{(['all', 'mine', 'owed'] as const).map(f => (
|
||||||
|
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 12.5, fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{dayGroups.length === 0
|
||||||
|
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||||
|
: dayGroups.map(g => {
|
||||||
|
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||||
|
return (
|
||||||
|
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balances */}
|
||||||
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
|
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.balances')}</div>
|
||||||
|
<BalancesList balances={settlement?.balances || []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By category */}
|
||||||
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
|
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||||
|
<CategoryBreakdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── inline subcomponents (close over helpers) ────────────────────────────
|
||||||
|
function ExpenseRow({ e }: { e: BudgetItem }) {
|
||||||
|
const c = catMeta(e.category)
|
||||||
|
const Icon = c.Icon
|
||||||
|
const cur = curOf(e)
|
||||||
|
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||||
|
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||||
|
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' }}>
|
||||||
|
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
||||||
|
{payers.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||||
|
{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: 11.5 }}>
|
||||||
|
<Avatar id={p.user_id} size={18} />
|
||||||
|
<span className="text-content" style={{ fontWeight: 700 }}>{fmt(convert(p.amount, cur))}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||||
|
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||||
|
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||||
|
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||||
|
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||||
|
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{rows.map(r => {
|
||||||
|
const pct = Math.min(100, Math.abs(r.balance) / max * 100)
|
||||||
|
const pos = r.balance > 0.01, neg = r.balance < -0.01
|
||||||
|
return (
|
||||||
|
<div key={r.user_id} style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||||
|
<Avatar id={r.user_id} size={28} />
|
||||||
|
<div>
|
||||||
|
<div className="text-content" style={{ fontSize: 13, fontWeight: 600 }}>{personName(r.user_id)}</div>
|
||||||
|
<div className="bg-surface-secondary" style={{ height: 5, borderRadius: 3, marginTop: 5, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<span style={{ position: 'absolute', left: '50%', top: -1, bottom: -1, width: 1, background: 'var(--border-primary)' }} />
|
||||||
|
{pos && <span style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#16a34a', borderRadius: 3 }} />}
|
||||||
|
{neg && <span style={{ position: 'absolute', right: '50%', top: 0, bottom: 0, width: pct / 2 + '%', background: '#dc2626', borderRadius: 3 }} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, textAlign: 'right', color: pos ? '#16a34a' : neg ? '#dc2626' : 'var(--text-faint)' }}>
|
||||||
|
{pos ? '+' + fmt(r.balance) : neg ? '−' + fmt(-r.balance) : fmt(0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBreakdown() {
|
||||||
|
const tot: Record<string, number> = {}
|
||||||
|
let grand = 0
|
||||||
|
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
||||||
|
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||||
|
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{rows.map(c => {
|
||||||
|
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||||
|
<span className="text-content" style={{ fontSize: 13, fontWeight: 500 }}>{t(c.labelKey)}</span>
|
||||||
|
<span className="text-content-muted" style={{ fontSize: 13, fontWeight: 600 }}>{fmt0(v)}</span>
|
||||||
|
<div className="bg-surface-secondary" style={{ gridColumn: '1 / -1', height: 5, borderRadius: 3, overflow: 'hidden', marginTop: -2 }}>
|
||||||
|
<span style={{ display: 'block', height: '100%', width: pct + '%', background: c.color, borderRadius: 3 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pure subcomponents ─────────────────────────────────────────────────────
|
||||||
|
function SummaryCard({ label, sub, amount, currency, locale, icon, foot, tone }: { label: string; sub: string; amount: number; currency: string; locale: string; icon: React.ReactNode; foot: React.ReactNode; tone: 'owe' | 'owed' | 'total' }) {
|
||||||
|
const total = tone === 'total'
|
||||||
|
const accent = tone === 'owe' ? '#dc2626' : tone === 'owed' ? '#16a34a' : undefined
|
||||||
|
const muted = total ? 'rgba(255,255,255,0.55)' : 'var(--text-faint)'
|
||||||
|
// formatToParts keeps the design's "big integer + muted symbol/decimals" styling
|
||||||
|
// while letting Intl place the symbol and pick separators per locale + currency.
|
||||||
|
let parts: Intl.NumberFormatPart[] | null = null
|
||||||
|
try {
|
||||||
|
const d = currencyDecimals(currency)
|
||||||
|
parts = new Intl.NumberFormat(currencyLocale(currency), { style: 'currency', currency: (currency || 'EUR').toUpperCase(), minimumFractionDigits: d, maximumFractionDigits: d }).formatToParts(amount || 0)
|
||||||
|
} catch { parts = null }
|
||||||
|
const big = (p: Intl.NumberFormatPart) => p.type === 'integer' || p.type === 'group' || p.type === 'minusSign'
|
||||||
|
return (
|
||||||
|
<div className={total ? '' : 'bg-surface-card border border-edge'}
|
||||||
|
style={{ borderRadius: 22, padding: '26px 28px', position: 'relative', overflow: 'hidden', ...(total ? { background: 'linear-gradient(135deg,#1f2937,#111827)', color: '#fff' } : {}) }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||||
|
<span style={{ width: 36, height: 36, borderRadius: 11, display: 'grid', placeItems: 'center', background: total ? 'rgba(255,255,255,0.12)' : (accent + '22'), color: total ? '#fff' : accent }}>{icon}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }} className={total ? '' : 'text-content'}>{label}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: total ? 0.6 : 1 }} className={total ? '' : 'text-content-faint'}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 46, fontWeight: 600, letterSpacing: '-0.035em', lineHeight: 1, marginTop: 20, display: 'flex', alignItems: 'baseline', color: total ? '#fff' : accent }}>
|
||||||
|
{parts
|
||||||
|
? parts.map((p, i) => <span key={i} style={big(p) ? undefined : { fontSize: 26, fontWeight: 500, color: muted }}>{p.value}</span>)
|
||||||
|
: <span>{formatMoney(amount, currency, locale)}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', opacity: total ? 0.85 : 1 }}>{foot}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string }) {
|
||||||
|
const uniq = Array.from(new Set(ids))
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<span className="text-content-faint">{lead}</span>
|
||||||
|
{uniq.map(id => (
|
||||||
|
<span key={id} className="bg-surface-secondary border border-edge text-content" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 10px 3px 3px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
|
||||||
|
<Avatar id={id} size={18} />{name(id)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||||
|
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
||||||
|
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
||||||
|
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{settlements.map(s => (
|
||||||
|
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
||||||
|
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
||||||
|
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||||
|
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||||
|
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const { addBudgetItem, updateBudgetItem } = useTripStore()
|
||||||
|
const { convert } = useExchangeRates(base)
|
||||||
|
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||||
|
|
||||||
|
const [name, setName] = useState(editing?.name || '')
|
||||||
|
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
||||||
|
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||||
|
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||||
|
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||||
|
const m: Record<number, string> = {}
|
||||||
|
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
const [split, setSplit] = useState<Set<number>>(() =>
|
||||||
|
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||||
|
const each = split.size > 0 ? payersTotal / split.size : 0
|
||||||
|
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!valid) return
|
||||||
|
setSaving(true)
|
||||||
|
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
||||||
|
const data = {
|
||||||
|
name: name.trim(), category: cat,
|
||||||
|
// Store the actual currency the amounts were entered in; conversion to the
|
||||||
|
// viewer's display currency happens live (real rates), no manual rate.
|
||||||
|
currency,
|
||||||
|
payers: payerList, member_ids: [...split],
|
||||||
|
expense_date: day || null,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||||
|
else await addBudgetItem(tripId, data)
|
||||||
|
onSaved()
|
||||||
|
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size="2xl"
|
||||||
|
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: 13, 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: 13, 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 }}>
|
||||||
|
<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: 14, outline: 'none' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||||
|
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||||
|
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<label className={labelCls}>{t('costs.currency')}</label>
|
||||||
|
<CustomSelect value={currency} onChange={v => setCurrency(String(v))} searchable
|
||||||
|
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||||
|
style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<label className={labelCls}>{t('costs.day')}</label>
|
||||||
|
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currency !== base && payersTotal > 0 && (
|
||||||
|
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
||||||
|
<span className="text-content-faint">≈</span>
|
||||||
|
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
||||||
|
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.category')}</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||||
|
{COST_CATEGORY_LIST.map(c => {
|
||||||
|
const Icon = c.Icon; const on = cat === c.key
|
||||||
|
return (
|
||||||
|
<button key={c.key} onClick={() => setCat(c.key)}
|
||||||
|
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||||
|
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>
|
||||||
|
{t(c.labelKey)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
|
{people.map(p => (
|
||||||
|
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
||||||
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||||
|
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||||
|
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
||||||
|
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
||||||
|
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||||
|
{people.map(p => {
|
||||||
|
const on = split.has(p.id)
|
||||||
|
return (
|
||||||
|
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
||||||
|
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||||
|
{p.avatar_url
|
||||||
|
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
||||||
|
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||||
|
{p.id === me ? t('costs.you') : p.username}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
||||||
|
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixed Costs categories. Users can't add their own — every expense maps to
|
||||||
|
* one of these. Category colour is the one place an accent is allowed (it
|
||||||
|
* visualises the category); everything else stays black/white. The label comes
|
||||||
|
* from i18n (`costs.cat.*`).
|
||||||
|
*/
|
||||||
|
export interface CostCategoryMeta {
|
||||||
|
key: CostCategory
|
||||||
|
labelKey: string
|
||||||
|
Icon: LucideIcon
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||||
|
accommodation: { key: 'accommodation', labelKey: 'costs.cat.accommodation', Icon: Hotel, color: '#16a34a' },
|
||||||
|
food: { key: 'food', labelKey: 'costs.cat.food', Icon: Utensils, color: '#ea580c' },
|
||||||
|
groceries: { key: 'groceries', labelKey: 'costs.cat.groceries', Icon: ShoppingCart, color: '#65a30d' },
|
||||||
|
transport: { key: 'transport', labelKey: 'costs.cat.transport', Icon: Bus, color: '#2563eb' },
|
||||||
|
flights: { key: 'flights', labelKey: 'costs.cat.flights', Icon: Plane, color: '#0ea5e9' },
|
||||||
|
activities: { key: 'activities', labelKey: 'costs.cat.activities', Icon: Ticket, color: '#9333ea' },
|
||||||
|
sightseeing: { key: 'sightseeing', labelKey: 'costs.cat.sightseeing', Icon: Camera, color: '#db2777' },
|
||||||
|
shopping: { key: 'shopping', labelKey: 'costs.cat.shopping', Icon: ShoppingBag, color: '#e11d48' },
|
||||||
|
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
|
||||||
|
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
|
||||||
|
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
|
||||||
|
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||||
|
|
||||||
|
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||||
|
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||||
|
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||||
|
return COST_CAT_META.other
|
||||||
|
}
|
||||||
@@ -12,10 +12,10 @@ export function ChatMessages(props: any) {
|
|||||||
<>
|
<>
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
|
||||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
<span style={{ fontSize: 12, opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
export const FONT = "var(--font-system)"
|
||||||
|
|
||||||
export const NOTE_COLORS = [
|
export const NOTE_COLORS = [
|
||||||
{ value: '#6366f1', label: 'Indigo' },
|
{ value: '#6366f1', label: 'Indigo' },
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ interface Poll {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
const FONT = "var(--font-system)"
|
||||||
|
|
||||||
function timeRemaining(deadline) {
|
function timeRemaining(deadline) {
|
||||||
if (!deadline) return null
|
if (!deadline) return null
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function FileManager(props: FileManagerProps) {
|
|||||||
const S = useFileManager(props)
|
const S = useFileManager(props)
|
||||||
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
|
|||||||
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function ensureJourneyPopupStyle() {
|
|||||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
font-family: -apple-system, system-ui, sans-serif;
|
font-family:var(--font-system);
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
|
|||||||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>`
|
</svg>`
|
||||||
wrap.appendChild(inner)
|
wrap.appendChild(inner)
|
||||||
return wrap
|
return wrap
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ function useCreateAction(): { label: string; run: () => void } {
|
|||||||
const onJourneyList = useMatch('/journey')
|
const onJourneyList = useMatch('/journey')
|
||||||
|
|
||||||
if (inTrip) {
|
if (inTrip) {
|
||||||
|
// On the Costs tab the "+" adds an expense; otherwise it adds a place.
|
||||||
|
const tripTab = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`trip-tab-${inTrip.params.id}`) : null
|
||||||
|
if (tripTab === 'finanzplan') {
|
||||||
|
return { label: t('costs.addExpense'), run: () => navigate(`/trips/${inTrip.params.id}?create=expense`) }
|
||||||
|
}
|
||||||
return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
|
return { label: t('places.addPlace'), run: () => navigate(`/trips/${inTrip.params.id}?create=place`) }
|
||||||
}
|
}
|
||||||
if (inJourney) {
|
if (inJourney) {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||||
paddingLeft: 16, paddingRight: 16,
|
paddingLeft: 16, paddingRight: 16,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "var(--font-system)",
|
||||||
}} onClick={() => setDismissed(true)}>
|
}} onClick={() => setDismissed(true)}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
font-family:var(--font-system);line-height:1;
|
||||||
box-sizing:border-box;white-space:nowrap;
|
box-sizing:border-box;white-space:nowrap;
|
||||||
">${label}</span>`
|
">${label}</span>`
|
||||||
}
|
}
|
||||||
@@ -592,7 +592,7 @@ export const MapView = memo(function MapView({
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "var(--font-system)",
|
||||||
maxWidth: 220,
|
maxWidth: 220,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
|||||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
font-family:var(--font-system);line-height:1;
|
||||||
box-sizing:border-box;white-space:nowrap;
|
box-sizing:border-box;white-space:nowrap;
|
||||||
">${label}</span>`
|
">${label}</span>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
|||||||
padding:0 8px;border-radius:999px;
|
padding:0 8px;border-radius:999px;
|
||||||
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1.5px solid #fff;color:#fff;
|
border:1.5px solid #fff;color:#fff;
|
||||||
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||||
box-sizing:border-box;height:22px;white-space:nowrap;
|
box-sizing:border-box;height:22px;white-space:nowrap;
|
||||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
||||||
iconSize: [estWidth, 22],
|
iconSize: [estWidth, 22],
|
||||||
@@ -181,7 +181,7 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
|
|||||||
background:rgba(17,24,39,0.92);color:#fff;
|
background:rgba(17,24,39,0.92);color:#fff;
|
||||||
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1px solid ${color}aa;
|
border:1px solid ${color}aa;
|
||||||
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
|
font-family:var(--font-system);
|
||||||
white-space:nowrap;box-sizing:border-box;
|
white-space:nowrap;box-sizing:border-box;
|
||||||
transform-origin:center;
|
transform-origin:center;
|
||||||
will-change:transform;
|
will-change:transform;
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
|||||||
padding:0 8px;border-radius:999px;
|
padding:0 8px;border-radius:999px;
|
||||||
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1.5px solid #fff;color:#fff;
|
border:1.5px solid #fff;color:#fff;
|
||||||
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||||
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
|
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
|
||||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht
|
|||||||
background:rgba(17,24,39,0.92);color:#fff;
|
background:rgba(17,24,39,0.92);color:#fff;
|
||||||
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
||||||
border:1px solid ${TRANSPORT_COLOR}aa;
|
border:1px solid ${TRANSPORT_COLOR}aa;
|
||||||
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
|
font-family:var(--font-system);
|
||||||
white-space:nowrap;box-sizing:border-box;pointer-events:none;
|
white-space:nowrap;box-sizing:border-box;pointer-events:none;
|
||||||
transform-origin:center;will-change:transform;
|
transform-origin:center;will-change:transform;
|
||||||
">${main}${sub}</div>`
|
">${main}${sub}</div>`
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "var(--font-system)" }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tripId, items, inlineHeader, t, canEdit, font,
|
tripId, items, inlineHeader, t, canEdit, font,
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
|||||||
<div
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
className="bg-surface-card"
|
className="bg-surface-card"
|
||||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
) : null
|
) : null
|
||||||
|
|
||||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "var(--font-system)" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
anyGeoPlace,
|
anyGeoPlace,
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<DayPlanSidebarToolbar
|
<DayPlanSidebarToolbar
|
||||||
tripId={tripId}
|
tripId={tripId}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
|||||||
<div
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
className="bg-surface-card"
|
className="bg-surface-card"
|
||||||
style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}
|
style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)" }}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||||
{t('places.importFile')}
|
{t('places.importFile')}
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default function PlaceInspector({
|
|||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "var(--font-system)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-surface-elevated" style={{
|
<div className="bg-surface-elevated" style={{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
|||||||
onDragOver={handleSidebarDragOver}
|
onDragOver={handleSidebarDragOver}
|
||||||
onDragLeave={handleSidebarDragLeave}
|
onDragLeave={handleSidebarDragLeave}
|
||||||
onDrop={handleSidebarDrop}
|
onDrop={handleSidebarDrop}
|
||||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}
|
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "var(--font-system)", position: 'relative' }}
|
||||||
>
|
>
|
||||||
{sidebarDragOver && <PlacesDropOverlay {...S} />}
|
{sidebarDragOver && <PlacesDropOverlay {...S} />}
|
||||||
{/* Kopfbereich */}
|
{/* Kopfbereich */}
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
}, [reservations])
|
}, [reservations])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "var(--font-system)" }}>
|
||||||
{/* Unified toolbar */}
|
{/* Unified toolbar */}
|
||||||
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
|
||||||
<div className="bg-surface-tertiary" style={{
|
<div className="bg-surface-tertiary" style={{
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
|
|||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
|
||||||
export default function DisplaySettingsTab(): React.ReactElement {
|
export default function DisplaySettingsTab(): React.ReactElement {
|
||||||
@@ -28,6 +30,21 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('settings.display')} icon={Palette}>
|
<Section title={t('settings.display')} icon={Palette}>
|
||||||
|
{/* Display currency */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.currency')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={settings.default_currency || 'EUR'}
|
||||||
|
onChange={async v => {
|
||||||
|
try { await updateSetting('default_currency', String(v)) }
|
||||||
|
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||||
|
}}
|
||||||
|
options={CURRENCIES.map(c => ({ value: c, label: `${c} — ${SYMBOLS[c] || c}` }))}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-content-faint mt-2">{t('settings.currencyHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Color Mode */}
|
{/* Color Mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.colorMode')}</label>
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.colorMode')}</label>
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
||||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||||
|
|
||||||
{/* Left column: Members */}
|
{/* Left column: Members */}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked
|
|||||||
|
|
||||||
if (!lat || !lng) return null
|
if (!lat || !lng) return null
|
||||||
|
|
||||||
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const fontStyle = { fontFamily: "var(--font-system)" }
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "var(--font-system)",
|
||||||
transformOrigin: 'top left',
|
transformOrigin: 'top left',
|
||||||
}}>
|
}}>
|
||||||
{menu.items.filter(Boolean).map((item, i) => {
|
{menu.items.filter(Boolean).map((item, i) => {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function ToastContainer() {
|
|||||||
<span style={{
|
<span style={{
|
||||||
flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
|
flex: 1, fontSize: 13, fontWeight: 500, color: 'rgba(255, 255, 255, 0.9)',
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "var(--font-system)",
|
||||||
}}>
|
}}>
|
||||||
{toast.message}
|
{toast.message}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, ch
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "var(--font-system)",
|
||||||
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||||
|
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
|
||||||
|
* for the dashboard widget) for the given base and caches per base in memory +
|
||||||
|
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
||||||
|
* currency C converts to base as `amount / rates[C]`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TTL_MS = 6 * 60 * 60 * 1000 // 6h
|
||||||
|
const mem = new Map<string, { rates: Record<string, number>; ts: number }>()
|
||||||
|
|
||||||
|
function readCache(base: string): { rates: Record<string, number>; ts: number } | null {
|
||||||
|
const m = mem.get(base)
|
||||||
|
if (m) return m
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('trek_fx_' + base)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as { rates: Record<string, number>; ts: number }
|
||||||
|
if (parsed?.rates) { mem.set(base, parsed); return parsed }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExchangeRates(base: string) {
|
||||||
|
const upper = (base || 'EUR').toUpperCase()
|
||||||
|
const [rates, setRates] = useState<Record<string, number> | null>(() => readCache(upper)?.rates ?? null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = readCache(upper)
|
||||||
|
if (cached) setRates(cached.rates)
|
||||||
|
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((d: { rates?: Record<string, number> }) => {
|
||||||
|
if (cancelled || !d?.rates) return
|
||||||
|
const entry = { rates: d.rates, ts: Date.now() }
|
||||||
|
mem.set(upper, entry)
|
||||||
|
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
||||||
|
setRates(d.rates)
|
||||||
|
})
|
||||||
|
.catch(() => { /* offline → keep cached/identity */ })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [upper])
|
||||||
|
|
||||||
|
const convert = useCallback(
|
||||||
|
(amount: number, from: string | null | undefined): number => {
|
||||||
|
const f = (from || upper).toUpperCase()
|
||||||
|
if (f === upper || !rates) return amount
|
||||||
|
const r = rates[f]
|
||||||
|
return r && r > 0 ? amount / r : amount
|
||||||
|
},
|
||||||
|
[rates, upper],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { rates, convert }
|
||||||
|
}
|
||||||
@@ -431,7 +431,9 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
|||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
--nav-h: 0px;
|
--nav-h: 0px;
|
||||||
--bottom-nav-h: 0px;
|
--bottom-nav-h: 0px;
|
||||||
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
--font-system: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
/* Secondary "subtext"/caption tier renders in Geist; primary text + headings stay Poppins. */
|
||||||
|
--font-subtext: 'Geist Sans', 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
--sp-1: 4px;
|
--sp-1: 4px;
|
||||||
--sp-2: 8px;
|
--sp-2: 8px;
|
||||||
--sp-3: 12px;
|
--sp-3: 12px;
|
||||||
@@ -539,6 +541,11 @@ body {
|
|||||||
transition: background-color 0.2s, color 0.2s;
|
transition: background-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtext tier in Geist. The faint text token is TREK's caption/secondary tier;
|
||||||
|
a direct rule on the element beats the Poppins inherited from wrapper styles,
|
||||||
|
giving the design's "Geist text · Poppins numbers" hierarchy. */
|
||||||
|
.text-content-faint { font-family: var(--font-subtext); }
|
||||||
|
|
||||||
/* ── Marker cluster custom styling ────────────── */
|
/* ── Marker cluster custom styling ────────────── */
|
||||||
.marker-cluster-wrapper {
|
.marker-cluster-wrapper {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -563,7 +570,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.marker-cluster-custom span {
|
.marker-cluster-custom span {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
font-family:var(--font-system);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
// Self-hosted Poppins (bundled, same-origin) so the app font can't be blocked by
|
||||||
|
// ad/tracker blockers the way the Google Fonts CDN can.
|
||||||
|
import '@fontsource/poppins/300.css'
|
||||||
|
import '@fontsource/poppins/400.css'
|
||||||
|
import '@fontsource/poppins/500.css'
|
||||||
|
import '@fontsource/poppins/600.css'
|
||||||
|
import '@fontsource/poppins/700.css'
|
||||||
|
// Geist Sans (self-hosted too) — used only for secondary "subtext" via --font-subtext.
|
||||||
|
import '@fontsource/geist-sans/400.css'
|
||||||
|
import '@fontsource/geist-sans/500.css'
|
||||||
|
import '@fontsource/geist-sans/600.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { startConnectivityProbe } from './sync/connectivity'
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Navbar lives outside .trek-dash so it keeps the app-wide font + button
|
{/* Navbar lives outside .trek-dash so it keeps the app-wide font + button
|
||||||
styling instead of inheriting the dashboard scope's Geist font and the
|
styling instead of inheriting the dashboard scope's font and the
|
||||||
`.trek-dash button` reset (which shifted the bell icon + menu items). */}
|
`.trek-dash button` reset (which shifted the bell icon + menu items). */}
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="trek-dash trek-dash-shell">
|
<div className="trek-dash trek-dash-shell">
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "var(--font-system)", position: 'relative' }}>
|
||||||
|
|
||||||
{/* Language dropdown */}
|
{/* Language dropdown */}
|
||||||
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
|
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 10 }}>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function SharedTripPage() {
|
|||||||
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
|
const center = mapPlaces.length > 0 ? [mapPlaces[0].lat, mapPlaces[0].lng] : [48.85, 2.35]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div className="bg-surface-secondary" style={{ minHeight: '100vh', fontFamily: "var(--font-system)" }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
|
<div className="text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', textAlign: 'center', position: 'relative' }}>
|
||||||
{/* Cover image background */}
|
{/* Cover image background */}
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ vi.mock('../components/Files/FileManager', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/Budget/BudgetPanel', () => ({
|
vi.mock('../components/Budget/CostsPanel', () => ({
|
||||||
default: () => React.createElement('div', { 'data-testid': 'budget-panel' }),
|
default: () => React.createElement('div', { 'data-testid': 'costs-panel' }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../components/Packing/PackingListPanel', () => ({
|
vi.mock('../components/Packing/PackingListPanel', () => ({
|
||||||
@@ -436,8 +436,8 @@ describe('TripPlannerPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-PLANNER-012: Budget tab renders BudgetPanel', () => {
|
describe('FE-PAGE-PLANNER-012: Costs tab renders CostsPanel', () => {
|
||||||
it('shows BudgetPanel after clicking the Budget tab with budget addon enabled', async () => {
|
it('shows CostsPanel after clicking the Costs tab with budget addon enabled', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/addons', () =>
|
||||||
HttpResponse.json({ addons: [{ id: 'budget', type: 'budget' }] })
|
HttpResponse.json({ addons: [{ id: 'budget', type: 'budget' }] })
|
||||||
@@ -454,11 +454,11 @@ describe('TripPlannerPage', () => {
|
|||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
const budgetTab = await screen.findByTitle('Budget');
|
const costsTab = await screen.findByTitle('Costs');
|
||||||
fireEvent.click(budgetTab);
|
fireEvent.click(costsTab);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('budget-panel')).toBeInTheDocument();
|
expect(screen.getByTestId('costs-panel')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
|
|||||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
import BudgetPanel from '../components/Budget/BudgetPanel'
|
import CostsPanel from '../components/Budget/CostsPanel'
|
||||||
import CollabPanel from '../components/Collab/CollabPanel'
|
import CollabPanel from '../components/Collab/CollabPanel'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
@@ -647,7 +647,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
|
|
||||||
{activeTab === 'finanzplan' && (
|
{activeTab === 'finanzplan' && (
|
||||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
<CostsPanel tripId={tripId} tripMembers={tripMembers} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ export function useTripPlanner() {
|
|||||||
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
|
||||||
const defaultZoom = settings.default_zoom || 10
|
const defaultZoom = settings.default_zoom || 10
|
||||||
|
|
||||||
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
|
const fontStyle = { fontFamily: "var(--font-system)" }
|
||||||
|
|
||||||
// Splash screen — show for initial load + a brief moment for photos to start loading
|
// Splash screen — show for initial load + a brief moment for photos to start loading
|
||||||
const [splashDone, setSplashDone] = useState(false)
|
const [splashDone, setSplashDone] = useState(false)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Geist", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
font-family: "Poppins", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
font-feature-settings: "ss01", "cv11";
|
font-feature-settings: "ss01", "cv11";
|
||||||
letter-spacing: -0.005em;
|
letter-spacing: -0.005em;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|||||||
@@ -39,6 +39,58 @@ export function currencyDecimals(currency: string): number {
|
|||||||
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each currency formats in its own home convention (symbol position, grouping and
|
||||||
|
// decimal separators) regardless of the app language — so EUR is always "1.234,56 €"
|
||||||
|
// and USD always "$1,234.56". Intl derives all of that from the locale, so we map
|
||||||
|
// each supported currency to a representative locale (Latin-digit variants for the
|
||||||
|
// Arabic/Bengali ones to avoid non-Latin numerals).
|
||||||
|
const CURRENCY_LOCALE: Record<string, string> = {
|
||||||
|
EUR: 'de-DE', USD: 'en-US', GBP: 'en-GB', JPY: 'ja-JP', CHF: 'de-CH',
|
||||||
|
CZK: 'cs-CZ', PLN: 'pl-PL', SEK: 'sv-SE', NOK: 'nb-NO', DKK: 'da-DK',
|
||||||
|
TRY: 'tr-TR', THB: 'th-TH', AUD: 'en-AU', CAD: 'en-CA', NZD: 'en-NZ',
|
||||||
|
BRL: 'pt-BR', MXN: 'es-MX', INR: 'en-IN', IDR: 'id-ID', MYR: 'ms-MY',
|
||||||
|
PHP: 'en-PH', SGD: 'en-SG', KRW: 'ko-KR', CNY: 'zh-CN', HKD: 'en-HK',
|
||||||
|
TWD: 'zh-TW', ZAR: 'en-ZA', AED: 'en-AE', SAR: 'en-SA', ILS: 'he-IL',
|
||||||
|
EGP: 'en-EG', MAD: 'fr-MA', HUF: 'hu-HU', RON: 'ro-RO', BGN: 'bg-BG',
|
||||||
|
HRK: 'hr-HR', ISK: 'is-IS', RUB: 'ru-RU', UAH: 'uk-UA', BDT: 'en-BD',
|
||||||
|
LKR: 'en-LK', VND: 'vi-VN', CLP: 'es-CL', COP: 'es-CO', PEN: 'es-PE',
|
||||||
|
ARS: 'es-AR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currencyLocale(currency: string): string {
|
||||||
|
return CURRENCY_LOCALE[(currency || '').toUpperCase()] || 'en-US'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale- and currency-correct money formatting via Intl: the symbol position,
|
||||||
|
* thousands/decimal separators and decimal count all follow the user's locale
|
||||||
|
* and the currency itself (e.g. de-DE EUR → "1.234,56 €", en-US USD → "$1,234.56",
|
||||||
|
* ja-JP JPY → "¥1,235"). Falls back to a "<number> CODE" suffix for unknown codes.
|
||||||
|
*/
|
||||||
|
export function formatMoney(
|
||||||
|
value: number,
|
||||||
|
currency: string,
|
||||||
|
locale: string,
|
||||||
|
opts?: { decimals?: number },
|
||||||
|
): string {
|
||||||
|
const cur = (currency || 'EUR').toUpperCase()
|
||||||
|
const decimals = opts?.decimals ?? currencyDecimals(cur)
|
||||||
|
// Format in the currency's home convention, not the app language, so the symbol
|
||||||
|
// position and separators are always correct for that currency. `locale` stays
|
||||||
|
// as a last-resort fallback for the error path.
|
||||||
|
const fmtLocale = currencyLocale(cur)
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(fmtLocale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: cur,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(value || 0)
|
||||||
|
} catch {
|
||||||
|
return `${(value || 0).toLocaleString(locale || fmtLocale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${cur}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
export function formatDate(dateStr: string | null | undefined, locale: string, timeZone?: string): string | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
const opts: Intl.DateTimeFormatOptions = {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ services:
|
|||||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
|
# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||||
|
|||||||
Generated
+21
@@ -26,6 +26,8 @@
|
|||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "3.0.22",
|
"version": "3.0.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/geist-sans": "^5.2.5",
|
||||||
|
"@fontsource/poppins": "^5.2.7",
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@trek/shared": "*",
|
"@trek/shared": "*",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
@@ -2505,6 +2507,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/geist-sans": {
|
||||||
|
"version": "5.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz",
|
||||||
|
"integrity": "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/poppins": {
|
||||||
|
"version": "5.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz",
|
||||||
|
"integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5209,6 +5229,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ NODE_ENV=development # development = development mode; production = production m
|
|||||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||||
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
|
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
|
||||||
|
# SESSION_DURATION=30d # How long users stay logged in — sets the trek_session JWT exp + cookie maxAge. Accepts 1h, 12h, 7d, 30d, 90d. Default: 24h
|
||||||
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
|
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
|
||||||
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
||||||
|
|||||||
@@ -107,3 +107,32 @@ if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
|||||||
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||||
}
|
}
|
||||||
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';
|
||||||
|
|
||||||
|
// SESSION_DURATION controls how long a TREK session (the `trek_session` JWT
|
||||||
|
// cookie) stays valid before re-login is required. Accepts ms-style strings:
|
||||||
|
// '1h', '12h', '7d', '30d', '90d', etc. It applies to BOTH the JWT `exp` claim
|
||||||
|
// and the cookie `maxAge`, so the two never drift apart. Invalid values warn at
|
||||||
|
// startup and fall back to the default. Does not affect the short-lived MFA
|
||||||
|
// challenge token or MCP OAuth tokens — those keep their own TTL.
|
||||||
|
const DEFAULT_SESSION_DURATION = '24h';
|
||||||
|
const DURATION_UNITS_MS: Record<string, number> = {
|
||||||
|
ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
|
||||||
|
};
|
||||||
|
function parseDurationMs(value: string): number | null {
|
||||||
|
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseFloat(m[1]);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return null;
|
||||||
|
return n * DURATION_UNITS_MS[(m[2] || 'ms').toLowerCase()];
|
||||||
|
}
|
||||||
|
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
|
||||||
|
const parsedSessionMs = parseDurationMs(rawSessionDuration);
|
||||||
|
if (parsedSessionMs == null) {
|
||||||
|
console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
|
||||||
|
}
|
||||||
|
/** Human-readable session length actually in effect (for logs/diagnostics). */
|
||||||
|
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
|
||||||
|
/** Session length in milliseconds — used for the cookie `maxAge`. */
|
||||||
|
export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
|
||||||
|
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
|
||||||
|
export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000);
|
||||||
|
|||||||
@@ -2278,6 +2278,68 @@ function runMigrations(db: Database.Database): void {
|
|||||||
if (!err.message?.includes('no such table')) throw err;
|
if (!err.message?.includes('no such table')) throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively
|
||||||
|
// and without touching existing rows:
|
||||||
|
// - per-expense currency + exchange_rate, so an expense can be entered in a
|
||||||
|
// foreign currency and converted to the trip base currency (NULL currency =
|
||||||
|
// base currency; rate 1.0). Closes the multi-currency request (#551).
|
||||||
|
// - budget_item_payers: several people can each have paid part of one expense
|
||||||
|
// (amounts in the expense currency), replacing the single paid_by_user_id.
|
||||||
|
// - budget_settlements: persisted "X paid Y" transfers so the settle-up
|
||||||
|
// history (with undo) is shared across all trip members.
|
||||||
|
// The equal-split participants stay in budget_item_members. The single legacy
|
||||||
|
// payer is backfilled into budget_item_payers as one payer covering the total.
|
||||||
|
() => {
|
||||||
|
try { db.exec('ALTER TABLE budget_items ADD COLUMN currency TEXT'); }
|
||||||
|
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
|
try { db.exec('ALTER TABLE budget_items ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); }
|
||||||
|
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS budget_item_payers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
amount REAL NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(budget_item_id, user_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS budget_settlements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
amount REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by_user_id INTEGER REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)');
|
||||||
|
|
||||||
|
// Backfill the legacy single payer: that person paid the full total of the
|
||||||
|
// expense, in the (base) currency the existing amount was already stored in.
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount)
|
||||||
|
SELECT id, paid_by_user_id, total_price
|
||||||
|
FROM budget_items
|
||||||
|
WHERE paid_by_user_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes('no such column')) throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This
|
||||||
|
// is a display rename only — the addon id, tables, permissions and MCP tools
|
||||||
|
// all stay 'budget'. Scoped to the default name so a customised one is kept.
|
||||||
|
() => {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'",
|
||||||
|
).run();
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function seedAddons(db: Database.Database): void {
|
|||||||
try {
|
try {
|
||||||
const defaultAddons = [
|
const defaultAddons = [
|
||||||
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||||
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
{ id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||||
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
@@ -57,9 +58,56 @@ export class BudgetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('settlement')
|
@Get('settlement')
|
||||||
settlement(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
settlement(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@Query('base') base?: string,
|
||||||
|
) {
|
||||||
|
const trip = this.requireTrip(tripId, user);
|
||||||
|
return this.budget.settlement(tripId, base, (trip as { currency?: string }).currency || 'EUR');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('settlements')
|
||||||
|
listSettlements(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||||
this.requireTrip(tripId, user);
|
this.requireTrip(tripId, user);
|
||||||
return this.budget.settlement(tripId);
|
return { settlements: this.budget.listSettlements(tripId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('settlements')
|
||||||
|
createSettlement(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
||||||
|
@Headers('x-socket-id') socketId?: string,
|
||||||
|
) {
|
||||||
|
const trip = this.requireTrip(tripId, user);
|
||||||
|
this.requireEdit(trip, user);
|
||||||
|
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
||||||
|
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
||||||
|
}
|
||||||
|
const settlement = this.budget.createSettlement(
|
||||||
|
tripId,
|
||||||
|
{ from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount },
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
this.budget.broadcast(tripId, 'budget:settlement-created', { settlement }, socketId);
|
||||||
|
return { settlement };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('settlements/:settlementId')
|
||||||
|
deleteSettlement(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@Param('settlementId') settlementId: string,
|
||||||
|
@Headers('x-socket-id') socketId?: string,
|
||||||
|
) {
|
||||||
|
const trip = this.requireTrip(tripId, user);
|
||||||
|
this.requireEdit(trip, user);
|
||||||
|
if (!this.budget.deleteSettlement(settlementId, tripId)) {
|
||||||
|
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||||
|
}
|
||||||
|
this.budget.broadcast(tripId, 'budget:settlement-deleted', { settlementId: Number(settlementId) }, socketId);
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -149,6 +197,27 @@ export class BudgetController {
|
|||||||
return { members: result.members, item: result.item };
|
return { members: result.members, item: result.item };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/payers')
|
||||||
|
setPayers(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Param('tripId') tripId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body('payers') payers: unknown,
|
||||||
|
@Headers('x-socket-id') socketId?: string,
|
||||||
|
) {
|
||||||
|
const trip = this.requireTrip(tripId, user);
|
||||||
|
this.requireEdit(trip, user);
|
||||||
|
if (!Array.isArray(payers)) {
|
||||||
|
throw new HttpException({ error: 'payers must be an array' }, 400);
|
||||||
|
}
|
||||||
|
const item = this.budget.setPayers(id, tripId, payers as { user_id: number; amount: number }[]);
|
||||||
|
if (!item) {
|
||||||
|
throw new HttpException({ error: 'Budget item not found' }, 404);
|
||||||
|
}
|
||||||
|
this.budget.broadcast(tripId, 'budget:updated', { item }, socketId);
|
||||||
|
return { item };
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id/members/:userId/paid')
|
@Put(':id/members/:userId/paid')
|
||||||
toggleMemberPaid(
|
toggleMemberPaid(
|
||||||
@CurrentUser() user: User,
|
@CurrentUser() user: User,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { broadcast } from '../../websocket';
|
|||||||
import { checkPermission } from '../../services/permissions';
|
import { checkPermission } from '../../services/permissions';
|
||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
import * as svc from '../../services/budgetService';
|
import * as svc from '../../services/budgetService';
|
||||||
|
import { getRates } from '../../services/exchangeRateService';
|
||||||
|
|
||||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||||
|
|
||||||
@@ -34,8 +35,10 @@ export class BudgetService {
|
|||||||
return svc.getPerPersonSummary(tripId);
|
return svc.getPerPersonSummary(tripId);
|
||||||
}
|
}
|
||||||
|
|
||||||
settlement(tripId: string) {
|
async settlement(tripId: string, base: string | undefined, tripCurrency: string) {
|
||||||
return svc.calculateSettlement(tripId);
|
const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase();
|
||||||
|
const rates = await getRates(effectiveBase);
|
||||||
|
return svc.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency });
|
||||||
}
|
}
|
||||||
|
|
||||||
create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
|
create(tripId: string, data: Parameters<typeof svc.createBudgetItem>[1]) {
|
||||||
@@ -58,6 +61,22 @@ export class BudgetService {
|
|||||||
return svc.toggleMemberPaid(id, userId, paid);
|
return svc.toggleMemberPaid(id, userId, paid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) {
|
||||||
|
return svc.setItemPayers(id, tripId, payers);
|
||||||
|
}
|
||||||
|
|
||||||
|
listSettlements(tripId: string) {
|
||||||
|
return svc.listSettlements(tripId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSettlement(tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }, userId: number) {
|
||||||
|
return svc.createSettlement(tripId, data, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSettlement(id: string, tripId: string): boolean {
|
||||||
|
return svc.deleteSettlement(id, tripId);
|
||||||
|
}
|
||||||
|
|
||||||
reorderItems(tripId: string, orderedIds: number[]): void {
|
reorderItems(tripId: string, orderedIds: number[]): void {
|
||||||
svc.reorderBudgetItems(tripId, orderedIds);
|
svc.reorderBudgetItems(tripId, orderedIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export class JourneyController {
|
|||||||
// ── Share Link ──────────────────────────────────────────────────────────
|
// ── Share Link ──────────────────────────────────────────────────────────
|
||||||
@Get(':id/share-link')
|
@Get(':id/share-link')
|
||||||
getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
|
getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
|
||||||
return { link: this.journey.getJourneyShareLink(Number(id)) };
|
return { link: this.journey.getJourneyShareLink(Number(id), user.id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/share-link')
|
@Post(':id/share-link')
|
||||||
|
|||||||
@@ -61,7 +61,13 @@ export class JourneyService {
|
|||||||
removeContributor(id: number, userId: number, targetUserId: number) { return svc.removeContributor(id, userId, targetUserId); }
|
removeContributor(id: number, userId: number, targetUserId: number) { return svc.removeContributor(id, userId, targetUserId); }
|
||||||
|
|
||||||
// Share links
|
// Share links
|
||||||
getJourneyShareLink(id: number) { return share.getJourneyShareLink(id); }
|
// Authorization: only someone with access to the journey may read its public
|
||||||
|
// share token — same access model as create/delete here and the
|
||||||
|
// get_journey_share_link MCP tool.
|
||||||
|
getJourneyShareLink(id: number, userId: number) {
|
||||||
|
if (!svc.canAccessJourney(id, userId)) return null;
|
||||||
|
return share.getJourneyShareLink(id);
|
||||||
|
}
|
||||||
createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters<typeof share.createOrUpdateJourneyShareLink>[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); }
|
createOrUpdateJourneyShareLink(id: number, userId: number, data: Parameters<typeof share.createOrUpdateJourneyShareLink>[2]) { return share.createOrUpdateJourneyShareLink(id, userId, data); }
|
||||||
deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); }
|
deleteJourneyShareLink(id: number, userId: number) { return share.deleteJourneyShareLink(id, userId); }
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
|
|||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { randomBytes, createHash } from 'crypto';
|
import { randomBytes, createHash } from 'crypto';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { JWT_SECRET } from '../config';
|
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
||||||
import { validatePassword } from './passwordPolicy';
|
import { validatePassword } from './passwordPolicy';
|
||||||
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
||||||
import { getAllPermissions } from './permissions';
|
import { getAllPermissions } from './permissions';
|
||||||
@@ -177,7 +177,7 @@ export function generateToken(user: { id: number | bigint; password_version?: nu
|
|||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{ id: user.id, pv },
|
{ id: user.id, pv },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: '24h', algorithm: 'HS256' }
|
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { BudgetItem, BudgetItemMember } from '../types';
|
import { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../types';
|
||||||
import { avatarUrl } from './avatarUrl';
|
import { avatarUrl } from './avatarUrl';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -19,6 +19,30 @@ function loadItemMembers(itemId: number | string) {
|
|||||||
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadItemPayers(itemId: number | string) {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT bp.user_id, bp.amount, u.username, u.avatar
|
||||||
|
FROM budget_item_payers bp
|
||||||
|
JOIN users u ON bp.user_id = u.id
|
||||||
|
WHERE bp.budget_item_id = ?
|
||||||
|
`).all(itemId) as BudgetItemPayer[];
|
||||||
|
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
|
||||||
|
function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
|
||||||
|
db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId);
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
|
||||||
|
let total = 0;
|
||||||
|
for (const p of payers) {
|
||||||
|
if (!(p.amount > 0)) continue;
|
||||||
|
insert.run(itemId, p.user_id, p.amount);
|
||||||
|
total += p.amount;
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(total, itemId);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CRUD
|
// CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -50,20 +74,45 @@ export function listBudgetItems(tripId: string | number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach(item => { item.members = membersByItem[item.id] || []; });
|
const payersByItem: Record<number, (BudgetItemPayer & { avatar_url: string | null })[]> = {};
|
||||||
|
if (itemIds.length > 0) {
|
||||||
|
const allPayers = db.prepare(`
|
||||||
|
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
||||||
|
FROM budget_item_payers bp
|
||||||
|
JOIN users u ON bp.user_id = u.id
|
||||||
|
WHERE bp.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||||
|
`).all(...itemIds) as (BudgetItemPayer & { budget_item_id: number })[];
|
||||||
|
|
||||||
|
for (const p of allPayers) {
|
||||||
|
if (!payersByItem[p.budget_item_id]) payersByItem[p.budget_item_id] = [];
|
||||||
|
payersByItem[p.budget_item_id].push({
|
||||||
|
user_id: p.user_id, amount: p.amount, username: p.username, avatar_url: avatarUrl(p),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
item.members = membersByItem[item.id] || [];
|
||||||
|
item.payers = payersByItem[item.id] || [];
|
||||||
|
});
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBudgetItem(
|
export function createBudgetItem(
|
||||||
tripId: string | number,
|
tripId: string | number,
|
||||||
data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
data: {
|
||||||
|
category?: string; name: string; total_price?: number;
|
||||||
|
currency?: string | null; exchange_rate?: number;
|
||||||
|
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||||
|
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const maxOrder = db.prepare(
|
const maxOrder = db.prepare(
|
||||||
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
|
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
|
||||||
).get(tripId) as { max: number | null };
|
).get(tripId) as { max: number | null };
|
||||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||||
|
|
||||||
const cat = data.category || 'Other';
|
const cat = data.category || 'other';
|
||||||
|
|
||||||
// Ensure category has a sort_order entry
|
// Ensure category has a sort_order entry
|
||||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
|
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
|
||||||
@@ -73,22 +122,37 @@ export function createBudgetItem(
|
|||||||
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
|
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// total_price is derived from explicit payers when given; otherwise the caller
|
||||||
|
// value (planning entries, or a bill no one has paid yet).
|
||||||
|
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
|
||||||
|
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(
|
).run(
|
||||||
tripId,
|
tripId,
|
||||||
cat,
|
cat,
|
||||||
data.name,
|
data.name,
|
||||||
data.total_price || 0,
|
total,
|
||||||
data.persons != null ? data.persons : null,
|
data.currency || null,
|
||||||
|
data.exchange_rate != null ? data.exchange_rate : 1,
|
||||||
|
data.member_ids ? data.member_ids.length : (data.persons != null ? data.persons : null),
|
||||||
data.days !== undefined && data.days !== null ? data.days : null,
|
data.days !== undefined && data.days !== null ? data.days : null,
|
||||||
data.note || null,
|
data.note || null,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
data.expense_date || null,
|
data.expense_date || null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
|
const itemId = result.lastInsertRowid as number;
|
||||||
item.members = [];
|
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
|
||||||
|
if (data.member_ids && data.member_ids.length > 0) {
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
||||||
|
for (const uid of data.member_ids) insert.run(itemId, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem;
|
||||||
|
item.members = loadItemMembers(itemId);
|
||||||
|
item.payers = loadItemPayers(itemId);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +170,12 @@ export function linkBudgetItemToReservation(
|
|||||||
export function updateBudgetItem(
|
export function updateBudgetItem(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tripId: string | number,
|
tripId: string | number,
|
||||||
data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
|
data: {
|
||||||
|
category?: string; name?: string; total_price?: number;
|
||||||
|
currency?: string | null; exchange_rate?: number;
|
||||||
|
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||||
|
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
@@ -116,6 +185,8 @@ export function updateBudgetItem(
|
|||||||
category = COALESCE(?, category),
|
category = COALESCE(?, category),
|
||||||
name = COALESCE(?, name),
|
name = COALESCE(?, name),
|
||||||
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
|
||||||
|
currency = CASE WHEN ? THEN ? ELSE currency END,
|
||||||
|
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
|
||||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||||
days = CASE WHEN ? THEN ? ELSE days END,
|
days = CASE WHEN ? THEN ? ELSE days END,
|
||||||
note = CASE WHEN ? THEN ? ELSE note END,
|
note = CASE WHEN ? THEN ? ELSE note END,
|
||||||
@@ -126,6 +197,8 @@ export function updateBudgetItem(
|
|||||||
data.category || null,
|
data.category || null,
|
||||||
data.name || null,
|
data.name || null,
|
||||||
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
|
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
|
||||||
|
data.currency !== undefined ? 1 : 0, data.currency !== undefined ? (data.currency || null) : null,
|
||||||
|
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.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
|
||||||
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
|
||||||
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
|
||||||
@@ -134,6 +207,15 @@ export function updateBudgetItem(
|
|||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Optional inline payer/member replacement (the edit modal saves all at once).
|
||||||
|
if (data.payers !== undefined) writeItemPayers(id, data.payers);
|
||||||
|
if (data.member_ids !== undefined) {
|
||||||
|
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
||||||
|
for (const uid of data.member_ids) insert.run(id, uid);
|
||||||
|
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
|
||||||
|
}
|
||||||
|
|
||||||
// If category changed, update category order table
|
// If category changed, update category order table
|
||||||
if (data.category) {
|
if (data.category) {
|
||||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
|
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
|
||||||
@@ -144,8 +226,23 @@ export function updateBudgetItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
|
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
||||||
updated.members = loadItemMembers(id);
|
updated.members = loadItemMembers(id);
|
||||||
|
updated.payers = loadItemPayers(id);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Payers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function setItemPayers(id: string | number, tripId: string | number, payers: { user_id: number; amount: number }[]) {
|
||||||
|
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!item) return null;
|
||||||
|
writeItemPayers(id, payers);
|
||||||
|
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
|
||||||
|
updated.members = loadItemMembers(id);
|
||||||
|
updated.payers = loadItemPayers(id);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,37 +317,65 @@ export function getPerPersonSummary(tripId: string | number) {
|
|||||||
// Settlement calculation (greedy debt matching)
|
// Settlement calculation (greedy debt matching)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function calculateSettlement(tripId: string | number) {
|
export function calculateSettlement(
|
||||||
|
tripId: string | number,
|
||||||
|
opts: { base?: string; rates?: Record<string, number> | null; tripCurrency?: string } = {},
|
||||||
|
) {
|
||||||
|
const base = (opts.base || opts.tripCurrency || 'EUR').toUpperCase();
|
||||||
|
const tripCurrency = (opts.tripCurrency || base).toUpperCase();
|
||||||
|
const rates = opts.rates ?? null;
|
||||||
|
// Amount in some currency → base. Pre-rework rows store currency = NULL, which
|
||||||
|
// means "the trip's own currency". rates[X] = units of X per 1 base.
|
||||||
|
const toBase = (amount: number, itemCurrency: string | null | undefined): number => {
|
||||||
|
const cur = (itemCurrency || tripCurrency).toUpperCase();
|
||||||
|
if (cur === base || !rates) return amount;
|
||||||
|
const r = rates[cur];
|
||||||
|
return r && r > 0 ? amount / r : amount;
|
||||||
|
};
|
||||||
|
|
||||||
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
|
||||||
const allMembers = db.prepare(`
|
const allMembers = db.prepare(`
|
||||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
SELECT bm.budget_item_id, bm.user_id, u.username, u.avatar
|
||||||
FROM budget_item_members bm
|
FROM budget_item_members bm
|
||||||
JOIN users u ON bm.user_id = u.id
|
JOIN users u ON bm.user_id = u.id
|
||||||
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||||
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
|
||||||
|
const allPayers = db.prepare(`
|
||||||
|
SELECT bp.budget_item_id, bp.user_id, bp.amount, u.username, u.avatar
|
||||||
|
FROM budget_item_payers bp
|
||||||
|
JOIN users u ON bp.user_id = u.id
|
||||||
|
WHERE bp.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
|
||||||
|
`).all(tripId) as (BudgetItemPayer & { budget_item_id: number })[];
|
||||||
|
|
||||||
// Calculate net balance per user: positive = is owed money, negative = owes money
|
// Net balance per user, in the requested base currency: positive = is owed
|
||||||
|
// money, negative = owes money. Each expense's amounts are converted from their
|
||||||
|
// own currency to the base with live rates, so mixed-currency trips net correctly.
|
||||||
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
|
||||||
|
const ensure = (id: number, src: { username?: string; avatar?: string | null }) => {
|
||||||
|
if (!balances[id]) balances[id] = { user_id: id, username: src.username || '', avatar_url: avatarUrl(src), balance: 0 };
|
||||||
|
return balances[id];
|
||||||
|
};
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
const members = allMembers.filter(m => m.budget_item_id === item.id);
|
||||||
if (members.length === 0) continue;
|
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||||
|
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||||
|
|
||||||
const payers = members.filter(m => m.paid);
|
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency), 0);
|
||||||
if (payers.length === 0) continue; // no one marked as paid
|
const sharePerMember = paidBase / members.length;
|
||||||
|
|
||||||
const sharePerMember = item.total_price / members.length;
|
// Payers are credited what they actually paid (converted to base)…
|
||||||
const paidPerPayer = item.total_price / payers.length;
|
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency);
|
||||||
|
// …and every split participant owes an equal share of the base total.
|
||||||
|
for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
|
||||||
|
}
|
||||||
|
|
||||||
for (const m of members) {
|
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||||
if (!balances[m.user_id]) {
|
// the receiver's credit shrinks, so the corresponding flow disappears.
|
||||||
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
|
const settlements = listSettlements(tripId);
|
||||||
}
|
for (const s of settlements) {
|
||||||
// Everyone owes their share
|
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
|
||||||
balances[m.user_id].balance -= sharePerMember;
|
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
|
||||||
// Payers get credited what they paid
|
|
||||||
if (m.paid) balances[m.user_id].balance += paidPerPayer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate optimized payment flows (greedy algorithm)
|
// Calculate optimized payment flows (greedy algorithm)
|
||||||
@@ -283,9 +408,52 @@ export function calculateSettlement(tripId: string | number) {
|
|||||||
return {
|
return {
|
||||||
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
|
||||||
flows,
|
flows,
|
||||||
|
settlements,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settlements (persisted settle-up transfers — history + undo)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function listSettlements(tripId: string | number) {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.created_at, s.created_by_user_id,
|
||||||
|
fu.username AS from_username, fu.avatar AS from_avatar,
|
||||||
|
tu.username AS to_username, tu.avatar AS to_avatar
|
||||||
|
FROM budget_settlements s
|
||||||
|
JOIN users fu ON s.from_user_id = fu.id
|
||||||
|
JOIN users tu ON s.to_user_id = tu.id
|
||||||
|
WHERE s.trip_id = ?
|
||||||
|
ORDER BY s.created_at DESC, s.id DESC
|
||||||
|
`).all(tripId) as any[];
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id, trip_id: r.trip_id,
|
||||||
|
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
|
||||||
|
amount: r.amount, created_at: r.created_at, created_by_user_id: r.created_by_user_id,
|
||||||
|
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
|
||||||
|
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSettlement(
|
||||||
|
tripId: string | number,
|
||||||
|
data: { from_user_id: number; to_user_id: number; amount: number },
|
||||||
|
createdByUserId?: number,
|
||||||
|
) {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, created_by_user_id) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, createdByUserId ?? null);
|
||||||
|
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
||||||
|
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!row) return false;
|
||||||
|
db.prepare('DELETE FROM budget_settlements WHERE id = ?').run(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Reorder
|
// Reorder
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { SESSION_DURATION_MS } from '../config';
|
||||||
|
|
||||||
const COOKIE_NAME = 'trek_session';
|
const COOKIE_NAME = 'trek_session';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ function buildOptions(clear: boolean, secure: boolean) {
|
|||||||
secure,
|
secure,
|
||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
path: '/',
|
path: '/',
|
||||||
...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry
|
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Live exchange rates for the Costs/Budget money conversion.
|
||||||
|
*
|
||||||
|
* Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the
|
||||||
|
* dashboard widget) and caches per base currency in-memory for a few hours so a
|
||||||
|
* settlement request never hammers the upstream. Rates are "units of X per 1
|
||||||
|
* base", so an amount in currency C converts to base as `amount / rates[C]`.
|
||||||
|
*
|
||||||
|
* Everything degrades gracefully: if the fetch fails (offline, upstream down),
|
||||||
|
* callers get `null`/identity conversion and amounts are treated as already in
|
||||||
|
* the base currency rather than throwing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TTL_MS = 6 * 60 * 60 * 1000; // 6h
|
||||||
|
const cache = new Map<string, { rates: Record<string, number>; ts: number }>();
|
||||||
|
const inflight = new Map<string, Promise<Record<string, number> | null>>();
|
||||||
|
|
||||||
|
async function fetchRates(base: string): Promise<Record<string, number> | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { rates?: Record<string, number> };
|
||||||
|
return data.rates && typeof data.rates === 'object' ? data.rates : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rates map for `base` (cached). Returns null if unavailable. */
|
||||||
|
export async function getRates(base: string): Promise<Record<string, number> | null> {
|
||||||
|
const key = (base || 'EUR').toUpperCase();
|
||||||
|
const hit = cache.get(key);
|
||||||
|
const now = Date.now();
|
||||||
|
if (hit && now - hit.ts < TTL_MS) return hit.rates;
|
||||||
|
|
||||||
|
// Coalesce concurrent fetches for the same base.
|
||||||
|
let p = inflight.get(key);
|
||||||
|
if (!p) {
|
||||||
|
p = fetchRates(key).then(rates => {
|
||||||
|
if (rates) cache.set(key, { rates, ts: Date.now() });
|
||||||
|
inflight.delete(key);
|
||||||
|
return rates;
|
||||||
|
});
|
||||||
|
inflight.set(key, p);
|
||||||
|
}
|
||||||
|
const rates = await p;
|
||||||
|
// On failure fall back to the last cached value if we have one.
|
||||||
|
if (!rates && hit) return hit.rates;
|
||||||
|
return rates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert `amount` from `from` currency into `base` using a rates map obtained
|
||||||
|
* from getRates(base). Identity when same currency or the rate is missing.
|
||||||
|
*/
|
||||||
|
export function convertWithRates(
|
||||||
|
amount: number,
|
||||||
|
from: string | null | undefined,
|
||||||
|
base: string,
|
||||||
|
rates: Record<string, number> | null,
|
||||||
|
): number {
|
||||||
|
const fromCur = (from || base).toUpperCase();
|
||||||
|
const baseCur = base.toUpperCase();
|
||||||
|
if (fromCur === baseCur || !rates) return amount;
|
||||||
|
const r = rates[fromCur];
|
||||||
|
if (!r || r <= 0) return amount;
|
||||||
|
return amount / r;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { JWT_SECRET } from '../config';
|
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { decrypt_api_key } from './apiKeyCrypto';
|
import { decrypt_api_key } from './apiKeyCrypto';
|
||||||
import { resolveAuthToggles } from './authService';
|
import { resolveAuthToggles } from './authService';
|
||||||
@@ -200,7 +200,7 @@ export function frontendUrl(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateToken(user: { id: number }): string {
|
export function generateToken(user: { id: number }): string {
|
||||||
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
|
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -122,13 +122,18 @@ export interface BudgetItem {
|
|||||||
category: string;
|
category: string;
|
||||||
name: string;
|
name: string;
|
||||||
total_price: number;
|
total_price: number;
|
||||||
|
currency?: string | null;
|
||||||
|
exchange_rate?: number;
|
||||||
persons?: number | null;
|
persons?: number | null;
|
||||||
days?: number | null;
|
days?: number | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
reservation_id?: number | null;
|
reservation_id?: number | null;
|
||||||
|
paid_by_user_id?: number | null;
|
||||||
|
expense_date?: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
members?: BudgetItemMember[];
|
members?: BudgetItemMember[];
|
||||||
|
payers?: BudgetItemPayer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetItemMember {
|
export interface BudgetItemMember {
|
||||||
@@ -140,6 +145,15 @@ export interface BudgetItemMember {
|
|||||||
budget_item_id?: number;
|
budget_item_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BudgetItemPayer {
|
||||||
|
user_id: number;
|
||||||
|
amount: number;
|
||||||
|
username?: string;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
budget_item_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReservationEndpoint {
|
export interface ReservationEndpoint {
|
||||||
id: number;
|
id: number;
|
||||||
reservation_id: number;
|
reservation_id: number;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const DEFAULT_CATEGORIES = [
|
|||||||
|
|
||||||
const DEFAULT_ADDONS = [
|
const DEFAULT_ADDONS = [
|
||||||
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||||
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
{ id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||||
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||||
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||||
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||||
@@ -262,4 +262,7 @@ export const TEST_CONFIG = {
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
@@ -347,10 +350,12 @@ describe('Budget summary and settlement', () => {
|
|||||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||||
.set('Cookie', authCookie(user.id))
|
.set('Cookie', authCookie(user.id))
|
||||||
.send({ user_ids: [user.id, user2.id] });
|
.send({ user_ids: [user.id, user2.id] });
|
||||||
|
// New model: who actually paid is recorded as an explicit payer (amount in
|
||||||
|
// the expense currency), not a per-member "paid" toggle.
|
||||||
await request(app)
|
await request(app)
|
||||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
.put(`/api/trips/${trip.id}/budget/${item.id}/payers`)
|
||||||
.set('Cookie', authCookie(user.id))
|
.set('Cookie', authCookie(user.id))
|
||||||
.send({ paid: true });
|
.send({ payers: [{ user_id: user.id, amount: 60 }] });
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({
|
vi.mock('../../src/websocket', () => ({
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ describe('Tool: create_budget_item', () => {
|
|||||||
arguments: { tripId: trip.id, name: 'Misc', total_price: 10 },
|
arguments: { tripId: trip.id, name: 'Misc', total_price: 10 },
|
||||||
});
|
});
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
expect(data.item.category).toBe('Other');
|
expect(data.item.category).toBe('other');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
// ── DB mock setup ────────────────────────────────────────────────────────────
|
// ── DB mock setup ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface MockPrepared {
|
|
||||||
all: ReturnType<typeof vi.fn>;
|
|
||||||
get: ReturnType<typeof vi.fn>;
|
|
||||||
run: ReturnType<typeof vi.fn>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preparedMap: Record<string, MockPrepared> = {};
|
|
||||||
let defaultAll: ReturnType<typeof vi.fn>;
|
|
||||||
let defaultGet: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
const mockDb = vi.hoisted(() => {
|
const mockDb = vi.hoisted(() => {
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
prepare: vi.fn((sql: string) => {
|
prepare: vi.fn(() => ({
|
||||||
return {
|
all: vi.fn(() => []),
|
||||||
all: vi.fn(() => []),
|
get: vi.fn(() => undefined),
|
||||||
get: vi.fn(() => undefined),
|
run: vi.fn(),
|
||||||
run: vi.fn(),
|
})),
|
||||||
};
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
canAccessTrip: vi.fn(() => true),
|
canAccessTrip: vi.fn(() => true),
|
||||||
};
|
};
|
||||||
@@ -30,25 +18,29 @@ const mockDb = vi.hoisted(() => {
|
|||||||
vi.mock('../../../src/db/database', () => mockDb);
|
vi.mock('../../../src/db/database', () => mockDb);
|
||||||
|
|
||||||
import { calculateSettlement } from '../../../src/services/budgetService';
|
import { calculateSettlement } from '../../../src/services/budgetService';
|
||||||
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
|
import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src/types';
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
// Who actually paid is recorded as explicit payers (budget_item_payers); members
|
||||||
|
// are only the equal-split participants.
|
||||||
|
|
||||||
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
|
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
|
||||||
return { id, trip_id, name: `Item ${id}`, total_price, category: 'Other' } as BudgetItem;
|
return { id, trip_id, name: `Item ${id}`, total_price, category: 'other' } as BudgetItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMember(budget_item_id: number, user_id: number, paid: boolean | 0 | 1, username: string): BudgetItemMember & { budget_item_id: number } {
|
function makeMember(budget_item_id: number, user_id: number, username: string): BudgetItemMember & { budget_item_id: number } {
|
||||||
return {
|
return { budget_item_id, user_id, paid: 0, username, avatar: null } as BudgetItemMember & { budget_item_id: number };
|
||||||
budget_item_id,
|
|
||||||
user_id,
|
|
||||||
paid: paid ? 1 : 0,
|
|
||||||
username,
|
|
||||||
avatar: null,
|
|
||||||
} as BudgetItemMember & { budget_item_id: number };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item_id: number })[]) {
|
function makePayer(budget_item_id: number, user_id: number, amount: number, username: string): BudgetItemPayer & { budget_item_id: number } {
|
||||||
|
return { budget_item_id, user_id, amount, username, avatar: null } as BudgetItemPayer & { budget_item_id: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDb(
|
||||||
|
items: BudgetItem[],
|
||||||
|
members: (BudgetItemMember & { budget_item_id: number })[],
|
||||||
|
payers: (BudgetItemPayer & { budget_item_id: number })[] = [],
|
||||||
|
) {
|
||||||
mockDb.db.prepare.mockImplementation((sql: string) => {
|
mockDb.db.prepare.mockImplementation((sql: string) => {
|
||||||
if (sql.includes('SELECT * FROM budget_items')) {
|
if (sql.includes('SELECT * FROM budget_items')) {
|
||||||
return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
|
return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
|
||||||
@@ -56,45 +48,51 @@ function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item
|
|||||||
if (sql.includes('budget_item_members')) {
|
if (sql.includes('budget_item_members')) {
|
||||||
return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
|
return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
|
||||||
}
|
}
|
||||||
|
if (sql.includes('budget_item_payers')) {
|
||||||
|
return { all: vi.fn(() => payers), get: vi.fn(), run: vi.fn() };
|
||||||
|
}
|
||||||
|
// budget_settlements and anything else → empty
|
||||||
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
setupDb([], []);
|
setupDb([], [], []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── calculateSettlement ──────────────────────────────────────────────────────
|
// ── calculateSettlement ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('calculateSettlement', () => {
|
describe('calculateSettlement', () => {
|
||||||
it('returns empty balances and flows when trip has no items', () => {
|
it('returns empty balances and flows when trip has no items', () => {
|
||||||
setupDb([], []);
|
setupDb([], [], []);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
expect(result.balances).toEqual([]);
|
expect(result.balances).toEqual([]);
|
||||||
expect(result.flows).toEqual([]);
|
expect(result.flows).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns no flows when there are items but no members', () => {
|
it('returns no flows when there are items but no members', () => {
|
||||||
setupDb([makeItem(1, 100)], []);
|
setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
expect(result.flows).toEqual([]);
|
expect(result.flows).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns no flows when no one is marked as paid', () => {
|
it('returns no flows when no one has paid', () => {
|
||||||
setupDb(
|
setupDb(
|
||||||
[makeItem(1, 100)],
|
[makeItem(1, 100)],
|
||||||
[makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')],
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
expect(result.flows).toEqual([]);
|
expect(result.flows).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
|
it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
|
||||||
// Item: $100. Alice paid, Bob did not. Each owes $50. Alice net: +$50. Bob net: -$50.
|
// Item: $100. Alice paid all, [Alice, Bob] split. Each owes $50. Alice net: +$50. Bob: -$50.
|
||||||
setupDb(
|
setupDb(
|
||||||
[makeItem(1, 100)],
|
[makeItem(1, 100)],
|
||||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||||
|
[makePayer(1, 1, 100, 'alice')],
|
||||||
);
|
);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||||
@@ -111,7 +109,8 @@ describe('calculateSettlement', () => {
|
|||||||
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
|
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
|
||||||
setupDb(
|
setupDb(
|
||||||
[makeItem(1, 90)],
|
[makeItem(1, 90)],
|
||||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||||
|
[makePayer(1, 1, 90, 'alice')],
|
||||||
);
|
);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||||
@@ -124,12 +123,11 @@ describe('calculateSettlement', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('all paid equally: all balances are zero, no flows', () => {
|
it('all paid equally: all balances are zero, no flows', () => {
|
||||||
// Item: $60. 3 members, all paid equally (each paid $20, each owes $20). Net: 0.
|
// Item: $60. 3 members, each paid $20 and owes $20. Net: 0 for everyone.
|
||||||
// Actually with "paid" flag it means: paidPerPayer = item.total / numPayers.
|
|
||||||
// If all 3 paid: each gets +20 credit, each owes -20 = net 0 for everyone.
|
|
||||||
setupDb(
|
setupDb(
|
||||||
[makeItem(1, 60)],
|
[makeItem(1, 60)],
|
||||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 1, 'bob'), makeMember(1, 3, 1, 'carol')],
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||||
|
[makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')],
|
||||||
);
|
);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
for (const b of result.balances) {
|
for (const b of result.balances) {
|
||||||
@@ -142,7 +140,8 @@ describe('calculateSettlement', () => {
|
|||||||
// Alice paid $100 for 2 people. Bob owes Alice $50.
|
// Alice paid $100 for 2 people. Bob owes Alice $50.
|
||||||
setupDb(
|
setupDb(
|
||||||
[makeItem(1, 100)],
|
[makeItem(1, 100)],
|
||||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')],
|
||||||
|
[makePayer(1, 1, 100, 'alice')],
|
||||||
);
|
);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
const flow = result.flows[0];
|
const flow = result.flows[0];
|
||||||
@@ -154,7 +153,8 @@ describe('calculateSettlement', () => {
|
|||||||
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
|
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
|
||||||
setupDb(
|
setupDb(
|
||||||
[makeItem(1, 10)],
|
[makeItem(1, 10)],
|
||||||
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
|
[makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')],
|
||||||
|
[makePayer(1, 1, 10, 'alice')],
|
||||||
);
|
);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
for (const b of result.balances) {
|
for (const b of result.balances) {
|
||||||
@@ -176,9 +176,10 @@ describe('calculateSettlement', () => {
|
|||||||
setupDb(
|
setupDb(
|
||||||
[makeItem(1, 100), makeItem(2, 60)],
|
[makeItem(1, 100), makeItem(2, 60)],
|
||||||
[
|
[
|
||||||
makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'),
|
makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'),
|
||||||
makeMember(2, 1, 0, 'alice'), makeMember(2, 2, 1, 'bob'),
|
makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob'),
|
||||||
],
|
],
|
||||||
|
[makePayer(1, 1, 100, 'alice'), makePayer(2, 2, 60, 'bob')],
|
||||||
);
|
);
|
||||||
const result = calculateSettlement(1);
|
const result = calculateSettlement(1);
|
||||||
const alice = result.balances.find(b => b.user_id === 1)!;
|
const alice = result.balances.find(b => b.user_id === 1)!;
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ vi.mock('../../src/config', () => ({
|
|||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||||
updateJwtSecret: () => {},
|
updateJwtSecret: () => {},
|
||||||
|
SESSION_DURATION: '24h',
|
||||||
|
SESSION_DURATION_MS: 86400000,
|
||||||
|
SESSION_DURATION_SECONDS: 86400,
|
||||||
DEFAULT_LANGUAGE: 'en',
|
DEFAULT_LANGUAGE: 'en',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,49 @@ export const budgetItemMemberSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
|
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixed "Costs" expense categories. Unlike the old budget, users cannot
|
||||||
|
* create their own categories — every expense maps to one of these keys. The
|
||||||
|
* label/icon/colour per key live in the client; the server only stores the key.
|
||||||
|
* Pre-rework rows used free-text categories; those are shown as `other`.
|
||||||
|
*/
|
||||||
|
export const COST_CATEGORIES = [
|
||||||
|
'accommodation',
|
||||||
|
'food',
|
||||||
|
'groceries',
|
||||||
|
'transport',
|
||||||
|
'flights',
|
||||||
|
'activities',
|
||||||
|
'sightseeing',
|
||||||
|
'shopping',
|
||||||
|
'fees',
|
||||||
|
'health',
|
||||||
|
'tips',
|
||||||
|
'other',
|
||||||
|
] as const;
|
||||||
|
export type CostCategory = (typeof COST_CATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One payer of an expense — a row of budget_item_payers. `amount` is in the
|
||||||
|
* expense's own currency (budget_items.currency). Several payers can split who
|
||||||
|
* actually paid one bill. Username/avatar are joined for display.
|
||||||
|
*/
|
||||||
|
export const budgetItemPayerSchema = z.object({
|
||||||
|
user_id: z.number(),
|
||||||
|
amount: z.number(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
avatar_url: z.string().nullable().optional(),
|
||||||
|
avatar: z.string().nullable().optional(),
|
||||||
|
budget_item_id: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type BudgetItemPayer = z.infer<typeof budgetItemPayerSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Budget item entity as returned by the budget list/create/update endpoints
|
* Budget item entity as returned by the budget list/create/update endpoints
|
||||||
* (server/src/services/budgetService.ts). Columns of the `budget_items` table
|
* (server/src/services/budgetService.ts). Columns of the `budget_items` table
|
||||||
* plus the embedded `members` array. total_price is SQLite REAL.
|
* plus the embedded `members` (equal-split participants) and `payers` arrays.
|
||||||
|
* total_price is the sum of payer amounts in `currency`; `exchange_rate` converts
|
||||||
|
* that to the trip base currency (NULL currency + rate 1 = base currency).
|
||||||
*/
|
*/
|
||||||
export const budgetItemSchema = z.object({
|
export const budgetItemSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -38,6 +77,8 @@ export const budgetItemSchema = z.object({
|
|||||||
category: z.string(),
|
category: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
total_price: z.number(),
|
total_price: z.number(),
|
||||||
|
currency: z.string().nullable().optional(),
|
||||||
|
exchange_rate: z.number().optional(),
|
||||||
persons: z.number().nullable().optional(),
|
persons: z.number().nullable().optional(),
|
||||||
days: z.number().nullable().optional(),
|
days: z.number().nullable().optional(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
@@ -47,13 +88,26 @@ export const budgetItemSchema = z.object({
|
|||||||
sort_order: z.number().optional(),
|
sort_order: z.number().optional(),
|
||||||
created_at: z.string().optional(),
|
created_at: z.string().optional(),
|
||||||
members: z.array(budgetItemMemberSchema).optional(),
|
members: z.array(budgetItemMemberSchema).optional(),
|
||||||
|
payers: z.array(budgetItemPayerSchema).optional(),
|
||||||
});
|
});
|
||||||
export type BudgetItem = z.infer<typeof budgetItemSchema>;
|
export type BudgetItem = z.infer<typeof budgetItemSchema>;
|
||||||
|
|
||||||
|
const payerInputSchema = z.object({
|
||||||
|
user_id: z.number(),
|
||||||
|
amount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
export const budgetCreateItemRequestSchema = z.object({
|
export const budgetCreateItemRequestSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
total_price: z.number().optional(),
|
total_price: z.number().optional(),
|
||||||
|
currency: z.string().nullable().optional(),
|
||||||
|
exchange_rate: z.number().optional(),
|
||||||
|
// Multi-payer: who paid how much (in the expense currency). When omitted, the
|
||||||
|
// server falls back to total_price with no explicit payer.
|
||||||
|
payers: z.array(payerInputSchema).optional(),
|
||||||
|
// Equal-split participants. When omitted, the item has no split (planning-only).
|
||||||
|
member_ids: z.array(z.number()).optional(),
|
||||||
persons: z.number().nullable().optional(),
|
persons: z.number().nullable().optional(),
|
||||||
days: z.number().nullable().optional(),
|
days: z.number().nullable().optional(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
@@ -68,6 +122,10 @@ export const budgetUpdateItemRequestSchema = z.object({
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
total_price: z.number().optional(),
|
total_price: z.number().optional(),
|
||||||
|
currency: z.string().nullable().optional(),
|
||||||
|
exchange_rate: z.number().optional(),
|
||||||
|
payers: z.array(payerInputSchema).optional(),
|
||||||
|
member_ids: z.array(z.number()).optional(),
|
||||||
persons: z.number().nullable().optional(),
|
persons: z.number().nullable().optional(),
|
||||||
days: z.number().nullable().optional(),
|
days: z.number().nullable().optional(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
@@ -77,6 +135,43 @@ export type BudgetUpdateItemRequest = z.infer<
|
|||||||
typeof budgetUpdateItemRequestSchema
|
typeof budgetUpdateItemRequestSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/** Replace the explicit payers of an expense (amounts in expense currency). */
|
||||||
|
export const budgetUpdatePayersRequestSchema = z.object({
|
||||||
|
payers: z.array(payerInputSchema),
|
||||||
|
});
|
||||||
|
export type BudgetUpdatePayersRequest = z.infer<
|
||||||
|
typeof budgetUpdatePayersRequestSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persisted settle-up transfer (budget_settlements row): "from paid to" a
|
||||||
|
* given amount in the trip base currency. Creating one marks a suggested flow as
|
||||||
|
* paid; deleting it (undo) brings the flow back. Names joined for display.
|
||||||
|
*/
|
||||||
|
export const budgetSettlementSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
trip_id: z.number(),
|
||||||
|
from_user_id: z.number(),
|
||||||
|
to_user_id: z.number(),
|
||||||
|
amount: z.number(),
|
||||||
|
created_at: z.string().optional(),
|
||||||
|
created_by_user_id: z.number().nullable().optional(),
|
||||||
|
from_username: z.string().optional(),
|
||||||
|
from_avatar_url: z.string().nullable().optional(),
|
||||||
|
to_username: z.string().optional(),
|
||||||
|
to_avatar_url: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
export type BudgetSettlement = z.infer<typeof budgetSettlementSchema>;
|
||||||
|
|
||||||
|
export const budgetCreateSettlementRequestSchema = z.object({
|
||||||
|
from_user_id: z.number(),
|
||||||
|
to_user_id: z.number(),
|
||||||
|
amount: z.number(),
|
||||||
|
});
|
||||||
|
export type BudgetCreateSettlementRequest = z.infer<
|
||||||
|
typeof budgetCreateSettlementRequestSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const budgetUpdateMembersRequestSchema = z.object({
|
export const budgetUpdateMembersRequestSchema = z.object({
|
||||||
user_ids: z.array(z.number()),
|
user_ids: z.array(z.number()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,5 +38,78 @@ const budget: TranslationStrings = {
|
|||||||
'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
||||||
'budget.netBalances': 'الأرصدة الصافية',
|
'budget.netBalances': 'الأرصدة الصافية',
|
||||||
'budget.categoriesLabel': 'فئات',
|
'budget.categoriesLabel': 'فئات',
|
||||||
|
"costs.you": "You",
|
||||||
|
"costs.youShort": "Y",
|
||||||
|
"costs.youLower": "you",
|
||||||
|
"costs.youOwe": "You owe",
|
||||||
|
"costs.youOweSub": "You should pay others",
|
||||||
|
"costs.youreOwed": "You're owed",
|
||||||
|
"costs.youreOwedSub": "Others should pay you",
|
||||||
|
"costs.totalSpend": "Total trip spend",
|
||||||
|
"costs.totalSpendSub": "Across all travelers",
|
||||||
|
"costs.to": "To",
|
||||||
|
"costs.from": "From",
|
||||||
|
"costs.allSettled": "You're all settled up",
|
||||||
|
"costs.nothingOwed": "Nothing owed to you",
|
||||||
|
"costs.yourShare": "Your share",
|
||||||
|
"costs.youPaid": "You paid",
|
||||||
|
"costs.expenses": "Expenses",
|
||||||
|
"costs.entries": "{count} entries",
|
||||||
|
"costs.searchPlaceholder": "Search expenses…",
|
||||||
|
"costs.filter.all": "All",
|
||||||
|
"costs.filter.mine": "Paid by me",
|
||||||
|
"costs.filter.owed": "I'm owed",
|
||||||
|
"costs.addExpense": "Add expense",
|
||||||
|
"costs.editExpense": "Edit expense",
|
||||||
|
"costs.noMatch": "No expenses match your search.",
|
||||||
|
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||||
|
"costs.spent": "{amount} spent",
|
||||||
|
"costs.noDate": "No date",
|
||||||
|
"costs.noOnePaid": "No one paid yet",
|
||||||
|
"costs.youLent": "you lent {amount}",
|
||||||
|
"costs.youBorrowed": "you borrowed {amount}",
|
||||||
|
"costs.settleUp": "Settle up",
|
||||||
|
"costs.history": "History",
|
||||||
|
"costs.everyoneSquare": "Everyone's square",
|
||||||
|
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||||
|
"costs.pay": "pay",
|
||||||
|
"costs.pays": "pays",
|
||||||
|
"costs.settle": "Settle",
|
||||||
|
"costs.balances": "Balances",
|
||||||
|
"costs.byCategory": "By category",
|
||||||
|
"costs.noCategories": "No expenses yet.",
|
||||||
|
"costs.settleHistory": "Settle history",
|
||||||
|
"costs.noSettlements": "No settled payments yet.",
|
||||||
|
"costs.paymentsSettled": "{count} payments settled",
|
||||||
|
"costs.paid": "paid",
|
||||||
|
"costs.undo": "Undo",
|
||||||
|
"costs.whatFor": "What was it for?",
|
||||||
|
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||||
|
"costs.totalAmount": "Total amount",
|
||||||
|
"costs.currency": "Currency",
|
||||||
|
"costs.day": "Day",
|
||||||
|
"costs.rateLabel": "1 {from} in {to}",
|
||||||
|
"costs.category": "Category",
|
||||||
|
"costs.whoPaid": "Who paid?",
|
||||||
|
"costs.splitBetween": "Split equally between",
|
||||||
|
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||||
|
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||||
|
"costs.cat.accommodation": "Accommodation",
|
||||||
|
"costs.cat.food": "Food & drink",
|
||||||
|
"costs.cat.groceries": "Groceries",
|
||||||
|
"costs.cat.transport": "Transport",
|
||||||
|
"costs.cat.flights": "Flights",
|
||||||
|
"costs.cat.activities": "Activities",
|
||||||
|
"costs.cat.sightseeing": "Sightseeing",
|
||||||
|
"costs.cat.shopping": "Shopping",
|
||||||
|
"costs.cat.fees": "Fees & tickets",
|
||||||
|
"costs.cat.health": "Health",
|
||||||
|
"costs.cat.tips": "Tips",
|
||||||
|
"costs.cat.other": "Other",
|
||||||
|
"costs.daysCount": "{count} days",
|
||||||
|
"costs.travelers": "{count} travelers",
|
||||||
|
"costs.liveRate": "live rate",
|
||||||
|
"costs.settleAll": "Settle all",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -288,5 +288,8 @@ const settings: TranslationStrings = {
|
|||||||
'Business Class Dreamer', // en-fallback
|
'Business Class Dreamer', // en-fallback
|
||||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller', // en-fallback
|
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller', // en-fallback
|
||||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
|
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate', // en-fallback
|
||||||
|
"settings.currency": "Currency",
|
||||||
|
"settings.currencyHint": "All amounts in Costs are converted to and shown in this currency.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default settings;
|
export default settings;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user