mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
3c5e406229
* feat(places): link an expense to a place, the way bookings already do Recording what a museum ticket or a guided tour cost meant leaving the place, opening the Costs tab and typing it in again — while a booking has had a Costs block in its own form all along. The place form now carries that block. Same component, same flow: with nothing linked it offers Create expense, which saves the place and then opens the Costs editor for a new expense pointing at it; once linked it shows the expense with edit and remove. budget_items gains place_id beside the reservation_id it already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop and phone both, and the mobile editor carries the link through its own save path rather than the desktop one. The expense belongs to the place, not to a day assignment, so a place sitting on five days is not five tickets. Hanging it off the assignment would have meant adding a place to three days quietly creating three expenses and removing a day quietly deleting one. Deleting a place takes its expense with it in the same transaction and announces it, on every path that deletes a place: REST, both MCP tools and the plugin RPC. Closes #1298 * test(places): teach the RPC stubs about linkedExpenseIds The delete paths read the linked expense before the place is gone, so the two hand-built PlacesService stubs threw on the new method instead of reaching the hook, the remove and the broadcast — four failures out of one missing line.
95 lines
5.8 KiB
TypeScript
95 lines
5.8 KiB
TypeScript
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { formatMoney } from '../../utils/formatters'
|
|
import { catMeta } from '../Budget/costsCategories'
|
|
import type { BudgetItem } from '../../types'
|
|
|
|
/**
|
|
* The Costs block inside a booking modal — and, since #1298, inside the place
|
|
* form, which links its expense the same way. Replaces the old inline price +
|
|
* budget category fields: when no expense is linked yet it offers a "create
|
|
* expense" button (the modal saves its own record first, then opens the full
|
|
* Costs editor); once linked it shows the expense with edit / remove actions.
|
|
*
|
|
* Exactly one of reservationId / placeId is set — they are the two sides of the
|
|
* same link, and the block behaves identically on both.
|
|
*/
|
|
export function BookingCostsSection({ reservationId, placeId = null, hintKey = 'reservations.createExpenseHint', pendingExpense, onCreate, onEdit, onRemove }: {
|
|
reservationId: number | null
|
|
/** Set instead of reservationId when the block sits in the place form (#1298). */
|
|
placeId?: number | null
|
|
/** What gets saved before the editor opens — "the booking" or "the place". */
|
|
hintKey?: string
|
|
/** A cost parsed from an import that will be linked on save — previewed before the booking exists. */
|
|
pendingExpense?: { total_price: number; currency?: string | null; category: string } | null
|
|
onCreate: () => void
|
|
onEdit: (item: BudgetItem) => void
|
|
onRemove: (item: BudgetItem) => void
|
|
}) {
|
|
const { t, locale } = useTranslation()
|
|
const budgetItems = useTripStore(s => s.budgetItems)
|
|
const trip = useTripStore(s => s.trip)
|
|
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
|
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
|
const linked = reservationId
|
|
? budgetItems.find(i => i.reservation_id === reservationId)
|
|
: placeId
|
|
? budgetItems.find(i => i.place_id === placeId)
|
|
: null
|
|
|
|
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
|
|
|
// Import review (booking not saved yet): preview the parsed cost that will be linked on save.
|
|
if (!linked && pendingExpense && pendingExpense.total_price > 0) {
|
|
const meta = catMeta(pendingExpense.category)
|
|
const Icon = meta.Icon
|
|
return (
|
|
<div>
|
|
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
|
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
|
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
|
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
|
|
</div>
|
|
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (linked) {
|
|
const meta = catMeta(linked.category)
|
|
const Icon = meta.Icon
|
|
return (
|
|
<div>
|
|
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
|
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
|
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
|
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
|
|
</div>
|
|
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
|
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
|
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
|
<button type="button" onClick={onCreate}
|
|
className="bg-surface-secondary border border-edge text-content"
|
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
<Plus size={15} /> {t('reservations.createExpense')}
|
|
</button>
|
|
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 6 }}>{t(hintKey)}</div>
|
|
</div>
|
|
)
|
|
}
|