Simpl-Resultat/src/components/balance/SnapshotLineRow.tsx
le king fu 3963f552ae
All checks were successful
PR Check / rust (push) Successful in 23m42s
PR Check / frontend (push) Successful in 2m26s
PR Check / rust (pull_request) Successful in 22m55s
PR Check / frontend (pull_request) Successful in 2m24s
feat(balance): add asset_type column to balance_categories
Priced balance categories now carry an explicit `asset_type`
('stock' | 'crypto') so PriceFetchControl can route to the right
provider without symbol heuristics. ETH = Ethan Allen NYSE AND
Ethereum crypto are no longer ambiguous.

Migration v10 adds a nullable column and backfills the two seeded
priced categories (key='stock','crypto'). Legacy custom priced rows
stay NULL until the user edits the category — SnapshotLineRow hides
the price-fetch button when asset_type is NULL on a priced row, so
manual entry remains available.

Service-side validation rejects priced creation without asset_type
('asset_type_required') and rejects values outside ('stock','crypto')
('asset_type_invalid'). Simple kind coerces asset_type to NULL.

The CategoryVariant of AccountForm shows the selector only when
kind=priced, requires it on submit, and resets it on kind switch.
i18n keys added under balance.category.assetType.* (FR + EN).

Tests:
- 4 new Rust migration tests in lib.rs (column add, seed backfill,
  legacy row stays NULL, CHECK rejects 'gold')
- 6 new vitest cases on createBalanceCategory + listBalanceAccounts
  asserts c.asset_type AS category_asset_type in the join
- balance-flow integration test updated to pass asset_type='stock'

No new test for SnapshotLineRow render guard — project lacks
@testing-library/react + jsdom; the guard is one boolean expression
covered by manual QA per autopilot decisions in PR #167.

Fixes #169
2026-04-28 19:54:04 -04:00

222 lines
8.7 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 is rendered by PriceFetchControl (Issue #158).
//
// 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";
import PriceFetchControl from "./PriceFetchControl";
interface BaseProps {
account: BalanceAccountWithCategory;
disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */
snapshotDate?: string;
}
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,
snapshotDate,
}: 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>
{/* PriceFetchControl — wired next to the unit_price input.
Hidden when category_asset_type is null (legacy custom priced
rows pre-#169 migration; user must edit the category to set it). */}
{account.symbol && account.category_asset_type && (
<PriceFetchControl
symbol={account.symbol}
date={snapshotDate ?? ""}
categoryKind={account.category_kind as "priced"}
assetType={account.category_asset_type}
onPriceFetched={(price) =>
onUnitPriceChange?.(String(price))
}
/>
)}
</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>
);
}