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";
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
import AccountForm from "../components/balance/AccountForm";
import type { CreateBalanceCategoryInput } from "../services/balance.service";
type Tab = "accounts" | "categories";
@ -43,14 +44,27 @@ export default function AccountsPage() {
useState<BalanceAccountWithCategory | null>(null);
const [showCategoryForm, setShowCategoryForm] = useState(false);
const [newCategoryKey, setNewCategoryKey] = useState("");
const [newCategoryLabel, setNewCategoryLabel] = useState("");
/** Local error string for category deletion guard (count + names of linked accounts). */
const [categoryDeleteError, setCategoryDeleteError] = useState<string | null>(
null
);
const activeCategories = useMemo(
() => state.categories.filter((c) => c.is_active),
[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) =>
t(cat.i18n_key, { defaultValue: cat.key });
@ -76,29 +90,39 @@ export default function AccountsPage() {
}
};
const handleCreateCategory = async () => {
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;
const handleCategorySubmit = async (input: CreateBalanceCategoryInput) => {
try {
await addCategory({
key,
i18n_key: i18nKey,
kind: "simple",
sort_order: 100, // user-created categories sort after seeded ones
});
setNewCategoryKey("");
setNewCategoryLabel("");
await addCategory(input);
setShowCategoryForm(false);
} catch {
// 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 (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="flex items-center gap-3 mb-6">
@ -174,6 +198,7 @@ export default function AccountsPage() {
: t("balance.account.form.createTitle")}
</h2>
<AccountForm
mode="account"
initialAccount={editingAccount ?? null}
categories={activeCategories}
isSaving={state.isSaving}
@ -312,66 +337,25 @@ export default function AccountsPage() {
<h2 className="text-lg font-semibold mb-4">
{t("balance.category.form.createTitle")}
</h2>
<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={newCategoryKey}
onChange={(e) => setNewCategoryKey(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.keyPlaceholder")}
autoComplete="off"
/>
</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={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>
<AccountForm
mode="category"
isSaving={state.isSaving}
onSubmit={handleCategorySubmit}
onCancel={() => setShowCategoryForm(false)}
/>
</div>
)}
{categoryDeleteError && (
<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">
<span>{categoryDeleteError}</span>
<button
type="button"
onClick={() => setCategoryDeleteError(null)}
className="text-xs underline shrink-0"
>
{t("common.dismiss", { defaultValue: "OK" })}
</button>
</div>
)}
@ -437,28 +421,29 @@ export default function AccountsPage() {
>
<Edit2 size={14} />
</button>
<button
type="button"
onClick={() => {
if (cat.is_seed) return;
if (
window.confirm(
t("balance.category.actions.deleteConfirm")
)
) {
removeCategory(cat.id);
}
}}
disabled={cat.is_seed}
title={
cat.is_seed
? t("balance.category.actions.deleteSeedHint")
: t("common.delete")
}
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} />
</button>
{(() => {
const linkedCount =
accountsByCategory.get(cat.id)?.length ?? 0;
const blocked = cat.is_seed || linkedCount > 0;
const titleKey = cat.is_seed
? t("balance.category.actions.deleteSeedHint")
: linkedCount > 0
? t("balance.category.actions.deleteHasAccountsHint", {
count: linkedCount,
})
: t("common.delete");
return (
<button
type="button"
onClick={() => handleDeleteCategory(cat)}
disabled={blocked}
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"
>
<Trash2 size={14} />
</button>
);
})()}
</div>
</td>
</tr>