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";
|
} 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue