diff --git a/src/App.tsx b/src/App.tsx index dd6677b..10a099a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCartesPage from "./pages/ReportsCartesPage"; import SettingsPage from "./pages/SettingsPage"; +import AccountsPage from "./pages/AccountsPage"; import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage"; import CategoriesMigrationPage from "./pages/CategoriesMigrationPage"; import DocsPage from "./pages/DocsPage"; @@ -114,6 +115,7 @@ export default function App() { } /> } /> } /> + } /> } diff --git a/src/components/balance/AccountForm.tsx b/src/components/balance/AccountForm.tsx new file mode 100644 index 0000000..a56efb0 --- /dev/null +++ b/src/components/balance/AccountForm.tsx @@ -0,0 +1,229 @@ +// AccountForm — variant=account (Issue #138 / Bilan #1a). +// +// The category variant lands in Issue #140 (Bilan #2) when the priced-kind +// switch becomes available. For now this component focuses on creating / +// editing a `balance_account` record bound to an existing category. + +import { FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + BalanceAccount, + BalanceCategory, +} from "../../shared/types"; +import type { + CreateBalanceAccountInput, + UpdateBalanceAccountInput, +} from "../../services/balance.service"; + +export interface AccountFormValues { + balance_category_id: number; + name: string; + symbol: string; + notes: string; +} + +interface Props { + /** When provided, the form is in edit mode; otherwise creation. */ + initialAccount?: BalanceAccount | null; + categories: BalanceCategory[]; + isSaving: boolean; + onSubmit: ( + values: CreateBalanceAccountInput | UpdateBalanceAccountInput + ) => Promise | void; + onCancel: () => void; +} + +function defaultValues( + initial: BalanceAccount | null | undefined, + categories: BalanceCategory[] +): AccountFormValues { + if (initial) { + return { + balance_category_id: initial.balance_category_id, + name: initial.name, + symbol: initial.symbol ?? "", + notes: initial.notes ?? "", + }; + } + // First active category as a sane default + const first = categories.find((c) => c.is_active) ?? categories[0]; + return { + balance_category_id: first?.id ?? 0, + name: "", + symbol: "", + notes: "", + }; +} + +export default function AccountForm({ + initialAccount, + categories, + isSaving, + onSubmit, + onCancel, +}: Props) { + const { t } = useTranslation(); + const [values, setValues] = useState(() => + defaultValues(initialAccount, categories) + ); + const [touched, setTouched] = useState(false); + + // Reset form when target account changes (edit different row). + useEffect(() => { + setValues(defaultValues(initialAccount, categories)); + setTouched(false); + }, [initialAccount, categories]); + + const isEditing = !!initialAccount; + const selectedCategory = categories.find( + (c) => c.id === values.balance_category_id + ); + const isPriced = selectedCategory?.kind === "priced"; + const trimmedName = values.name.trim(); + const nameInvalid = touched && trimmedName.length === 0; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setTouched(true); + if (!trimmedName) return; + + const payload: CreateBalanceAccountInput = { + balance_category_id: values.balance_category_id, + name: trimmedName, + symbol: values.symbol.trim() || null, + notes: values.notes.trim() || null, + }; + + if (isEditing) { + const updatePayload: UpdateBalanceAccountInput = { + balance_category_id: payload.balance_category_id, + name: payload.name, + symbol: payload.symbol, + notes: payload.notes, + }; + await onSubmit(updatePayload); + } else { + await onSubmit(payload); + } + }; + + const renderCategoryLabel = (cat: BalanceCategory) => + t(cat.i18n_key, { defaultValue: cat.key }); + + return ( +
+
+ + +
+ +
+ + setValues({ ...values, name: e.target.value })} + onBlur={() => setTouched(true)} + 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)] ${ + nameInvalid + ? "border-[var(--negative)]" + : "border-[var(--border)]" + }`} + autoFocus + autoComplete="off" + /> + {nameInvalid && ( +

+ {t("balance.account.form.nameRequired")} +

+ )} +
+ +
+ + setValues({ ...values, symbol: e.target.value })} + placeholder={ + isPriced + ? t("balance.account.form.symbolPlaceholderPriced") + : 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)]" + autoComplete="off" + /> +
+ +
+ +