Simpl-Resultat/src/components/balance/SnapshotLineRow.tsx
le king fu 6288a3fe23 feat(balance): support priced kind in AccountForm + SnapshotLineRow
- 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>
2026-04-25 15:01:38 -04:00

204 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}