mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
Muluhabt ERP modules
This commit is contained in:
541
apps/finance-web/src/features/budgeting/BudgetsPage.tsx
Normal file
541
apps/finance-web/src/features/budgeting/BudgetsPage.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Progress,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPlus } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/AuthContext";
|
||||
import { FINANCE_PERMS } from "@/auth/permissions";
|
||||
import { PageHeader } from "@/shared/components/PageHeader";
|
||||
import { ApiErrorAlert } from "@/shared/components/ApiErrorAlert";
|
||||
import { formatMoney } from "@/shared/lib/formatMoney";
|
||||
import { fetchAccounts } from "../accounts/api";
|
||||
import { fetchFiscalPeriods, fetchFiscalYears } from "../periods/api";
|
||||
import {
|
||||
BUDGET_STATUS_COLOR,
|
||||
approveBudget,
|
||||
createBudget,
|
||||
fetchBudget,
|
||||
fetchBudgets,
|
||||
fetchCostCenters,
|
||||
fetchVariance,
|
||||
spreadBudget,
|
||||
} from "./api";
|
||||
|
||||
export function BudgetsPage() {
|
||||
const { can } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [periodId, setPeriodId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [spreadOpen, setSpreadOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
|
||||
const budgets = useQuery({ queryKey: ["budgeting", "budgets"], queryFn: fetchBudgets });
|
||||
|
||||
// Shares its cache key with the create modal's query, so the empty state can
|
||||
// tell "no fiscal year exists" apart from "a year exists but no budget uses
|
||||
// it yet" — two situations with different next steps.
|
||||
const fiscalYears = useQuery({ queryKey: ["fiscal", "years"], queryFn: fetchFiscalYears });
|
||||
|
||||
// Default to the most recent budget so the page is never an empty shell
|
||||
// when data exists.
|
||||
useEffect(() => {
|
||||
if (!selectedId && budgets.data?.length) setSelectedId(budgets.data[0].id);
|
||||
}, [budgets.data, selectedId]);
|
||||
|
||||
const budget = useQuery({
|
||||
queryKey: ["budgeting", "budget", selectedId],
|
||||
queryFn: () => fetchBudget(selectedId as string),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
|
||||
const periods = useQuery({
|
||||
queryKey: ["fiscal", "periods", budget.data?.fiscalYearId],
|
||||
queryFn: () => fetchFiscalPeriods(budget.data?.fiscalYearId),
|
||||
enabled: Boolean(budget.data?.fiscalYearId),
|
||||
});
|
||||
|
||||
const variance = useQuery({
|
||||
queryKey: ["budgeting", "variance", selectedId, periodId],
|
||||
queryFn: () => fetchVariance(selectedId as string, periodId ?? undefined),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
void queryClient.invalidateQueries({ queryKey: ["budgeting"] });
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => approveBudget(selectedId as string),
|
||||
onMutate: () => setActionError(null),
|
||||
onSuccess: invalidate,
|
||||
onError: setActionError,
|
||||
});
|
||||
|
||||
const isDraft = budget.data?.status === "DRAFT";
|
||||
const rows = variance.data ?? [];
|
||||
const overBudget = rows.filter((r) => r.isOverBudget);
|
||||
const unbudgeted = rows.filter((r) => r.isUnbudgeted);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Budgets"
|
||||
description="What was approved, what has been spent, and what is already promised."
|
||||
actions={
|
||||
<Group gap="sm">
|
||||
{isDraft && can(FINANCE_PERMS.budget.manage) && (
|
||||
<Button variant="default" onClick={() => setSpreadOpen(true)}>
|
||||
Set figures
|
||||
</Button>
|
||||
)}
|
||||
{isDraft &&
|
||||
(can(FINANCE_PERMS.budget.approve) ? (
|
||||
<Button loading={approve.isPending} onClick={() => approve.mutate()}>
|
||||
Approve
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip label="Approving a budget is a separate authorisation from preparing it.">
|
||||
<Button disabled>Approve</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
{can(FINANCE_PERMS.budget.manage) && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New budget
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<ApiErrorAlert error={actionError} title="That action was refused" />
|
||||
|
||||
{budgets.isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (budgets.data ?? []).length === 0 ? (
|
||||
<Text c="dimmed">
|
||||
{(fiscalYears.data ?? []).length === 0
|
||||
? "No budgets yet. A budget belongs to a fiscal year, and none exists — create one under Fiscal periods first."
|
||||
: "No budgets yet. Create one against a fiscal year to start planning."}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Group mb="md" gap="sm" align="flex-end">
|
||||
<Select
|
||||
label="Budget"
|
||||
data={(budgets.data ?? []).map((b) => ({
|
||||
value: b.id,
|
||||
label: `${b.name} (${b.status})`,
|
||||
}))}
|
||||
value={selectedId}
|
||||
onChange={(v) => { setSelectedId(v); setPeriodId(null); }}
|
||||
allowDeselect={false}
|
||||
w={340}
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
placeholder="Whole fiscal year"
|
||||
clearable
|
||||
data={(periods.data ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name.en,
|
||||
}))}
|
||||
value={periodId}
|
||||
onChange={setPeriodId}
|
||||
w={220}
|
||||
/>
|
||||
{budget.data && (
|
||||
<Badge size="lg" variant="light" color={BUDGET_STATUS_COLOR[budget.data.status]}>
|
||||
{budget.data.status}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isDraft && (
|
||||
<Alert color="gray" variant="light" mb="md">
|
||||
This budget is a draft — variance is shown against it, but it is
|
||||
not yet the approved plan for the year.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overBudget.length > 0 && (
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
mb="md"
|
||||
title={`${overBudget.length} line(s) over budget`}
|
||||
>
|
||||
<Text size="sm">
|
||||
Counting what is already committed, not only what has been
|
||||
spent — a line at 50% spent and 95% committed has no room left.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unbudgeted.length > 0 && (
|
||||
<Alert icon={<IconInfoCircle size={18} />} color="orange" variant="light" mb="md">
|
||||
<Text size="sm">
|
||||
{unbudgeted.length} line(s) carry spend with <b>nothing budgeted</b>.
|
||||
They appear here on purpose — a report that only listed budgeted
|
||||
lines would hide exactly the spend worth seeing.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="variance">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="variance">Budget vs actual</Tabs.Tab>
|
||||
<Tabs.Tab value="lines">
|
||||
Figures ({(budget.data?.lines ?? []).length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="variance">
|
||||
{variance.isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Text c="dimmed">Nothing budgeted or spent in this window.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1050}>
|
||||
<Table striped withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={90}>Account</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th w={160}>Cost center</Table.Th>
|
||||
<Table.Th w={130} ta="right">Budget</Table.Th>
|
||||
<Table.Th w={130} ta="right">Actual</Table.Th>
|
||||
<Table.Th w={130} ta="right">Committed</Table.Th>
|
||||
<Table.Th w={130} ta="right">Remaining</Table.Th>
|
||||
<Table.Th w={150}>Used</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={`${r.accountId}-${r.costCenterId ?? "none"}`}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm">{r.accountCode}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text size="sm">{r.accountName?.en}</Text>
|
||||
{r.isUnbudgeted && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
unbudgeted
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{r.costCenterCode
|
||||
? `${r.costCenterCode} ${r.costCenterName?.en ?? ""}`
|
||||
: "unallocated"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace">{formatMoney(r.budget)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace">{formatMoney(r.actual)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace" c={r.committed > 0 ? "orange" : "dimmed"}>
|
||||
{formatMoney(r.committed)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace" c={r.remaining < 0 ? "red" : undefined}>
|
||||
{formatMoney(r.remaining)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.percentUsed === null ? (
|
||||
<Text size="xs" c="dimmed">—</Text>
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
<Progress
|
||||
value={Math.min(r.percentUsed, 100)}
|
||||
color={r.isOverBudget ? "red" : r.percentUsed > 85 ? "orange" : "green"}
|
||||
size="sm"
|
||||
/>
|
||||
<Text size="xs" c={r.isOverBudget ? "red" : "dimmed"}>
|
||||
{r.percentUsed}%
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="lines">
|
||||
<Table.ScrollContainer minWidth={800}>
|
||||
<Table striped withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={90}>Account</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th w={160}>Cost center</Table.Th>
|
||||
<Table.Th w={170}>Period</Table.Th>
|
||||
<Table.Th w={140} ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(budget.data?.lines ?? []).map((l) => (
|
||||
<Table.Tr key={l.id}>
|
||||
<Table.Td><Text ff="monospace" size="sm">{l.accountCode}</Text></Table.Td>
|
||||
<Table.Td>{l.accountName?.en}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{l.costCenterCode ?? "unallocated"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{l.periodName?.en}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace">{formatMoney(l.amount)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{(budget.data?.lines ?? []).length === 0 && (
|
||||
<Text c="dimmed">
|
||||
No figures yet — use “Set figures” to enter annual amounts.
|
||||
</Text>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
<NewBudgetModal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={(id) => { setCreateOpen(false); setSelectedId(id); invalidate(); }}
|
||||
/>
|
||||
<SpreadModal
|
||||
budgetId={selectedId}
|
||||
opened={spreadOpen}
|
||||
onClose={() => setSpreadOpen(false)}
|
||||
onDone={() => { setSpreadOpen(false); invalidate(); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewBudgetModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
}) {
|
||||
const [fiscalYearId, setFiscalYearId] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const years = useQuery({ queryKey: ["fiscal", "years"], queryFn: fetchFiscalYears });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createBudget({ fiscalYearId: fiscalYearId as string, name: name.trim() }),
|
||||
onSuccess: (b) => { setName(""); onCreated(b.id); },
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="New budget">
|
||||
<Stack>
|
||||
<ApiErrorAlert error={create.error} title="Could not create the budget" />
|
||||
<Select
|
||||
label="Fiscal year"
|
||||
required
|
||||
placeholder={years.isLoading ? "Loading…" : "Select a fiscal year"}
|
||||
data={(years.data ?? []).map((y) => ({ value: y.id, label: y.code }))}
|
||||
value={fiscalYearId}
|
||||
onChange={setFiscalYearId}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="FY 2026/27 operating budget"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
loading={create.isPending}
|
||||
disabled={!fiscalYearId || !name.trim()}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SpreadModal({
|
||||
budgetId,
|
||||
opened,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
budgetId: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<
|
||||
{ key: string; accountId: string | null; costCenterId: string | null; annual: number | "" }[]
|
||||
>([{ key: "1", accountId: null, costCenterId: null, annual: "" }]);
|
||||
|
||||
const accounts = useQuery({
|
||||
queryKey: ["accounts", "budgetable"],
|
||||
queryFn: () => fetchAccounts({ isActive: true }),
|
||||
select: (all) =>
|
||||
all
|
||||
.filter((a) => !a.isGroup && a.isActive && ["EXPENSE", "REVENUE"].includes(a.accountType))
|
||||
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name.en}` })),
|
||||
});
|
||||
|
||||
const costCenters = useQuery({
|
||||
queryKey: ["budgeting", "cost-centers"],
|
||||
queryFn: fetchCostCenters,
|
||||
select: (all) =>
|
||||
all
|
||||
.filter((c) => !c.isGroup && c.isActive)
|
||||
.map((c) => ({ value: c.id, label: `${c.code} — ${c.name.en}` })),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
spreadBudget(
|
||||
budgetId as string,
|
||||
rows
|
||||
.filter((r) => r.accountId && Number(r.annual) > 0)
|
||||
.map((r) => ({
|
||||
accountId: r.accountId as string,
|
||||
costCenterId: r.costCenterId ?? undefined,
|
||||
annualAmount: Number(r.annual),
|
||||
})),
|
||||
),
|
||||
onSuccess: onDone,
|
||||
});
|
||||
|
||||
const usable = rows.filter((r) => r.accountId && Number(r.annual) > 0).length;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Set annual figures" size="xl">
|
||||
<Stack>
|
||||
<ApiErrorAlert error={save.error} title="Could not save the figures" />
|
||||
<Alert color="blue" variant="light" icon={<IconInfoCircle size={18} />}>
|
||||
<Text size="sm">
|
||||
Each annual amount is spread evenly across the year's periods, with
|
||||
the remainder on the last one so the periods add back to exactly
|
||||
what you entered. A figure already set for the same account, cost
|
||||
center and period is replaced, not added to.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Table withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Account</Table.Th>
|
||||
<Table.Th w={240}>Cost center</Table.Th>
|
||||
<Table.Th w={180}>Annual amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.key}>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Account"
|
||||
searchable
|
||||
data={accounts.data ?? []}
|
||||
value={r.accountId}
|
||||
onChange={(v) =>
|
||||
setRows((c) => c.map((x) => (x.key === r.key ? { ...x, accountId: v } : x)))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Unallocated"
|
||||
clearable
|
||||
searchable
|
||||
data={costCenters.data ?? []}
|
||||
value={r.costCenterId}
|
||||
onChange={(v) =>
|
||||
setRows((c) => c.map((x) => (x.key === r.key ? { ...x, costCenterId: v } : x)))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<NumberInput
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
value={r.annual}
|
||||
onChange={(v) =>
|
||||
setRows((c) =>
|
||||
c.map((x) => (x.key === r.key ? { ...x, annual: v === "" ? "" : Number(v) } : x)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
setRows((c) => [
|
||||
...c,
|
||||
{ key: Math.random().toString(36).slice(2), accountId: null, costCenterId: null, annual: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add row
|
||||
</Button>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button loading={save.isPending} disabled={usable === 0} onClick={() => save.mutate()}>
|
||||
Save figures
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
268
apps/finance-web/src/features/budgeting/CostCentersPage.tsx
Normal file
268
apps/finance-web/src/features/budgeting/CostCentersPage.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/AuthContext";
|
||||
import { FINANCE_PERMS } from "@/auth/permissions";
|
||||
import { PageHeader } from "@/shared/components/PageHeader";
|
||||
import { ApiErrorAlert } from "@/shared/components/ApiErrorAlert";
|
||||
import {
|
||||
createCostCenter,
|
||||
deleteCostCenter,
|
||||
fetchCostCenters,
|
||||
fetchUnlinkedUnits,
|
||||
} from "./api";
|
||||
|
||||
export function CostCentersPage() {
|
||||
const { can } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = can(FINANCE_PERMS.budget.manageCostCenter);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
|
||||
const centers = useQuery({
|
||||
queryKey: ["budgeting", "cost-centers"],
|
||||
queryFn: fetchCostCenters,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => deleteCostCenter(id),
|
||||
onMutate: () => setActionError(null),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["budgeting"] }),
|
||||
onError: setActionError,
|
||||
});
|
||||
|
||||
const rows = (centers.data ?? []).filter((c) =>
|
||||
search.trim()
|
||||
? `${c.code} ${c.name?.en ?? ""} ${c.name?.am ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
: true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cost centers"
|
||||
description="The dimension every cost can be attributed to. A center may stand for an IAM unit without Finance owning the org chart."
|
||||
actions={
|
||||
canManage && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New cost center
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ApiErrorAlert error={actionError} title="That change was refused" />
|
||||
|
||||
<Alert icon={<IconInfoCircle size={18} />} color="blue" variant="light" mb="md">
|
||||
<Text size="sm">
|
||||
Linking a center to a unit is optional and one-to-one — two centers on
|
||||
one unit would make “what did this department spend?” ambiguous. The
|
||||
unit's name is read from IAM at query time; Finance stores no copy.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search code or name"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={320}
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
{centers.isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Text c="dimmed">No cost centers yet.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={880}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={140}>Code</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th w={220}>Linked unit</Table.Th>
|
||||
<Table.Th w={180}>Manager</Table.Th>
|
||||
<Table.Th w={140}>Posting</Table.Th>
|
||||
<Table.Th w={60} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((c) => (
|
||||
<Table.Tr key={c.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" fw={c.isGroup ? 700 : 400}>{c.code}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text fw={c.isGroup ? 600 : 400}>{c.name?.en}</Text>
|
||||
{!c.isActive && (
|
||||
<Badge size="xs" color="red" variant="light">inactive</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{c.unitName?.en ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{c.managerName ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{c.isGroup ? (
|
||||
<Text size="sm" c="dimmed">Group — totals children</Text>
|
||||
) : (
|
||||
<Text size="sm">Postable</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{canManage && (
|
||||
<Tooltip label="Refused once anything is posted to it, or if it has children">
|
||||
{/* Wrapped so the tooltip still fires — a silently dead
|
||||
control is worse than a disabled one. */}
|
||||
<Box>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={remove.isPending && remove.variables === c.id}
|
||||
onClick={() => remove.mutate(c.id)}
|
||||
aria-label={`Delete ${c.code}`}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<NewCostCenterModal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["budgeting"] });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewCostCenterModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState("");
|
||||
const [en, setEn] = useState("");
|
||||
const [am, setAm] = useState("");
|
||||
const [isGroup, setIsGroup] = useState(false);
|
||||
const [parentId, setParentId] = useState<string | null>(null);
|
||||
const [unitId, setUnitId] = useState<string | null>(null);
|
||||
|
||||
// Only GROUP centers can take children — offering a leaf would be a
|
||||
// guaranteed rejection.
|
||||
const groups = useQuery({
|
||||
queryKey: ["budgeting", "cost-centers"],
|
||||
queryFn: fetchCostCenters,
|
||||
select: (all) =>
|
||||
all.filter((c) => c.isGroup).map((c) => ({ value: c.id, label: `${c.code} — ${c.name.en}` })),
|
||||
});
|
||||
|
||||
// Only units without a center yet — the server enforces one-to-one.
|
||||
const units = useQuery({
|
||||
queryKey: ["budgeting", "unlinked-units"],
|
||||
queryFn: fetchUnlinkedUnits,
|
||||
select: (all) => all.map((u) => ({ value: u.id, label: u.name?.en ?? u.id })),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createCostCenter({
|
||||
code: code.trim(),
|
||||
name: { en: en.trim(), am: am.trim() || en.trim() },
|
||||
parentId: parentId ?? undefined,
|
||||
unitId: unitId ?? undefined,
|
||||
isGroup,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCode(""); setEn(""); setAm(""); setParentId(null); setUnitId(null); setIsGroup(false);
|
||||
onCreated();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="New cost center" size="lg">
|
||||
<Stack>
|
||||
<ApiErrorAlert error={create.error} title="Could not create the cost center" />
|
||||
<TextInput label="Code" required value={code} onChange={(e) => setCode(e.currentTarget.value)} />
|
||||
<TextInput label="Name (English)" required value={en} onChange={(e) => setEn(e.currentTarget.value)} />
|
||||
<TextInput label="Name (Amharic)" value={am} onChange={(e) => setAm(e.currentTarget.value)} />
|
||||
<Select
|
||||
label="Parent (group centers only)"
|
||||
placeholder={groups.data?.length ? "None — a new root" : "No group centers exist yet"}
|
||||
clearable
|
||||
disabled={!groups.data?.length}
|
||||
data={groups.data ?? []}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
/>
|
||||
<Select
|
||||
label="Linked IAM unit"
|
||||
description="Only units without a cost center are listed"
|
||||
placeholder="None"
|
||||
clearable
|
||||
searchable
|
||||
data={units.data ?? []}
|
||||
value={unitId}
|
||||
onChange={setUnitId}
|
||||
/>
|
||||
<Switch
|
||||
label="Group cost center"
|
||||
description="Totals its children. Nothing can be posted or budgeted to it."
|
||||
checked={isGroup}
|
||||
onChange={(e) => setIsGroup(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
loading={create.isPending}
|
||||
disabled={!code.trim() || !en.trim()}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
161
apps/finance-web/src/features/budgeting/api.ts
Normal file
161
apps/finance-web/src/features/budgeting/api.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { financeApi } from "@/auth/http";
|
||||
import type { LocalizedName } from "@/shared/types";
|
||||
|
||||
export type CostCenter = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: LocalizedName;
|
||||
parentId: string | null;
|
||||
unitId: string | null;
|
||||
unitName: LocalizedName | null;
|
||||
managerEmployeeId: string | null;
|
||||
managerName: string | null;
|
||||
isGroup: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type CostCenterNode = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: LocalizedName;
|
||||
parentId: string | null;
|
||||
isGroup: boolean;
|
||||
isActive: boolean;
|
||||
children: CostCenterNode[];
|
||||
};
|
||||
|
||||
export type Budget = {
|
||||
id: string;
|
||||
fiscalYearId: string;
|
||||
name: string;
|
||||
status: "DRAFT" | "APPROVED" | "CLOSED";
|
||||
description: string | null;
|
||||
approvedBy: string | null;
|
||||
approvedAt: string | null;
|
||||
};
|
||||
|
||||
export type BudgetLine = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
accountCode: string;
|
||||
accountName: LocalizedName;
|
||||
costCenterId: string | null;
|
||||
costCenterCode: string | null;
|
||||
costCenterName: LocalizedName | null;
|
||||
fiscalPeriodId: string;
|
||||
periodNumber: number;
|
||||
periodName: LocalizedName;
|
||||
amount: number | string;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
export type BudgetDetail = Budget & { lines: BudgetLine[] };
|
||||
|
||||
export type VarianceRow = {
|
||||
accountId: string;
|
||||
accountCode: string;
|
||||
accountName: LocalizedName;
|
||||
accountType: string;
|
||||
costCenterId: string | null;
|
||||
costCenterCode: string | null;
|
||||
costCenterName: LocalizedName | null;
|
||||
budget: number;
|
||||
actual: number;
|
||||
committed: number;
|
||||
variance: number;
|
||||
remaining: number;
|
||||
percentUsed: number | null;
|
||||
isOverBudget: boolean;
|
||||
isUnbudgeted: boolean;
|
||||
};
|
||||
|
||||
export const BUDGET_STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: "gray",
|
||||
APPROVED: "green",
|
||||
CLOSED: "blue",
|
||||
};
|
||||
|
||||
export const fetchCostCenters = async (): Promise<CostCenter[]> => {
|
||||
const { data } = await financeApi.get("/budgeting/cost-centers");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchCostCenterTree = async (
|
||||
search?: string,
|
||||
): Promise<CostCenterNode[]> => {
|
||||
const { data } = await financeApi.get("/budgeting/cost-centers/tree", {
|
||||
params: search ? { search } : undefined,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchUnlinkedUnits = async (): Promise<
|
||||
{ id: string; name: LocalizedName }[]
|
||||
> => {
|
||||
const { data } = await financeApi.get("/budgeting/cost-centers/unlinked-units");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createCostCenter = async (payload: {
|
||||
code: string;
|
||||
name: { en: string; am: string };
|
||||
parentId?: string;
|
||||
unitId?: string;
|
||||
isGroup?: boolean;
|
||||
}): Promise<CostCenter> => {
|
||||
const { data } = await financeApi.post("/budgeting/cost-centers", payload);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteCostCenter = async (id: string): Promise<void> => {
|
||||
await financeApi.delete(`/budgeting/cost-centers/${id}`);
|
||||
};
|
||||
|
||||
export const fetchBudgets = async (): Promise<Budget[]> => {
|
||||
const { data } = await financeApi.get("/budgeting/budgets");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchBudget = async (id: string): Promise<BudgetDetail> => {
|
||||
const { data } = await financeApi.get(`/budgeting/budgets/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createBudget = async (payload: {
|
||||
fiscalYearId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<Budget> => {
|
||||
const { data } = await financeApi.post("/budgeting/budgets", payload);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const spreadBudget = async (
|
||||
id: string,
|
||||
entries: {
|
||||
accountId: string;
|
||||
costCenterId?: string;
|
||||
annualAmount: number;
|
||||
note?: string;
|
||||
}[],
|
||||
): Promise<BudgetDetail> => {
|
||||
const { data } = await financeApi.post(`/budgeting/budgets/${id}/spread`, {
|
||||
entries,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const approveBudget = async (id: string): Promise<BudgetDetail> => {
|
||||
const { data } = await financeApi.post(`/budgeting/budgets/${id}/approve`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchVariance = async (
|
||||
id: string,
|
||||
fiscalPeriodId?: string,
|
||||
): Promise<VarianceRow[]> => {
|
||||
const { data } = await financeApi.get(`/budgeting/budgets/${id}/variance`, {
|
||||
params: fiscalPeriodId ? { fiscalPeriodId } : undefined,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
Reference in New Issue
Block a user