feat(balance): schema migration v9 + service skeleton + AccountsPage (#138) #147
4 changed files with 980 additions and 0 deletions
|
|
@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage";
|
||||||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import AccountsPage from "./pages/AccountsPage";
|
||||||
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||||
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
|
|
@ -114,6 +115,7 @@ export default function App() {
|
||||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/settings/categories/standard"
|
path="/settings/categories/standard"
|
||||||
element={<CategoriesStandardGuidePage />}
|
element={<CategoriesStandardGuidePage />}
|
||||||
|
|
|
||||||
229
src/components/balance/AccountForm.tsx
Normal file
229
src/components/balance/AccountForm.tsx
Normal file
|
|
@ -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> | 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<AccountFormValues>(() =>
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" htmlFor="account-category">
|
||||||
|
{t("balance.account.form.category")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="account-category"
|
||||||
|
value={values.balance_category_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
balance_category_id: Number(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)]"
|
||||||
|
>
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<option value={0}>{t("balance.account.form.noCategory")}</option>
|
||||||
|
) : (
|
||||||
|
categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{renderCategoryLabel(cat)}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" htmlFor="account-name">
|
||||||
|
{t("balance.account.form.name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account-name"
|
||||||
|
type="text"
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--negative)]">
|
||||||
|
{t("balance.account.form.nameRequired")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
|
||||||
|
{t("balance.account.form.symbol")}
|
||||||
|
{isPriced && (
|
||||||
|
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
({t("balance.account.form.symbolPricedHint")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account-symbol"
|
||||||
|
type="text"
|
||||||
|
value={values.symbol}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" htmlFor="account-notes">
|
||||||
|
{t("balance.account.form.notes")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="account-notes"
|
||||||
|
value={values.notes}
|
||||||
|
onChange={(e) => setValues({ ...values, notes: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
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)] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.account.form.currencyMvpNotice")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving || !trimmedName || categories.length === 0}
|
||||||
|
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isEditing
|
||||||
|
? t("balance.account.form.save")
|
||||||
|
: t("balance.account.form.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
src/hooks/useBalanceAccounts.ts
Normal file
276
src/hooks/useBalanceAccounts.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
// useBalanceAccounts — scoped useReducer hook backing AccountsPage.
|
||||||
|
//
|
||||||
|
// Domain coverage (per spec-plan-bilan.md v2): the AccountsPage CRUD over
|
||||||
|
// `balance_accounts` AND `balance_categories`. Snapshots, lines, transfers,
|
||||||
|
// and returns are out of scope here — they belong to `useSnapshotEditor`
|
||||||
|
// (Issue #146 / Bilan #1b) and `useBalanceOverview` (Issue #141 / Bilan #3).
|
||||||
|
|
||||||
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
|
import type {
|
||||||
|
BalanceAccountWithCategory,
|
||||||
|
BalanceCategory,
|
||||||
|
BalanceCategoryKind,
|
||||||
|
} from "../shared/types";
|
||||||
|
import {
|
||||||
|
listBalanceAccounts,
|
||||||
|
listBalanceCategories,
|
||||||
|
createBalanceAccount,
|
||||||
|
updateBalanceAccount,
|
||||||
|
archiveBalanceAccount,
|
||||||
|
unarchiveBalanceAccount,
|
||||||
|
createBalanceCategory,
|
||||||
|
updateBalanceCategory,
|
||||||
|
deleteBalanceCategory,
|
||||||
|
BalanceServiceError,
|
||||||
|
type CreateBalanceAccountInput,
|
||||||
|
type CreateBalanceCategoryInput,
|
||||||
|
type UpdateBalanceAccountInput,
|
||||||
|
type UpdateBalanceCategoryInput,
|
||||||
|
} from "../services/balance.service";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
accounts: BalanceAccountWithCategory[];
|
||||||
|
categories: BalanceCategory[];
|
||||||
|
includeArchived: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
/** Stable error code for UIs that want to localize via i18n (e.g. seed protection). */
|
||||||
|
errorCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_SAVING"; payload: boolean }
|
||||||
|
| { type: "SET_ERROR"; payload: { message: string | null; code: string | null } }
|
||||||
|
| {
|
||||||
|
type: "SET_DATA";
|
||||||
|
payload: {
|
||||||
|
accounts: BalanceAccountWithCategory[];
|
||||||
|
categories: BalanceCategory[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "SET_INCLUDE_ARCHIVED"; payload: boolean };
|
||||||
|
|
||||||
|
function initialState(): State {
|
||||||
|
return {
|
||||||
|
accounts: [],
|
||||||
|
categories: [],
|
||||||
|
includeArchived: false,
|
||||||
|
isLoading: false,
|
||||||
|
isSaving: false,
|
||||||
|
error: null,
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case "SET_SAVING":
|
||||||
|
return { ...state, isSaving: action.payload };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
error: action.payload.message,
|
||||||
|
errorCode: action.payload.code,
|
||||||
|
isLoading: false,
|
||||||
|
isSaving: false,
|
||||||
|
};
|
||||||
|
case "SET_DATA":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
accounts: action.payload.accounts,
|
||||||
|
categories: action.payload.categories,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
case "SET_INCLUDE_ARCHIVED":
|
||||||
|
return { ...state, includeArchived: action.payload };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(e: unknown): { message: string; code: string | null } {
|
||||||
|
if (e instanceof BalanceServiceError) {
|
||||||
|
return { message: e.message, code: e.code };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
code: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBalanceAccounts() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
||||||
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
const refreshData = useCallback(async (includeArchived: boolean) => {
|
||||||
|
const fetchId = ++fetchIdRef.current;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
||||||
|
try {
|
||||||
|
const [accounts, categories] = await Promise.all([
|
||||||
|
listBalanceAccounts({ includeArchived }),
|
||||||
|
listBalanceCategories(),
|
||||||
|
]);
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
|
||||||
|
} catch (e) {
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshData(state.includeArchived);
|
||||||
|
}, [state.includeArchived, refreshData]);
|
||||||
|
|
||||||
|
const setIncludeArchived = useCallback((next: boolean) => {
|
||||||
|
dispatch({ type: "SET_INCLUDE_ARCHIVED", payload: next });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Account mutations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const addAccount = useCallback(
|
||||||
|
async (input: CreateBalanceAccountInput) => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
try {
|
||||||
|
await createBalanceAccount(input);
|
||||||
|
await refreshData(state.includeArchived);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.includeArchived, refreshData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editAccount = useCallback(
|
||||||
|
async (id: number, input: UpdateBalanceAccountInput) => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
try {
|
||||||
|
await updateBalanceAccount(id, input);
|
||||||
|
await refreshData(state.includeArchived);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.includeArchived, refreshData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const archiveAccount = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
try {
|
||||||
|
await archiveBalanceAccount(id);
|
||||||
|
await refreshData(state.includeArchived);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.includeArchived, refreshData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unarchiveAccount = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
try {
|
||||||
|
await unarchiveBalanceAccount(id);
|
||||||
|
await refreshData(state.includeArchived);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.includeArchived, refreshData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Category mutations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #138 keeps the AccountsPage Categories tab to user-created
|
||||||
|
* `simple` kind only. The priced creation UI lands in #140 — until then,
|
||||||
|
* callers should pass kind = 'simple'.
|
||||||
|
*/
|
||||||
|
const addCategory = useCallback(
|
||||||
|
async (input: CreateBalanceCategoryInput) => {
|
||||||
|
const kind: BalanceCategoryKind = input.kind ?? "simple";
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
try {
|
||||||
|
await createBalanceCategory({ ...input, kind });
|
||||||
|
await refreshData(state.includeArchived);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.includeArchived, refreshData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editCategory = useCallback(
|
||||||
|
async (id: number, input: UpdateBalanceCategoryInput) => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
try {
|
||||||
|
await updateBalanceCategory(id, input);
|
||||||
|
await refreshData(state.includeArchived);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.includeArchived, refreshData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeCategory = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
try {
|
||||||
|
await deleteBalanceCategory(id);
|
||||||
|
await refreshData(state.includeArchived);
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.includeArchived, refreshData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
setIncludeArchived,
|
||||||
|
refresh: () => refreshData(state.includeArchived),
|
||||||
|
// Account ops
|
||||||
|
addAccount,
|
||||||
|
editAccount,
|
||||||
|
archiveAccount,
|
||||||
|
unarchiveAccount,
|
||||||
|
// Category ops
|
||||||
|
addCategory,
|
||||||
|
editCategory,
|
||||||
|
removeCategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
473
src/pages/AccountsPage.tsx
Normal file
473
src/pages/AccountsPage.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
// AccountsPage — CRUD UI for balance accounts and balance categories.
|
||||||
|
//
|
||||||
|
// Issue #138 (Bilan #1a) ships the route `/balance/accounts` with two tabs:
|
||||||
|
// - Comptes : full CRUD over balance_accounts (create/edit/archive)
|
||||||
|
// - Catégories : list of seeded + user-created categories. Users can add
|
||||||
|
// simple-kind categories (the priced toggle lands in #140),
|
||||||
|
// rename them, and delete the ones they created (the seeded
|
||||||
|
// ones are protected at the service layer).
|
||||||
|
//
|
||||||
|
// The sidebar entry "Bilan" is intentionally NOT added here — per spec-plan
|
||||||
|
// v2 it lands in Issue #141 (Bilan #3) when the `/balance` overview page
|
||||||
|
// becomes navigable. Until then the route is reachable directly via URL.
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArchiveRestore, Edit2, Plus, Trash2, Wallet } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
BalanceAccountWithCategory,
|
||||||
|
BalanceCategory,
|
||||||
|
} from "../shared/types";
|
||||||
|
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
|
||||||
|
import AccountForm from "../components/balance/AccountForm";
|
||||||
|
|
||||||
|
type Tab = "accounts" | "categories";
|
||||||
|
|
||||||
|
export default function AccountsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
setIncludeArchived,
|
||||||
|
addAccount,
|
||||||
|
editAccount,
|
||||||
|
archiveAccount,
|
||||||
|
unarchiveAccount,
|
||||||
|
addCategory,
|
||||||
|
editCategory,
|
||||||
|
removeCategory,
|
||||||
|
} = useBalanceAccounts();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("accounts");
|
||||||
|
const [showAccountForm, setShowAccountForm] = useState(false);
|
||||||
|
const [editingAccount, setEditingAccount] =
|
||||||
|
useState<BalanceAccountWithCategory | null>(null);
|
||||||
|
|
||||||
|
const [showCategoryForm, setShowCategoryForm] = useState(false);
|
||||||
|
const [newCategoryKey, setNewCategoryKey] = useState("");
|
||||||
|
const [newCategoryLabel, setNewCategoryLabel] = useState("");
|
||||||
|
|
||||||
|
const activeCategories = useMemo(
|
||||||
|
() => state.categories.filter((c) => c.is_active),
|
||||||
|
[state.categories]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCategoryLabel = (cat: BalanceCategory) =>
|
||||||
|
t(cat.i18n_key, { defaultValue: cat.key });
|
||||||
|
|
||||||
|
const closeAccountForm = () => {
|
||||||
|
setShowAccountForm(false);
|
||||||
|
setEditingAccount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountSubmit = async (
|
||||||
|
payload:
|
||||||
|
| Parameters<typeof addAccount>[0]
|
||||||
|
| Parameters<typeof editAccount>[1]
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (editingAccount) {
|
||||||
|
await editAccount(editingAccount.id, payload as Parameters<typeof editAccount>[1]);
|
||||||
|
} else {
|
||||||
|
await addAccount(payload as Parameters<typeof addAccount>[0]);
|
||||||
|
}
|
||||||
|
closeAccountForm();
|
||||||
|
} catch {
|
||||||
|
// Error already surfaced via state.error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
await addCategory({
|
||||||
|
key,
|
||||||
|
i18n_key: i18nKey,
|
||||||
|
kind: "simple",
|
||||||
|
sort_order: 100, // user-created categories sort after seeded ones
|
||||||
|
});
|
||||||
|
setNewCategoryKey("");
|
||||||
|
setNewCategoryLabel("");
|
||||||
|
setShowCategoryForm(false);
|
||||||
|
} catch {
|
||||||
|
// Error already surfaced via state.error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Wallet size={24} className="text-[var(--primary)]" />
|
||||||
|
<h1 className="text-2xl font-bold">{t("balance.accountsPage.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||||
|
{state.errorCode
|
||||||
|
? t(`balance.errors.${state.errorCode}`, {
|
||||||
|
defaultValue: state.error,
|
||||||
|
})
|
||||||
|
: state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex border-b border-[var(--border)] mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("accounts")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||||
|
activeTab === "accounts"
|
||||||
|
? "border-[var(--primary)] text-[var(--primary)]"
|
||||||
|
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("balance.accountsPage.tabs.accounts")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("categories")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
||||||
|
activeTab === "categories"
|
||||||
|
? "border-[var(--primary)] text-[var(--primary)]"
|
||||||
|
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("balance.accountsPage.tabs.categories")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "accounts" && (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={state.includeArchived}
|
||||||
|
onChange={(e) => setIncludeArchived(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("balance.accountsPage.includeArchived")}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingAccount(null);
|
||||||
|
setShowAccountForm(true);
|
||||||
|
}}
|
||||||
|
disabled={activeCategories.length === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("balance.accountsPage.newAccount")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAccountForm ? (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
{editingAccount
|
||||||
|
? t("balance.account.form.editTitle")
|
||||||
|
: t("balance.account.form.createTitle")}
|
||||||
|
</h2>
|
||||||
|
<AccountForm
|
||||||
|
initialAccount={editingAccount ?? null}
|
||||||
|
categories={activeCategories}
|
||||||
|
isSaving={state.isSaving}
|
||||||
|
onSubmit={handleAccountSubmit}
|
||||||
|
onCancel={closeAccountForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.accounts.length === 0 ? (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.accountsPage.empty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[var(--muted)]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.account.fields.name")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.account.fields.category")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.account.fields.symbol")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.account.fields.currency")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.account.fields.status")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium">
|
||||||
|
{t("balance.account.fields.actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{state.accounts.map((acc) => {
|
||||||
|
const isArchived = !!acc.archived_at;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={acc.id}
|
||||||
|
className="border-t border-[var(--border)]"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={isArchived ? "opacity-60" : ""}>
|
||||||
|
{acc.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{t(acc.category_i18n_key, {
|
||||||
|
defaultValue: acc.category_key,
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
||||||
|
{acc.symbol ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
||||||
|
{acc.currency}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{isArchived ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)] text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.account.status.archived")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--positive)]/10 text-[var(--positive)]">
|
||||||
|
{t("balance.account.status.active")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingAccount(acc);
|
||||||
|
setShowAccountForm(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
{isArchived ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => unarchiveAccount(acc.id)}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
title={t("balance.account.actions.unarchive")}
|
||||||
|
>
|
||||||
|
<ArchiveRestore size={14} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => archiveAccount(acc.id)}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)]"
|
||||||
|
title={t("balance.account.actions.archive")}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "categories" && (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.category.intro")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCategoryForm((prev) => !prev)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("balance.category.actions.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCategoryForm && (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[var(--muted)]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.category.fields.name")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.category.fields.key")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.category.fields.kind")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">
|
||||||
|
{t("balance.category.fields.origin")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium">
|
||||||
|
{t("balance.category.fields.actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{state.categories.map((cat) => (
|
||||||
|
<tr key={cat.id} className="border-t border-[var(--border)]">
|
||||||
|
<td className="px-4 py-2">{renderCategoryLabel(cat)}</td>
|
||||||
|
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
||||||
|
<code className="text-xs">{cat.key}</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)]">
|
||||||
|
{t(`balance.category.kind.${cat.kind}`)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{cat.is_seed ? (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.category.origin.seeded")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.category.origin.user")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = window.prompt(
|
||||||
|
t("balance.category.actions.renamePrompt"),
|
||||||
|
renderCategoryLabel(cat)
|
||||||
|
);
|
||||||
|
if (next && next.trim()) {
|
||||||
|
editCategory(cat.id, { i18n_key: next.trim() });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue