feat(balance): priced-kind support (#140) #149

Merged
maximus merged 4 commits from issue-140-bilan-2 into main 2026-04-26 13:25:21 +00:00
Showing only changes of commit 5bc7fe80b1 - Show all commits

View file

@ -20,6 +20,7 @@ import type {
} from "../shared/types"; } from "../shared/types";
import { useBalanceAccounts } from "../hooks/useBalanceAccounts"; import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
import AccountForm from "../components/balance/AccountForm"; import AccountForm from "../components/balance/AccountForm";
import type { CreateBalanceCategoryInput } from "../services/balance.service";
type Tab = "accounts" | "categories"; type Tab = "accounts" | "categories";
@ -43,14 +44,27 @@ export default function AccountsPage() {
useState<BalanceAccountWithCategory | null>(null); useState<BalanceAccountWithCategory | null>(null);
const [showCategoryForm, setShowCategoryForm] = useState(false); const [showCategoryForm, setShowCategoryForm] = useState(false);
const [newCategoryKey, setNewCategoryKey] = useState(""); /** Local error string for category deletion guard (count + names of linked accounts). */
const [newCategoryLabel, setNewCategoryLabel] = useState(""); const [categoryDeleteError, setCategoryDeleteError] = useState<string | null>(
null
);
const activeCategories = useMemo( const activeCategories = useMemo(
() => state.categories.filter((c) => c.is_active), () => state.categories.filter((c) => c.is_active),
[state.categories] [state.categories]
); );
/** Map category id → array of accounts linked to it (active + archived). */
const accountsByCategory = useMemo(() => {
const m = new Map<number, BalanceAccountWithCategory[]>();
for (const acc of state.accounts) {
const list = m.get(acc.balance_category_id) ?? [];
list.push(acc);
m.set(acc.balance_category_id, list);
}
return m;
}, [state.accounts]);
const renderCategoryLabel = (cat: BalanceCategory) => const renderCategoryLabel = (cat: BalanceCategory) =>
t(cat.i18n_key, { defaultValue: cat.key }); t(cat.i18n_key, { defaultValue: cat.key });
@ -76,29 +90,39 @@ export default function AccountsPage() {
} }
}; };
const handleCreateCategory = async () => { const handleCategorySubmit = async (input: CreateBalanceCategoryInput) => {
const key = newCategoryKey.trim();
const label = newCategoryLabel.trim();
if (!key) return;
// For user-created categories we use the literal label as the i18n_key
// fallback — they don't ship in the locale bundle, so renderers default
// to this string. (The CategoryCombobox does the same for legacy v2 rows.)
const i18nKey = label || key;
try { try {
await addCategory({ await addCategory(input);
key,
i18n_key: i18nKey,
kind: "simple",
sort_order: 100, // user-created categories sort after seeded ones
});
setNewCategoryKey("");
setNewCategoryLabel("");
setShowCategoryForm(false); setShowCategoryForm(false);
} catch { } catch {
// Error already surfaced via state.error // Error already surfaced via state.error
} }
}; };
/**
* Delete-guard for categories. The service refuses to delete a seeded
* category or one with linked accounts, but we pre-check at the UI to
* surface a richer message that lists the linked-account names.
*/
const handleDeleteCategory = (cat: BalanceCategory) => {
setCategoryDeleteError(null);
if (cat.is_seed) return;
const linked = accountsByCategory.get(cat.id) ?? [];
if (linked.length > 0) {
const sample = linked.slice(0, 3).map((a) => a.name).join(", ");
const more = linked.length > 3 ? ", …" : "";
setCategoryDeleteError(
t("balance.category.error.has_accounts", {
count: linked.length,
names: `${sample}${more}`,
})
);
return;
}
if (!window.confirm(t("balance.category.actions.deleteConfirm"))) return;
removeCategory(cat.id);
};
return ( return (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
@ -174,6 +198,7 @@ export default function AccountsPage() {
: t("balance.account.form.createTitle")} : t("balance.account.form.createTitle")}
</h2> </h2>
<AccountForm <AccountForm
mode="account"
initialAccount={editingAccount ?? null} initialAccount={editingAccount ?? null}
categories={activeCategories} categories={activeCategories}
isSaving={state.isSaving} isSaving={state.isSaving}
@ -312,66 +337,25 @@ export default function AccountsPage() {
<h2 className="text-lg font-semibold mb-4"> <h2 className="text-lg font-semibold mb-4">
{t("balance.category.form.createTitle")} {t("balance.category.form.createTitle")}
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <AccountForm
<div> mode="category"
<label isSaving={state.isSaving}
className="block text-sm font-medium mb-1" onSubmit={handleCategorySubmit}
htmlFor="category-key" onCancel={() => setShowCategoryForm(false)}
> />
{t("balance.category.form.key")} </div>
</label> )}
<input
id="category-key" {categoryDeleteError && (
type="text" <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20 flex items-start justify-between gap-2">
value={newCategoryKey} <span>{categoryDeleteError}</span>
onChange={(e) => setNewCategoryKey(e.target.value)} <button
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)]" type="button"
placeholder={t("balance.category.form.keyPlaceholder")} onClick={() => setCategoryDeleteError(null)}
autoComplete="off" className="text-xs underline shrink-0"
/> >
</div> {t("common.dismiss", { defaultValue: "OK" })}
<div> </button>
<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={newCategoryLabel}
onChange={(e) => setNewCategoryLabel(e.target.value)}
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)]"
placeholder={t("balance.category.form.labelPlaceholder")}
autoComplete="off"
/>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-3">
{t("balance.category.form.simpleOnlyNotice")}
</p>
<div className="flex justify-end gap-2 mt-4">
<button
type="button"
onClick={() => {
setShowCategoryForm(false);
setNewCategoryKey("");
setNewCategoryLabel("");
}}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleCreateCategory}
disabled={state.isSaving || !newCategoryKey.trim()}
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>
</div> </div>
)} )}
@ -437,28 +421,29 @@ export default function AccountsPage() {
> >
<Edit2 size={14} /> <Edit2 size={14} />
</button> </button>
<button {(() => {
type="button" const linkedCount =
onClick={() => { accountsByCategory.get(cat.id)?.length ?? 0;
if (cat.is_seed) return; const blocked = cat.is_seed || linkedCount > 0;
if ( const titleKey = cat.is_seed
window.confirm( ? t("balance.category.actions.deleteSeedHint")
t("balance.category.actions.deleteConfirm") : linkedCount > 0
) ? t("balance.category.actions.deleteHasAccountsHint", {
) { count: linkedCount,
removeCategory(cat.id); })
} : t("common.delete");
}} return (
disabled={cat.is_seed} <button
title={ type="button"
cat.is_seed onClick={() => handleDeleteCategory(cat)}
? t("balance.category.actions.deleteSeedHint") disabled={blocked}
: t("common.delete") title={titleKey}
} className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] disabled:opacity-30 disabled:cursor-not-allowed"
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] disabled:opacity-30 disabled:cursor-not-allowed" >
> <Trash2 size={14} />
<Trash2 size={14} /> </button>
</button> );
})()}
</div> </div>
</td> </td>
</tr> </tr>