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>
This commit is contained in:
le king fu 2026-04-25 15:01:38 -04:00
parent db5bffbdcf
commit 6288a3fe23
5 changed files with 584 additions and 57 deletions

View file

@ -1,20 +1,31 @@
// AccountForm — variant=account (Issue #138 / Bilan #1a). // AccountForm — account or category variant.
// //
// The category variant lands in Issue #140 (Bilan #2) when the priced-kind // Mode = 'account' (Issue #138 / Bilan #1a): create / edit a balance_account
// switch becomes available. For now this component focuses on creating / // row bound to an existing category.
// editing a `balance_account` record bound to an existing category. // Mode = 'category' (Issue #140 / Bilan #2): create a balance_category row
// with a kind selector (`simple | priced`).
//
// Both variants live in the same component because they share the surrounding
// wiring (form layout, save / cancel buttons, validation feedback) and only
// the input fields differ.
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { import type {
BalanceAccount, BalanceAccount,
BalanceCategory, BalanceCategory,
BalanceCategoryKind,
} from "../../shared/types"; } from "../../shared/types";
import type { import type {
CreateBalanceAccountInput, CreateBalanceAccountInput,
CreateBalanceCategoryInput,
UpdateBalanceAccountInput, UpdateBalanceAccountInput,
} from "../../services/balance.service"; } from "../../services/balance.service";
// -----------------------------------------------------------------------------
// Account variant types
// -----------------------------------------------------------------------------
export interface AccountFormValues { export interface AccountFormValues {
balance_category_id: number; balance_category_id: number;
name: string; name: string;
@ -22,7 +33,8 @@ export interface AccountFormValues {
notes: string; notes: string;
} }
interface Props { interface AccountVariantProps {
mode: "account";
/** When provided, the form is in edit mode; otherwise creation. */ /** When provided, the form is in edit mode; otherwise creation. */
initialAccount?: BalanceAccount | null; initialAccount?: BalanceAccount | null;
categories: BalanceCategory[]; categories: BalanceCategory[];
@ -33,7 +45,26 @@ interface Props {
onCancel: () => void; onCancel: () => void;
} }
function defaultValues( // -----------------------------------------------------------------------------
// Category variant types (Issue #140)
// -----------------------------------------------------------------------------
export interface CategoryFormValues {
key: string;
i18n_key: string;
kind: BalanceCategoryKind;
}
interface CategoryVariantProps {
mode: "category";
isSaving: boolean;
onSubmit: (values: CreateBalanceCategoryInput) => Promise<void> | void;
onCancel: () => void;
}
type Props = AccountVariantProps | CategoryVariantProps;
function defaultAccountValues(
initial: BalanceAccount | null | undefined, initial: BalanceAccount | null | undefined,
categories: BalanceCategory[] categories: BalanceCategory[]
): AccountFormValues { ): AccountFormValues {
@ -55,22 +86,33 @@ function defaultValues(
}; };
} }
export default function AccountForm({ export default function AccountForm(props: Props) {
if (props.mode === "category") {
return <CategoryVariant {...props} />;
}
return <AccountVariant {...props} />;
}
// -----------------------------------------------------------------------------
// Account variant
// -----------------------------------------------------------------------------
function AccountVariant({
initialAccount, initialAccount,
categories, categories,
isSaving, isSaving,
onSubmit, onSubmit,
onCancel, onCancel,
}: Props) { }: AccountVariantProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [values, setValues] = useState<AccountFormValues>(() => const [values, setValues] = useState<AccountFormValues>(() =>
defaultValues(initialAccount, categories) defaultAccountValues(initialAccount, categories)
); );
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
// Reset form when target account changes (edit different row). // Reset form when target account changes (edit different row).
useEffect(() => { useEffect(() => {
setValues(defaultValues(initialAccount, categories)); setValues(defaultAccountValues(initialAccount, categories));
setTouched(false); setTouched(false);
}, [initialAccount, categories]); }, [initialAccount, categories]);
@ -80,17 +122,21 @@ export default function AccountForm({
); );
const isPriced = selectedCategory?.kind === "priced"; const isPriced = selectedCategory?.kind === "priced";
const trimmedName = values.name.trim(); const trimmedName = values.name.trim();
const trimmedSymbol = values.symbol.trim();
const nameInvalid = touched && trimmedName.length === 0; const nameInvalid = touched && trimmedName.length === 0;
// Priced categories require a symbol — surfaced as a validation error.
const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0;
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setTouched(true); setTouched(true);
if (!trimmedName) return; if (!trimmedName) return;
if (isPriced && !trimmedSymbol) return;
const payload: CreateBalanceAccountInput = { const payload: CreateBalanceAccountInput = {
balance_category_id: values.balance_category_id, balance_category_id: values.balance_category_id,
name: trimmedName, name: trimmedName,
symbol: values.symbol.trim() || null, symbol: trimmedSymbol || null,
notes: values.notes.trim() || null, notes: values.notes.trim() || null,
}; };
@ -178,14 +224,24 @@ export default function AccountForm({
type="text" type="text"
value={values.symbol} value={values.symbol}
onChange={(e) => setValues({ ...values, symbol: e.target.value })} onChange={(e) => setValues({ ...values, symbol: e.target.value })}
onBlur={() => setTouched(true)}
placeholder={ placeholder={
isPriced isPriced
? t("balance.account.form.symbolPlaceholderPriced") ? t("balance.account.form.symbolPlaceholderPriced")
: t("balance.account.form.symbolPlaceholderSimple") : t("balance.account.form.symbolPlaceholderSimple")
} }
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
symbolMissingForPriced
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoComplete="off" autoComplete="off"
/> />
{symbolMissingForPriced && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.symbolRequiredForPriced")}
</p>
)}
</div> </div>
<div> <div>
@ -216,7 +272,12 @@ export default function AccountForm({
</button> </button>
<button <button
type="submit" type="submit"
disabled={isSaving || !trimmedName || categories.length === 0} disabled={
isSaving ||
!trimmedName ||
categories.length === 0 ||
(isPriced && !trimmedSymbol)
}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50" className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
> >
{isEditing {isEditing
@ -227,3 +288,141 @@ export default function AccountForm({
</form> </form>
); );
} }
// -----------------------------------------------------------------------------
// Category variant (Issue #140)
// -----------------------------------------------------------------------------
function CategoryVariant({
isSaving,
onSubmit,
onCancel,
}: CategoryVariantProps) {
const { t } = useTranslation();
const [values, setValues] = useState<CategoryFormValues>({
key: "",
i18n_key: "",
kind: "simple",
});
const [touched, setTouched] = useState(false);
const trimmedKey = values.key.trim();
const trimmedLabel = values.i18n_key.trim();
const keyInvalid = touched && trimmedKey.length === 0;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setTouched(true);
if (!trimmedKey) return;
// Fall back to the key if no human label was supplied.
const i18nKey = trimmedLabel || trimmedKey;
await onSubmit({
key: trimmedKey,
i18n_key: i18nKey,
kind: values.kind,
sort_order: 100, // user-created categories sort after seeded ones
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-key"
>
{t("balance.category.form.key")}
</label>
<input
id="category-key"
type="text"
value={values.key}
onChange={(e) =>
setValues({ ...values, key: e.target.value })
}
onBlur={() => setTouched(true)}
placeholder={t("balance.category.form.keyPlaceholder")}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
keyInvalid
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoComplete="off"
autoFocus
/>
{keyInvalid && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.nameRequired")}
</p>
)}
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-label"
>
{t("balance.category.form.label")}
</label>
<input
id="category-label"
type="text"
value={values.i18n_key}
onChange={(e) =>
setValues({ ...values, i18n_key: e.target.value })
}
placeholder={t("balance.category.form.labelPlaceholder")}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
autoComplete="off"
/>
</div>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-kind"
>
{t("balance.category.form.kindLabel")}
</label>
<select
id="category-kind"
value={values.kind}
onChange={(e) =>
setValues({
...values,
kind: e.target.value as BalanceCategoryKind,
})
}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="simple">{t("balance.category.kind.simple")}</option>
<option value="priced">{t("balance.category.kind.priced")}</option>
</select>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{values.kind === "priced"
? t("balance.category.form.kindHintPriced")
: t("balance.category.form.kindHintSimple")}
</p>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={isSaving || !trimmedKey}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("balance.category.form.create")}
</button>
</div>
</form>
);
}

View file

@ -1,11 +1,9 @@
// SnapshotEditor — groups the active accounts by balance category and // SnapshotEditor — groups the active accounts by balance category and
// renders one `SnapshotLineRow` per account. // renders one `SnapshotLineRow` per account.
// //
// Issue #146 / Bilan #1b: simple-kind editor only. The priced variant // Both `simple` and `priced` variants are dispatched by `account.category_kind`
// (quantity x unit_price + price fetch button) is rendered in #140. // inside `SnapshotLineRow`. The editor itself only carries the values down
// Until then, accounts whose category is `priced` still appear here so // and the change handlers up.
// the user can enter a manual aggregate value — the storage layer accepts
// a simple-kind line for any account regardless of its category kind.
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -13,13 +11,19 @@ import type {
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceCategory, BalanceCategory,
} from "../../shared/types"; } from "../../shared/types";
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
import SnapshotLineRow from "./SnapshotLineRow"; import SnapshotLineRow from "./SnapshotLineRow";
interface Props { interface Props {
accounts: BalanceAccountWithCategory[]; accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[]; categories: BalanceCategory[];
/** account_id → string-typed value (simple kind). */
values: Record<number, string>; values: Record<number, string>;
/** account_id → {quantity, unit_price} strings (priced kind). */
pricedValues: Record<number, PricedEntry>;
onValueChange: (accountId: number, next: string) => void; onValueChange: (accountId: number, next: string) => void;
onQuantityChange: (accountId: number, next: string) => void;
onUnitPriceChange: (accountId: number, next: string) => void;
disabled?: boolean; disabled?: boolean;
} }
@ -27,7 +31,10 @@ export default function SnapshotEditor({
accounts, accounts,
categories, categories,
values, values,
pricedValues,
onValueChange, onValueChange,
onQuantityChange,
onUnitPriceChange,
disabled, disabled,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -75,15 +82,22 @@ export default function SnapshotEditor({
</h3> </h3>
</div> </div>
<div className="px-4"> <div className="px-4">
{catAccounts.map((acc) => ( {catAccounts.map((acc) => {
<SnapshotLineRow const priced = pricedValues[acc.id];
key={acc.id} return (
account={acc} <SnapshotLineRow
value={values[acc.id] ?? ""} key={acc.id}
onChange={(next) => onValueChange(acc.id, next)} account={acc}
disabled={disabled} value={values[acc.id] ?? ""}
/> quantityValue={priced?.quantity ?? ""}
))} unitPriceValue={priced?.unit_price ?? ""}
onChange={(next) => onValueChange(acc.id, next)}
onQuantityChange={(next) => onQuantityChange(acc.id, next)}
onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)}
disabled={disabled}
/>
);
})}
</div> </div>
</div> </div>
))} ))}

View file

@ -1,23 +1,53 @@
// SnapshotLineRow — single account line inside the snapshot editor. // SnapshotLineRow — single account line inside the snapshot editor.
// //
// Issue #146 / Bilan #1b ships the *simple* variant only: a single value // Two variants are dispatched by `account.category_kind`:
// input keyed by `account_id`. The priced variant (quantity / unit_price /
// computed value + price-fetch button) lands in Issue #140 / Bilan #2.
// //
// We intentionally keep this component dumb: it receives a string value // - `simple` (Issue #146): a single value input keyed by `account_id`.
// from the parent (the editor stores raw strings to preserve partial input // - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both
// the user is typing) and emits the new string on every change. Numeric // required), and a read-only `value` field that
// validation happens at save time in `useSnapshotEditor.save`. // 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 } from "react"; import { ChangeEvent, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { BalanceAccountWithCategory } from "../../shared/types"; import type { BalanceAccountWithCategory } from "../../shared/types";
interface Props { interface BaseProps {
account: BalanceAccountWithCategory; account: BalanceAccountWithCategory;
disabled?: boolean;
}
interface SimpleProps extends BaseProps {
value: string; value: string;
onChange: (next: string) => void; onChange: (next: string) => void;
disabled?: boolean; /** 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({ export default function SnapshotLineRow({
@ -25,9 +55,119 @@ export default function SnapshotLineRow({
value, value,
onChange, onChange,
disabled, disabled,
quantityValue,
unitPriceValue,
onQuantityChange,
onUnitPriceChange,
}: Props) { }: Props) {
const { t } = useTranslation(); 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>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value); onChange(e.target.value);
}; };

View file

@ -38,6 +38,12 @@ import {
export type SnapshotEditorMode = "new" | "edit"; export type SnapshotEditorMode = "new" | "edit";
/** String-typed entry for a priced-kind line being edited. */
export interface PricedEntry {
quantity: string;
unit_price: string;
}
interface State { interface State {
mode: SnapshotEditorMode; mode: SnapshotEditorMode;
/** ISO YYYY-MM-DD; controlled in 'new' mode, frozen in 'edit'. */ /** ISO YYYY-MM-DD; controlled in 'new' mode, frozen in 'edit'. */
@ -49,11 +55,16 @@ interface State {
/** Used to group lines by category in the editor view. */ /** Used to group lines by category in the editor view. */
categories: BalanceCategory[]; categories: BalanceCategory[];
/** /**
* Map of account_id string-typed value. We keep strings to preserve * Map of account_id string-typed value (simple kind only). We keep
* empty / partial input the user is typing; conversion to number happens * strings to preserve empty / partial input; conversion to number
* at save time (and at validation when needed). * happens at save time.
*/ */
values: Record<number, string>; values: Record<number, string>;
/**
* Map of account_id string-typed `{quantity, unit_price}` (priced
* kind only). Same partial-input guarantee as `values`.
*/
pricedValues: Record<number, PricedEntry>;
/** Snapshot whose values would prefill if the user clicks "Prefill". */ /** Snapshot whose values would prefill if the user clicks "Prefill". */
previousSnapshot: BalanceSnapshot | null; previousSnapshot: BalanceSnapshot | null;
/** Lines from `previousSnapshot` (loaded lazily when needed). */ /** Lines from `previousSnapshot` (loaded lazily when needed). */
@ -78,13 +89,28 @@ type Action =
accounts: BalanceAccountWithCategory[]; accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[]; categories: BalanceCategory[];
values: Record<number, string>; values: Record<number, string>;
pricedValues: Record<number, PricedEntry>;
previousSnapshot: BalanceSnapshot | null; previousSnapshot: BalanceSnapshot | null;
previousLines: BalanceSnapshotLine[] | null; previousLines: BalanceSnapshotLine[] | null;
}; };
} }
| { type: "SET_DATE"; payload: string } | { type: "SET_DATE"; payload: string }
| { type: "SET_VALUE"; payload: { accountId: number; value: string } } | { type: "SET_VALUE"; payload: { accountId: number; value: string } }
| { type: "PREFILL"; payload: Record<number, string> } | {
type: "SET_PRICED_FIELD";
payload: {
accountId: number;
field: "quantity" | "unit_price";
value: string;
};
}
| {
type: "PREFILL";
payload: {
values: Record<number, string>;
pricedValues: Record<number, PricedEntry>;
};
}
| { type: "RESET" } | { type: "RESET" }
| { type: "CLEAR_DIRTY" }; | { type: "CLEAR_DIRTY" };
@ -96,6 +122,7 @@ function initialState(initialDate: string): State {
accounts: [], accounts: [],
categories: [], categories: [],
values: {}, values: {},
pricedValues: {},
previousSnapshot: null, previousSnapshot: null,
previousLines: null, previousLines: null,
isLoading: false, isLoading: false,
@ -129,6 +156,7 @@ function reducer(state: State, action: Action): State {
accounts: action.payload.accounts, accounts: action.payload.accounts,
categories: action.payload.categories, categories: action.payload.categories,
values: action.payload.values, values: action.payload.values,
pricedValues: action.payload.pricedValues,
previousSnapshot: action.payload.previousSnapshot, previousSnapshot: action.payload.previousSnapshot,
previousLines: action.payload.previousLines, previousLines: action.payload.previousLines,
isLoading: false, isLoading: false,
@ -148,10 +176,33 @@ function reducer(state: State, action: Action): State {
}, },
isDirty: true, isDirty: true,
}; };
case "SET_PRICED_FIELD": {
const existing =
state.pricedValues[action.payload.accountId] ?? {
quantity: "",
unit_price: "",
};
const next: PricedEntry =
action.payload.field === "quantity"
? { ...existing, quantity: action.payload.value }
: { ...existing, unit_price: action.payload.value };
return {
...state,
pricedValues: {
...state.pricedValues,
[action.payload.accountId]: next,
},
isDirty: true,
};
}
case "PREFILL": case "PREFILL":
return { return {
...state, ...state,
values: { ...state.values, ...action.payload }, values: { ...state.values, ...action.payload.values },
pricedValues: {
...state.pricedValues,
...action.payload.pricedValues,
},
isDirty: true, isDirty: true,
}; };
case "RESET": case "RESET":
@ -160,6 +211,7 @@ function reducer(state: State, action: Action): State {
// Keep the loaded structure (accounts, categories, snapshot) but wipe // Keep the loaded structure (accounts, categories, snapshot) but wipe
// user input back to a clean slate sourced from the saved lines. // user input back to a clean slate sourced from the saved lines.
values: {}, values: {},
pricedValues: {},
isDirty: true, isDirty: true,
}; };
case "CLEAR_DIRTY": case "CLEAR_DIRTY":
@ -222,11 +274,37 @@ export function useSnapshotEditor(options: Options = {}) {
const existing = await getSnapshotByDate(targetDate); const existing = await getSnapshotByDate(targetDate);
const isEdit = !!existing; const isEdit = !!existing;
let values: Record<number, string> = {}; let values: Record<number, string> = {};
let pricedValues: Record<number, PricedEntry> = {};
let previousLines: BalanceSnapshotLine[] | null = null; let previousLines: BalanceSnapshotLine[] | null = null;
// Index account kinds for quick line classification.
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
for (const acc of accounts) {
kindByAccountId.set(acc.id, acc.category_kind);
}
if (existing) { if (existing) {
const lines = await listLinesBySnapshot(existing.id); const lines = await listLinesBySnapshot(existing.id);
for (const line of lines) { for (const line of lines) {
values[line.account_id] = String(line.value); // The line itself carries quantity / unit_price for priced kinds;
// we still cross-check against the account kind to decide which
// input map this row belongs to (it dictates what the user sees).
const kind = kindByAccountId.get(line.account_id);
if (
kind === "priced" ||
(line.quantity !== null && line.unit_price !== null)
) {
pricedValues[line.account_id] = {
quantity:
line.quantity !== null && line.quantity !== undefined
? String(line.quantity)
: "",
unit_price:
line.unit_price !== null && line.unit_price !== undefined
? String(line.unit_price)
: "",
};
} else {
values[line.account_id] = String(line.value);
}
} }
} }
const previous = await getPreviousSnapshot(targetDate); const previous = await getPreviousSnapshot(targetDate);
@ -243,6 +321,7 @@ export function useSnapshotEditor(options: Options = {}) {
accounts, accounts,
categories, categories,
values, values,
pricedValues,
previousSnapshot: previous, previousSnapshot: previous,
previousLines, previousLines,
}, },
@ -269,17 +348,36 @@ export function useSnapshotEditor(options: Options = {}) {
}); });
}, []); }, []);
const setLineQuantity = useCallback(
(accountId: number, value: string) => {
dispatch({
type: "SET_PRICED_FIELD",
payload: { accountId, field: "quantity", value },
});
},
[]
);
const setLineUnitPrice = useCallback(
(accountId: number, value: string) => {
dispatch({
type: "SET_PRICED_FIELD",
payload: { accountId, field: "unit_price", value },
});
},
[]
);
const reset = useCallback(() => { const reset = useCallback(() => {
dispatch({ type: "RESET" }); dispatch({ type: "RESET" });
}, []); }, []);
/** /**
* Build the prefill map from the previous snapshot. Per spec-decisions * Build the prefill map from the previous snapshot. Per spec-decisions
* row "Bouton Pré-remplir" (Issue 1b decision): * row "Bouton Pré-remplir":
* - simple kind copy value * - simple kind copy value
* - priced kind copy quantity, leave unit_price blank effectively * - priced kind copy quantity, leave unit_price blank (the user
* no-op at Issue #146 because priced UI ships in #140. * must enter or fetch a fresh price each time).
* We add a TODO so the priced branch is explicit.
*/ */
const prefillFromPrevious = useCallback(() => { const prefillFromPrevious = useCallback(() => {
const lines = state.previousLines; const lines = state.previousLines;
@ -288,18 +386,29 @@ export function useSnapshotEditor(options: Options = {}) {
for (const acc of state.accounts) { for (const acc of state.accounts) {
accountKindById.set(acc.id, acc.category_kind); accountKindById.set(acc.id, acc.category_kind);
} }
const next: Record<number, string> = {}; const nextSimple: Record<number, string> = {};
const nextPriced: Record<number, PricedEntry> = {};
for (const line of lines) { for (const line of lines) {
const kind = accountKindById.get(line.account_id); const kind = accountKindById.get(line.account_id);
if (!kind) continue; // archived account — skip if (!kind) continue; // archived account — skip
if (kind === "simple") { if (kind === "simple") {
next[line.account_id] = String(line.value); nextSimple[line.account_id] = String(line.value);
} else { } else {
// TODO Issue #140 — implement priced prefill (quantity copy, leave // Priced: copy quantity, leave unit_price blank — quantities don't
// unit_price blank). For Issue #146 the priced UI does not exist yet. // change unless the user buys / sells, prices always change.
nextPriced[line.account_id] = {
quantity:
line.quantity !== null && line.quantity !== undefined
? String(line.quantity)
: "",
unit_price: "",
};
} }
} }
dispatch({ type: "PREFILL", payload: next }); dispatch({
type: "PREFILL",
payload: { values: nextSimple, pricedValues: nextPriced },
});
}, [state.previousLines, state.accounts]); }, [state.previousLines, state.accounts]);
/** /**
@ -326,7 +435,13 @@ export function useSnapshotEditor(options: Options = {}) {
snapshot_date: state.snapshotDate, snapshot_date: state.snapshotDate,
}); });
} }
const lines = Object.entries(state.values) // Index account kinds for line classification at save time.
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
for (const acc of state.accounts) {
kindByAccountId.set(acc.id, acc.category_kind);
}
// Simple-kind lines: drop empty fields, accept any finite number.
const simpleLines = Object.entries(state.values)
.filter(([, v]) => v !== undefined && String(v).trim().length > 0) .filter(([, v]) => v !== undefined && String(v).trim().length > 0)
.map(([accountIdStr, raw]) => { .map(([accountIdStr, raw]) => {
const accountId = Number(accountIdStr); const accountId = Number(accountIdStr);
@ -338,9 +453,49 @@ export function useSnapshotEditor(options: Options = {}) {
`Invalid value for account ${accountId}: "${raw}"` `Invalid value for account ${accountId}: "${raw}"`
); );
} }
return { account_id: accountId, value: num }; return {
account_id: accountId,
value: num,
account_kind: "simple" as const,
};
}); });
await upsertSnapshotLines(snapshotId, lines); // Priced-kind lines: both qty + price required, value computed.
const pricedLines = Object.entries(state.pricedValues)
.filter(
([, entry]) =>
entry &&
String(entry.quantity ?? "").trim().length > 0 &&
String(entry.unit_price ?? "").trim().length > 0
)
.map(([accountIdStr, entry]) => {
const accountId = Number(accountIdStr);
const qtyTrim = String(entry.quantity).trim().replace(",", ".");
const priceTrim = String(entry.unit_price).trim().replace(",", ".");
const qty = Number(qtyTrim);
const price = Number(priceTrim);
if (!Number.isFinite(qty)) {
throw new BalanceServiceError(
"snapshot_priced_quantity_required",
`Invalid quantity for account ${accountId}: "${entry.quantity}"`
);
}
if (!Number.isFinite(price)) {
throw new BalanceServiceError(
"snapshot_priced_unit_price_required",
`Invalid unit_price for account ${accountId}: "${entry.unit_price}"`
);
}
return {
account_id: accountId,
account_kind: "priced" as const,
quantity: qty,
unit_price: price,
// value = qty * price; the service re-validates the relation
// within PRICED_VALUE_TOLERANCE before persisting.
value: qty * price,
};
});
await upsertSnapshotLines(snapshotId, [...simpleLines, ...pricedLines]);
dispatch({ type: "CLEAR_DIRTY" }); dispatch({ type: "CLEAR_DIRTY" });
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state. // Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
await loadForDate(state.snapshotDate); await loadForDate(state.snapshotDate);
@ -356,6 +511,8 @@ export function useSnapshotEditor(options: Options = {}) {
state.snapshot, state.snapshot,
state.snapshotDate, state.snapshotDate,
state.values, state.values,
state.pricedValues,
state.accounts,
loadForDate, loadForDate,
]); ]);
@ -377,6 +534,8 @@ export function useSnapshotEditor(options: Options = {}) {
state, state,
setDate, setDate,
setLineValue, setLineValue,
setLineQuantity,
setLineUnitPrice,
reset, reset,
prefillFromPrevious, prefillFromPrevious,
save, save,

View file

@ -49,7 +49,8 @@ export default function SnapshotEditPage() {
const isEditMode = state.mode === "edit"; const isEditMode = state.mode === "edit";
const canPrefill = !!state.previousSnapshot; const canPrefill = !!state.previousSnapshot;
// Aggregate value (simple kind only — sums all visible numeric inputs). // Aggregate value across simple + priced lines (computed live as the
// user types). Priced contribution = quantity × unit_price.
const totalValue = useMemo(() => { const totalValue = useMemo(() => {
let total = 0; let total = 0;
let hasAny = false; let hasAny = false;
@ -62,8 +63,19 @@ export default function SnapshotEditPage() {
hasAny = true; hasAny = true;
} }
} }
for (const entry of Object.values(state.pricedValues)) {
if (!entry) continue;
const qty = Number(String(entry.quantity ?? "").trim().replace(",", "."));
const price = Number(
String(entry.unit_price ?? "").trim().replace(",", ".")
);
if (Number.isFinite(qty) && Number.isFinite(price)) {
total += qty * price;
hasAny = true;
}
}
return hasAny ? total : null; return hasAny ? total : null;
}, [state.values]); }, [state.values, state.pricedValues]);
const handleSave = async () => { const handleSave = async () => {
try { try {
@ -184,7 +196,10 @@ export default function SnapshotEditPage() {
accounts={state.accounts} accounts={state.accounts}
categories={state.categories} categories={state.categories}
values={state.values} values={state.values}
pricedValues={state.pricedValues}
onValueChange={editor.setLineValue} onValueChange={editor.setLineValue}
onQuantityChange={editor.setLineQuantity}
onUnitPriceChange={editor.setLineUnitPrice}
disabled={state.isSaving} disabled={state.isSaving}
/> />
)} )}