- AccountForm now exposes a 'category' variant with a kind selector (simple | priced); the legacy 'account' variant is unchanged modulo the new symbol-required-for-priced UI guard. - SnapshotLineRow dispatches on account.category_kind: * simple variant unchanged from #146 * priced variant: quantity + unit_price inputs + read-only computed value rendered live (qty × price, 2 decimals) + [Manuel] attribution tag - useSnapshotEditor extends state with pricedValues map, exposes setLineQuantity / setLineUnitPrice handlers, prefill copies quantity but leaves unit_price blank (per spec-decisions row), save() builds mixed simple+priced batches. - SnapshotEditor + SnapshotEditPage thread the new priced state. - Total line at the top of SnapshotEditPage now sums simple + priced contributions live as the user types. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
7.9 KiB
TypeScript
204 lines
7.9 KiB
TypeScript
// SnapshotLineRow — single account line inside the snapshot editor.
|
||
//
|
||
// Two variants are dispatched by `account.category_kind`:
|
||
//
|
||
// - `simple` (Issue #146): a single value input keyed by `account_id`.
|
||
// - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both
|
||
// required), and a read-only `value` field that
|
||
// renders `quantity * unit_price` live as the
|
||
// user types. An attribution tag `[Manuel]`
|
||
// appears next to the row; the `[via Maximus]`
|
||
// tag will land with Issue #143 (price-fetching).
|
||
//
|
||
// We keep this component dumb on purpose: it receives strings from the
|
||
// parent (the editor stores raw strings to preserve partial input) and
|
||
// emits new strings on every change. Numeric validation happens at save
|
||
// time in `useSnapshotEditor.save` against the service's
|
||
// `validateLineKindInvariants` helper.
|
||
|
||
import { ChangeEvent, useMemo } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { BalanceAccountWithCategory } from "../../shared/types";
|
||
|
||
interface BaseProps {
|
||
account: BalanceAccountWithCategory;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
interface SimpleProps extends BaseProps {
|
||
value: string;
|
||
onChange: (next: string) => void;
|
||
/** Optional priced handlers for callers that wire both at once. */
|
||
quantityValue?: string;
|
||
unitPriceValue?: string;
|
||
onQuantityChange?: (next: string) => void;
|
||
onUnitPriceChange?: (next: string) => void;
|
||
}
|
||
|
||
type Props = SimpleProps;
|
||
|
||
/**
|
||
* Parse a string like "12.34" or "12,34" into a finite number, or null
|
||
* if invalid / empty. Used by the priced variant to compute the live
|
||
* `value` preview.
|
||
*/
|
||
function parseDecimal(raw: string): number | null {
|
||
if (!raw) return null;
|
||
const trimmed = String(raw).trim().replace(",", ".");
|
||
if (!trimmed) return null;
|
||
const n = Number(trimmed);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
export default function SnapshotLineRow({
|
||
account,
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
quantityValue,
|
||
unitPriceValue,
|
||
onQuantityChange,
|
||
onUnitPriceChange,
|
||
}: Props) {
|
||
const { t } = useTranslation();
|
||
const isPriced = account.category_kind === "priced";
|
||
|
||
// Compute the live value preview for priced rows. Returns null when
|
||
// either input cannot yet be parsed (so we display a placeholder).
|
||
const computedPricedValue = useMemo(() => {
|
||
if (!isPriced) return null;
|
||
const qty = parseDecimal(quantityValue ?? "");
|
||
const price = parseDecimal(unitPriceValue ?? "");
|
||
if (qty === null || price === null) return null;
|
||
return qty * price;
|
||
}, [isPriced, quantityValue, unitPriceValue]);
|
||
|
||
if (isPriced) {
|
||
const handleQty = (e: ChangeEvent<HTMLInputElement>) =>
|
||
onQuantityChange?.(e.target.value);
|
||
const handlePrice = (e: ChangeEvent<HTMLInputElement>) =>
|
||
onUnitPriceChange?.(e.target.value);
|
||
|
||
return (
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-medium truncate">{account.name}</span>
|
||
<span
|
||
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||
title={t("balance.snapshot.priced.attributionManualHint")}
|
||
>
|
||
{t("balance.snapshot.priced.attributionManual")}
|
||
</span>
|
||
</div>
|
||
{account.symbol && (
|
||
<div className="text-xs text-[var(--muted-foreground)]">
|
||
{account.symbol}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="flex flex-col gap-0.5">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={quantityValue ?? ""}
|
||
onChange={handleQty}
|
||
disabled={disabled}
|
||
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
|
||
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||
aria-label={t("balance.snapshot.priced.quantityLabel", {
|
||
account: account.name,
|
||
})}
|
||
/>
|
||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||
{t("balance.snapshot.priced.quantity")}
|
||
</span>
|
||
</div>
|
||
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||
×
|
||
</span>
|
||
<div className="flex flex-col gap-0.5">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={unitPriceValue ?? ""}
|
||
onChange={handlePrice}
|
||
disabled={disabled}
|
||
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
|
||
className="w-28 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
|
||
account: account.name,
|
||
})}
|
||
/>
|
||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||
{t("balance.snapshot.priced.unitPrice")}
|
||
</span>
|
||
</div>
|
||
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||
=
|
||
</span>
|
||
<div className="flex flex-col gap-0.5">
|
||
<input
|
||
type="text"
|
||
value={
|
||
computedPricedValue === null
|
||
? ""
|
||
: computedPricedValue.toFixed(2)
|
||
}
|
||
readOnly
|
||
disabled
|
||
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
||
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] focus:outline-none cursor-not-allowed"
|
||
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
||
account: account.name,
|
||
})}
|
||
aria-readonly="true"
|
||
/>
|
||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||
{t("balance.snapshot.priced.computedValue")}
|
||
</span>
|
||
</div>
|
||
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
||
{account.currency}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Simple variant — unchanged from #146.
|
||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
onChange(e.target.value);
|
||
};
|
||
|
||
return (
|
||
<div className="flex items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium truncate">{account.name}</div>
|
||
{account.symbol && (
|
||
<div className="text-xs text-[var(--muted-foreground)]">
|
||
{account.symbol}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={value}
|
||
onChange={handleChange}
|
||
disabled={disabled}
|
||
placeholder={t("balance.snapshot.line.valuePlaceholder")}
|
||
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||
aria-label={t("balance.snapshot.line.valueLabel", {
|
||
account: account.name,
|
||
})}
|
||
/>
|
||
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
||
{account.currency}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|