feat(balance): priced-kind support (#140) #149
5 changed files with 584 additions and 57 deletions
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue