feat(balance): priced-kind support (#140) #149
1 changed files with 85 additions and 100 deletions
|
|
@ -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"
|
||||
<AccountForm
|
||||
mode="category"
|
||||
isSaving={state.isSaving}
|
||||
onSubmit={handleCategorySubmit}
|
||||
onCancel={() => setShowCategoryForm(false)}
|
||||
/>
|
||||
</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">
|
||||
)}
|
||||
|
||||
{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={() => {
|
||||
setShowCategoryForm(false);
|
||||
setNewCategoryKey("");
|
||||
setNewCategoryLabel("");
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
||||
onClick={() => setCategoryDeleteError(null)}
|
||||
className="text-xs underline shrink-0"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
{t("common.dismiss", { defaultValue: "OK" })}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -437,28 +421,29 @@ export default function AccountsPage() {
|
|||
>
|
||||
<Edit2 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={() => {
|
||||
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")
|
||||
}
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue