Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

View 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>
);
}

View 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>
);
}

View 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;
};