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,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR — Finance</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,46 @@
{
"name": "@edr/finance-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --clearScreen false",
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
"build": "vite build",
"preview": "vite preview --port 5186",
"lint": "eslint src",
"type-check": "tsc -b"
},
"dependencies": {
"@edr/ui-common": "workspace:*",
"@hookform/resolvers": "^5.4.0",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@mantine/notifications": "^9.3.0",
"@mantine/spotlight": "^9.5.2",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.16.1",
"dayjs": "^1.11.13",
"i18next": "^26.3.5",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hook-form": "^7.77.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.1.1",
"zod": "^4.0.0"
},
"devDependencies": {
"@edr/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^20.14.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3",
"vite": "^6.0.7"
}
}

View File

@@ -0,0 +1,124 @@
import { Alert, Center, Stack, Title } from "@mantine/core";
import { Navigate, Route, Routes } from "react-router-dom";
import { AppShell } from "@/shared/components/AppShell";
import { RequireAuth } from "@/auth/RequireAuth";
import { FINANCE_PERMS } from "@/auth/permissions";
import { LoginPage } from "@/features/auth/LoginPage";
import { DashboardPage } from "@/features/dashboard/DashboardPage";
import { ChartOfAccountsPage } from "@/features/accounts/ChartOfAccountsPage";
import { JournalsPage } from "@/features/journals/JournalsPage";
import { JournalDetailPage } from "@/features/journals/JournalDetailPage";
import { NewJournalPage } from "@/features/journals/NewJournalPage";
import { FiscalPeriodsPage } from "@/features/periods/FiscalPeriodsPage";
import { ReceivablesPage } from "@/features/revenue/ReceivablesPage";
import { RevenueMappingsPage } from "@/features/revenue/RevenueMappingsPage";
import { PayablesPage } from "@/features/payables/PayablesPage";
import { PayrollPage } from "@/features/payables/PayrollPage";
import { BudgetsPage } from "@/features/budgeting/BudgetsPage";
import { CostCentersPage } from "@/features/budgeting/CostCentersPage";
import { AssetsPage } from "@/features/assets/AssetsPage";
import { ReportsPage } from "@/features/reports/ReportsPage";
import { CutoverPage } from "@/features/cutover/CutoverPage";
function Forbidden() {
return (
<Center h="60vh">
<Stack align="center">
<Title order={3}>Not permitted</Title>
<Alert color="yellow" maw={480}>
Your account does not hold the permission this page needs. Note that
permissions are snapshotted when you sign in if an administrator has
just granted you a role, sign out and back in.
</Alert>
</Stack>
</Center>
);
}
/** Every authenticated route renders inside the shell. */
const shell = (element: JSX.Element, permission?: string) => (
<RequireAuth permission={permission}>
<AppShell>{element}</AppShell>
</RequireAuth>
);
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={shell(<DashboardPage />)} />
<Route path="/forbidden" element={shell(<Forbidden />)} />
<Route
path="/accounts"
element={shell(<ChartOfAccountsPage />, FINANCE_PERMS.account.view)}
/>
{/* `new` before `:id`, or the router would read "new" as an entry id. */}
<Route
path="/journals/new"
element={shell(<NewJournalPage />, FINANCE_PERMS.journal.create)}
/>
<Route
path="/journals/:id"
element={shell(<JournalDetailPage />, FINANCE_PERMS.journal.view)}
/>
<Route
path="/journals"
element={shell(<JournalsPage />, FINANCE_PERMS.journal.view)}
/>
<Route
path="/periods"
element={shell(<FiscalPeriodsPage />, FINANCE_PERMS.period.view)}
/>
<Route
path="/receivables"
element={shell(<ReceivablesPage />, FINANCE_PERMS.receivable.view)}
/>
<Route
path="/revenue-mappings"
element={shell(<RevenueMappingsPage />, FINANCE_PERMS.receivable.view)}
/>
<Route
path="/payables"
element={shell(<PayablesPage />, FINANCE_PERMS.payable.view)}
/>
<Route
path="/payroll"
element={shell(<PayrollPage />, FINANCE_PERMS.payable.view)}
/>
<Route
path="/budgets"
element={shell(<BudgetsPage />, FINANCE_PERMS.budget.view)}
/>
<Route
path="/cost-centers"
element={shell(<CostCentersPage />, FINANCE_PERMS.budget.view)}
/>
<Route
path="/assets"
element={shell(<AssetsPage />, FINANCE_PERMS.asset.view)}
/>
<Route
path="/reports"
element={shell(<ReportsPage />, FINANCE_PERMS.report.view)}
/>
{/* Gated on period.view, matching the API: whoever may see the fiscal
calendar may see the cutover that sits on it. */}
<Route
path="/cutover"
element={shell(<CutoverPage />, FINANCE_PERMS.period.view)}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -0,0 +1,103 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { getMeRequest, loginRequest } from "./api";
import { applyTokens } from "./http";
import {
AUTH_USER_COOKIE,
clearSessionCookies,
getCookie,
setCookie,
} from "./cookies";
import { hasPermission } from "./permissions";
import type { AuthUser } from "./types";
type AuthContextValue = {
user: AuthUser | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
can: (permission: string | string[]) => boolean;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const readCachedUser = (): AuthUser | null => {
const raw = getCookie(AUTH_USER_COOKIE);
if (!raw) return null;
try {
return JSON.parse(raw) as AuthUser;
} catch {
return null;
}
};
export function AuthProvider({ children }: { children: ReactNode }) {
// Seeded from the cookie so a refresh does not flash the login screen while
// /me is in flight; the server response replaces it as soon as it lands.
const [user, setUser] = useState<AuthUser | null>(readCachedUser);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
getMeRequest()
.then((me) => {
if (cancelled) return;
setUser(me);
setCookie(AUTH_USER_COOKIE, JSON.stringify(me));
})
.catch(() => {
// A failed /me means no usable session. The http interceptor has
// already tried a refresh by this point.
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (email: string, password: string) => {
const { token, refreshToken } = await loginRequest({ email, password });
applyTokens({ token, refreshToken });
const me = await getMeRequest();
setUser(me);
setCookie(AUTH_USER_COOKIE, JSON.stringify(me));
}, []);
const logout = useCallback(() => {
clearSessionCookies();
setUser(null);
window.location.replace("/login");
}, []);
const value = useMemo<AuthContextValue>(
() => ({
user,
isLoading,
login,
logout,
can: (permission: string | string[]) => hasPermission(user, permission),
}),
[user, isLoading, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used inside <AuthProvider>");
return context;
}

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { useAuth } from "./AuthContext";
/**
* Hides a control the caller cannot use.
*
* House rule (freight CLAUDE.md): a server-side guard must be reflected in the
* UI — prefer disabling with a visible reason over silently hiding, so pass
* `fallback` when the absence would be confusing. That matters more here than
* anywhere else in the platform: Finance separates preparing from posting, so a
* missing "Post" button is a segregation-of-duty rule the user should be told
* about, not a bug they should have to guess at.
*/
export function Can({
permission,
children,
fallback = null,
}: {
permission: string;
children: ReactNode;
fallback?: ReactNode;
}) {
const { can } = useAuth();
return <>{can(permission) ? children : fallback}</>;
}

View File

@@ -0,0 +1,39 @@
import { Center, Loader } from "@mantine/core";
import { Navigate, useLocation } from "react-router-dom";
import type { ReactNode } from "react";
import { useAuth } from "./AuthContext";
/**
* Route gate. `permission` mirrors the server's guard so a user without the key
* never lands on a page whose every request would 403 — the API remains the
* real gate.
*/
export function RequireAuth({
children,
permission,
}: {
children: ReactNode;
permission?: string;
}) {
const { user, isLoading, can } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<Center h="100vh">
<Loader />
</Center>
);
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (permission && !can(permission)) {
return <Navigate to="/forbidden" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,26 @@
import { authApi, financeApi } from "./http";
import type { AuthUser, LoginResponse } from "./types";
export const loginRequest = async (payload: {
email: string;
password: string;
}): Promise<LoginResponse> => {
const response = await authApi.post<LoginResponse>("/auth/login", payload);
return response.data;
};
/**
* The signed-in user, asked of finance-api rather than of whoever issued the
* token.
*
* finance-api resolves the session on every request anyway, so this returns
* exactly the identity it will enforce with — and it keeps this app correct even
* if login is later repointed at a different host.
*
* Note the permission set is snapshotted at LOGIN onto the session, so a role
* granted while the user is signed in does not appear until they log in again.
*/
export const getMeRequest = async (): Promise<AuthUser> => {
const response = await financeApi.get<AuthUser>("/me");
return response.data;
};

View File

@@ -0,0 +1,37 @@
/**
* Session cookies. Deliberately the SAME names freight-backoffice and hr-web use
* (`auth-token`, `refresh-token`, `auth-user`): the apps share one IAM session,
* so a user already signed in on the same host does not have to log in again,
* and signing out of one ends the session for all of them.
*/
const DEFAULT_PATH = "/";
const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7;
export const AUTH_TOKEN_COOKIE = "auth-token";
export const REFRESH_TOKEN_COOKIE = "refresh-token";
export const AUTH_USER_COOKIE = "auth-user";
export const getCookie = (name: string): string | null => {
const match = document.cookie
.split("; ")
.find((entry) => entry.startsWith(`${name}=`));
return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null;
};
export const setCookie = (
name: string,
value: string,
maxAge = SEVEN_DAYS_IN_SECONDS,
): void => {
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; path=${DEFAULT_PATH}; SameSite=Lax`;
};
export const clearCookie = (name: string): void => {
document.cookie = `${name}=; Max-Age=0; path=${DEFAULT_PATH}`;
};
export const clearSessionCookies = (): void => {
[AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, AUTH_USER_COOKIE].forEach(
clearCookie,
);
};

View File

@@ -0,0 +1,148 @@
import axios, {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from "axios";
import {
AUTH_API_URL,
AUTH_BASE_PATH,
CLIENT_APP,
FINANCE_API_URL,
} from "@/config/env";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
clearSessionCookies,
getCookie,
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean };
/**
* TWO clients, even though both point at finance-api by default:
*
* authApi → the IAM auth controller AUTH_API_URL + AUTH_BASE_PATH
* financeApi → finance-api /api/v1/*
*
* They are kept separate so login can be repointed at a central IAM service
* (two env vars, no code change) without moving the data client with it.
*
* They share the cookie store and the single-flight refresh below, so a token
* rotated by either is immediately used by both. That holds even when they
* address different services, because JwtGuard validates the session against
* `iam.sessions` in the shared database rather than against whichever app
* issued it — verified: a token minted by hr-api is accepted by finance-api.
*/
export const authApi: AxiosInstance = axios.create({
baseURL: `${AUTH_API_URL}${AUTH_BASE_PATH}`,
withCredentials: true,
});
export const financeApi: AxiosInstance = axios.create({
baseURL: `${FINANCE_API_URL}/api/v1`,
withCredentials: true,
});
let refreshPromise: Promise<AuthTokens> | null = null;
export const applyTokens = ({ token, refreshToken }: AuthTokens): void => {
setCookie(AUTH_TOKEN_COOKIE, token);
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
};
/**
* Single-flight refresh: several requests can 401 at once (a dashboard fires
* three queries in parallel), and they must not each rotate the refresh token —
* the second rotation would invalidate the first's result. They share one
* in-flight promise instead.
*/
const refreshSessionTokens = async (): Promise<AuthTokens> => {
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) throw new Error("missing refresh token");
refreshPromise ??= authApi
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
return tokens;
};
const attachAuthHeaders = (config: InternalAxiosRequestConfig) => {
const token = getCookie(AUTH_TOKEN_COOKIE);
if (token) config.headers.Authorization = `Bearer ${token}`;
// Tells the API which audience is asking. Sent ONLY when configured, because
// it is a freight-api requirement, not a platform-wide one: edr-passenger-api
// does not list it in its CORS `allowedHeaders`, so sending it unconditionally
// makes the browser fail the preflight and the login never leaves the page.
if (CLIENT_APP) config.headers["X-Client-App"] = CLIENT_APP;
return config;
};
/** Unwraps the `{ success, data }` envelope some IAM routes return. */
const unwrapEnvelope = (data: unknown): unknown =>
data && typeof data === "object" && "success" in data && "data" in data
? (data as { data: unknown }).data
: data;
const NON_REFRESHABLE = ["/auth/login", "/auth/refresh-token"];
const installInterceptors = (client: AxiosInstance) => {
client.interceptors.request.use(attachAuthHeaders);
client.interceptors.response.use(
(response) => {
response.data = unwrapEnvelope(response.data);
return response;
},
async (error: AxiosError) => {
const original = error.config as RetriableRequest | undefined;
const isRefreshable =
error.response?.status === 401 &&
original &&
!original._retry &&
!NON_REFRESHABLE.some((path) => original.url?.includes(path));
if (!isRefreshable) return Promise.reject(error);
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
clearSessionCookies();
return Promise.reject(error);
}
original._retry = true;
try {
const tokens = await refreshSessionTokens();
original.headers.set?.("Authorization", `Bearer ${tokens.token}`);
return client(original);
} catch (refreshError) {
clearSessionCookies();
window.location.replace("/login");
return Promise.reject(refreshError);
}
},
);
};
installInterceptors(authApi);
installInterceptors(financeApi);
/**
* The server's actual message, not "Request failed with status code 400".
* NestJS returns `message` as a string or an array of validation failures.
*/
export const apiErrorMessage = (error: unknown): string => {
const payload = (error as AxiosError<{ message?: string | string[] }>)
?.response?.data;
const message = payload?.message;
if (Array.isArray(message)) return message.join("\n");
if (typeof message === "string" && message) return message;
return (error as Error)?.message ?? "Something went wrong";
};

View File

@@ -0,0 +1,116 @@
import type { AuthPermission, AuthUser } from "./types";
/**
* Mirror of the API's FINANCE_PERMS registry
* (apps/finance-api/src/seed/finance-permissions.registry.ts).
*
* Kept as a hand-written mirror rather than an import: the API is a separate
* deployable and this app must not take a build dependency on it. The keys are a
* wire contract, so drift here surfaces as a UI control that is visible but
* 403s — which is why the server check is the real gate and this is only used to
* hide or disable controls.
*/
export const FINANCE_PERMS = {
account: {
view: "can:view:gl_account",
manage: "can:manage:gl_account",
},
journal: {
create: "can:create:journal_entry",
view: "can:view:journal_entry",
post: "can:post:journal_entry",
reverse: "can:reverse:journal_entry",
},
period: {
view: "can:view:fiscal_period",
manage: "can:manage:fiscal_period",
close: "can:close:fiscal_period",
},
receivable: {
view: "can:view:receivable",
manageCustomer: "can:manage:finance_customer",
recordReceipt: "can:record:receipt",
manageRevenueMapping: "can:manage:revenue_mapping",
},
payable: {
view: "can:view:payable",
manageSupplier: "can:manage:supplier",
manageBill: "can:manage:supplier_bill",
approveBill: "can:approve:supplier_bill",
recordPayment: "can:record:supplier_payment",
postPayroll: "can:post:payroll_to_gl",
manageStatutory: "can:manage:statutory_payable",
},
budget: {
view: "can:view:budget",
manage: "can:manage:budget",
approve: "can:approve:budget",
manageCostCenter: "can:manage:cost_center",
},
asset: {
view: "can:view:fixed_asset",
manage: "can:manage:fixed_asset",
depreciate: "can:run:depreciation",
dispose: "can:dispose:fixed_asset",
},
report: {
view: "can:view:finance_report",
export: "can:export:finance_report",
},
} as const;
const SUPER_ADMIN_ROLE = "super_admin";
const positionsOf = (user: AuthUser | null | undefined) => {
const employee = user?.employee;
if (!employee) return [];
if (Array.isArray(employee)) return employee.flatMap((e) => e.positions ?? []);
return [
...(employee.position ? [employee.position] : []),
...(employee.positions ?? []),
...(employee.delegatedPositions ?? []),
];
};
const keysOf = (permissions: AuthPermission[] | undefined) =>
(permissions ?? []).map((p) => p.key).filter((k): k is string => Boolean(k));
/** Every permission key the token carries — roles, positions, position types. */
export const collectPermissionKeys = (
user: AuthUser | null | undefined,
): string[] => {
if (!user) return [];
const keys = new Set<string>(keysOf(user.permissions));
for (const position of positionsOf(user)) {
keysOf(position.permissions).forEach((key) => keys.add(key));
keysOf(position.positionType?.permissions).forEach((key) => keys.add(key));
}
return [...keys];
};
export const isSuperAdmin = (user: AuthUser | null | undefined): boolean =>
Boolean(user?.roles?.some((role) => role.key === SUPER_ADMIN_ROLE));
/** An array is "any of" — kept in step with edr-hr-web, where a screen
* reachable by either of two permissions made the widening necessary. */
export const hasPermission = (
user: AuthUser | null | undefined,
permission: string | string[],
): boolean => {
if (!user) return false;
if (isSuperAdmin(user)) return true;
const keys = collectPermissionKeys(user);
return Array.isArray(permission)
? permission.some((p) => keys.includes(p))
: keys.includes(permission);
};
/** The caller's `iam.employees.id` — who a posting is recorded against. */
export const currentEmployeeId = (
user: AuthUser | null | undefined,
): string | null => {
const employee = user?.employee;
if (!employee) return null;
if (Array.isArray(employee)) return employee[0]?.id ?? null;
return employee.id ?? null;
};

View File

@@ -0,0 +1,42 @@
export type LocaleText = { am?: string; en?: string };
export type AuthTokens = { token: string; refreshToken: string };
export type AuthPermission = { id?: string; key?: string };
export type AuthPosition = {
id?: string;
key?: string;
name?: LocaleText;
permissions?: AuthPermission[];
positionType?: { key?: string; permissions?: AuthPermission[] } | null;
};
/**
* IAM issues the employee block in two shapes — an object on a session token and
* an array on the raw payload. Both reach this app depending on how the session
* was minted, so every reader handles both (the same reason the API's
* finance-permission.util does).
*/
export type AuthEmployee = {
id?: string;
organizationId?: string;
unitId?: string;
name?: LocaleText;
position?: AuthPosition;
positions?: AuthPosition[];
delegatedPositions?: AuthPosition[];
};
export type AuthUser = {
id?: string;
username?: string;
email?: string;
name?: LocaleText;
userType?: string;
roles?: { key?: string }[];
permissions?: AuthPermission[];
employee?: AuthEmployee | AuthEmployee[] | null;
};
export type LoginResponse = AuthTokens & { user?: AuthUser };

View File

@@ -0,0 +1,41 @@
/** Runtime configuration, read once so every consumer sees the same values. */
const trim = (value: string | undefined, fallback: string) =>
(value ?? "").trim() || fallback;
export const FINANCE_API_URL = trim(
import.meta.env.VITE_FINANCE_API_URL,
"http://localhost:3004",
);
/**
* Where credentials are POSTed — finance-api itself by default.
*
* finance-api embeds `IamModule.forRoot(...)`, which brings IAM's own auth
* controller, so it serves a working `POST /api/v1/auth/login`. Verified against
* the running service rather than assumed.
*
* It stays a separate variable so login can be repointed at a central IAM
* service without touching code.
*/
export const AUTH_API_URL = trim(
import.meta.env.VITE_AUTH_API_URL,
FINANCE_API_URL,
);
/**
* Audience header for the auth host. finance-api does NOT require it (verified);
* edr-freight-api's login 403s without one, and edr-passenger-api rejects it at
* the CORS preflight. Left EMPTY by default so the header is simply not sent —
* set it only when pointing at an auth host that wants it.
*/
export const CLIENT_APP = (import.meta.env.VITE_CLIENT_APP ?? "").trim();
/**
* Base path under AUTH_API_URL where the auth controller lives. It is not the
* same on every host: finance-api and hr-api mount it at `/api/v1/auth/*`,
* freight-api at `/api/auth/*`, and passenger-api sets no global prefix.
*/
export const AUTH_BASE_PATH = (
import.meta.env.VITE_AUTH_BASE_PATH ?? "/api/v1"
).trim();

View File

@@ -0,0 +1,494 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Select,
Stack,
Switch,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconLock,
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 { apiErrorMessage } from "@/auth/http";
import {
createAccount,
deleteAccount,
fetchAccountTree,
fetchAccounts,
type AccountPayload,
} from "./api";
import {
ACCOUNT_TYPES,
ACCOUNT_TYPE_COLOR,
type AccountNode,
type AccountType,
} from "./types";
/**
* One row of the tree, indented by depth.
*
* Group accounts are shown in bold with an expander; postable accounts are the
* leaves. Both the `is_group` and `is_system` facts are surfaced, because both
* change what the user is allowed to do and a disabled button with no
* explanation is the thing the house rules specifically warn against.
*/
function AccountRow({
node,
depth,
expanded,
onToggle,
canManage,
onDelete,
deletingId,
}: {
node: AccountNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
canManage: boolean;
onDelete: (node: AccountNode) => void;
deletingId: string | null;
}) {
const hasChildren = node.children.length > 0;
const isOpen = expanded.has(node.id);
return (
<>
<Table.Tr>
<Table.Td>
<Group gap={4} wrap="nowrap" style={{ paddingLeft: depth * 20 }}>
{hasChildren ? (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => onToggle(node.id)}
aria-label={isOpen ? "Collapse" : "Expand"}
>
{isOpen ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
</ActionIcon>
) : (
<Box w={26} />
)}
<Text ff="monospace" fw={node.isGroup ? 700 : 400}>
{node.code}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Text fw={node.isGroup ? 600 : 400}>{node.name.en}</Text>
{node.isContra && (
<Tooltip label="Subtracted from its type's total, not added">
<Badge size="xs" variant="light" color="gray">
contra
</Badge>
</Tooltip>
)}
{node.isSystem && (
<Tooltip label="Automated postings resolve this account by code — it cannot be deleted or deactivated">
<Badge
size="xs"
variant="light"
color="gray"
leftSection={<IconLock size={10} />}
>
system
</Badge>
</Tooltip>
)}
{!node.isActive && (
<Badge size="xs" color="red" variant="light">
inactive
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={ACCOUNT_TYPE_COLOR[node.accountType]}
>
{node.accountType}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{node.normalBalance}
</Text>
</Table.Td>
<Table.Td>
{node.isGroup ? (
<Text size="sm" c="dimmed">
Group totals its children
</Text>
) : (
<Text size="sm">Postable</Text>
)}
</Table.Td>
<Table.Td>
{canManage && (
<Tooltip
label={
node.isSystem
? "System accounts cannot be deleted"
: hasChildren
? "Move or remove its children first"
: "Delete (refused if anything has been posted to it)"
}
>
{/* Wrapped so the tooltip still fires while the button is disabled —
a silently dead control is worse than a disabled one. */}
<Box>
<ActionIcon
variant="subtle"
color="red"
disabled={node.isSystem || hasChildren}
loading={deletingId === node.id}
onClick={() => onDelete(node)}
aria-label={`Delete ${node.code}`}
>
<IconTrash size={16} />
</ActionIcon>
</Box>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
{isOpen &&
node.children.map((child) => (
<AccountRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
canManage={canManage}
onDelete={onDelete}
deletingId={deletingId}
/>
))}
</>
);
}
export function ChartOfAccountsPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const canManage = can(FINANCE_PERMS.account.manage);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<AccountType | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [createOpen, setCreateOpen] = useState(false);
const [actionError, setActionError] = useState<unknown>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const filters = useMemo(
() => ({
search: search.trim() || undefined,
accountType: typeFilter ?? undefined,
}),
[search, typeFilter],
);
const tree = useQuery({
queryKey: ["accounts", "tree", filters],
queryFn: () => fetchAccountTree(filters),
});
// Group accounts, for the parent picker. Only groups can take children, so
// offering anything else would produce a guaranteed 400.
const groups = useQuery({
queryKey: ["accounts", "groups"],
queryFn: () => fetchAccounts({}),
select: (accounts) => accounts.filter((account) => account.isGroup),
});
const toggle = (id: string) =>
setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const expandAll = () => {
const ids = new Set<string>();
const walk = (nodes: AccountNode[]) =>
nodes.forEach((node) => {
if (node.children.length) {
ids.add(node.id);
walk(node.children);
}
});
walk(tree.data ?? []);
setExpanded(ids);
};
const remove = useMutation({
mutationFn: (id: string) => deleteAccount(id),
onMutate: (id) => {
setDeletingId(id);
setActionError(null);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["accounts"] });
},
onError: (error) => setActionError(error),
onSettled: () => setDeletingId(null),
});
return (
<>
<PageHeader
title="Chart of accounts"
description="The account tree every posting resolves against. Group accounts total their children and can never be posted to."
actions={
<Group gap="sm">
<Button variant="default" onClick={expandAll}>
Expand all
</Button>
<Button variant="default" onClick={() => setExpanded(new Set())}>
Collapse all
</Button>
{canManage && (
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateOpen(true)}
>
New account
</Button>
)}
</Group>
}
/>
<ApiErrorAlert error={actionError} title="Could not delete the account" />
<ApiErrorAlert error={tree.error} title="Could not load the chart" />
<Group mb="md" gap="sm">
<TextInput
placeholder="Search code or name"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
w={280}
/>
<Select
placeholder="All types"
clearable
data={ACCOUNT_TYPES.map((type) => ({ value: type, label: type }))}
value={typeFilter}
onChange={(value) => setTypeFilter((value as AccountType) ?? null)}
w={180}
/>
</Group>
{tree.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (tree.data ?? []).length === 0 ? (
<Text c="dimmed">
No accounts match. The chart is seeded per organization if this is a
new organization, seed it before posting.
</Text>
) : (
<Table.ScrollContainer minWidth={860}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={220}>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th w={130}>Type</Table.Th>
<Table.Th w={110}>Normal</Table.Th>
<Table.Th w={200}>Posting</Table.Th>
<Table.Th w={70} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(tree.data ?? []).map((node) => (
<AccountRow
key={node.id}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
canManage={canManage}
onDelete={(account) => remove.mutate(account.id)}
deletingId={deletingId}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<CreateAccountModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
groups={(groups.data ?? []).map((account) => ({
value: account.id,
label: `${account.code}${account.name.en}`,
accountType: account.accountType,
}))}
onCreated={() => {
setCreateOpen(false);
void queryClient.invalidateQueries({ queryKey: ["accounts"] });
}}
/>
</>
);
}
function CreateAccountModal({
opened,
onClose,
groups,
onCreated,
}: {
opened: boolean;
onClose: () => void;
groups: { value: string; label: string; accountType: AccountType }[];
onCreated: () => void;
}) {
const [code, setCode] = useState("");
const [en, setEn] = useState("");
const [am, setAm] = useState("");
const [accountType, setAccountType] = useState<AccountType>("ASSET");
const [parentAccountId, setParentAccountId] = useState<string | null>(null);
const [isGroup, setIsGroup] = useState(false);
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: (payload: AccountPayload) => createAccount(payload),
onSuccess: () => {
setCode("");
setEn("");
setAm("");
setParentAccountId(null);
setIsGroup(false);
setError(null);
onCreated();
},
onError: (err) => setError(apiErrorMessage(err)),
});
// A child must share its parent's type, so only same-type groups are offered
// rather than letting the server reject the combination afterwards.
const eligibleParents = groups.filter(
(group) => group.accountType === accountType,
);
return (
<Modal opened={opened} onClose={onClose} title="New account" size="lg">
<Stack>
{error && (
<Text size="sm" c="red" style={{ whiteSpace: "pre-line" }}>
{error}
</Text>
)}
<TextInput
label="Code"
description="Digits only. The numbering implies the tree: 1111 sits under 1110."
required
value={code}
onChange={(event) => setCode(event.currentTarget.value)}
/>
<TextInput
label="Name (English)"
required
value={en}
onChange={(event) => setEn(event.currentTarget.value)}
/>
<TextInput
label="Name (Amharic)"
required
value={am}
onChange={(event) => setAm(event.currentTarget.value)}
/>
<Select
label="Type"
description="Decides the normal balance, and can never be changed afterwards."
data={ACCOUNT_TYPES.map((type) => ({ value: type, label: type }))}
value={accountType}
onChange={(value) => {
setAccountType((value as AccountType) ?? "ASSET");
setParentAccountId(null);
}}
allowDeselect={false}
/>
<Select
label="Parent (group accounts of the same type only)"
placeholder={
eligibleParents.length
? "None — a new root"
: `No ${accountType} group accounts exist yet`
}
clearable
disabled={eligibleParents.length === 0}
data={eligibleParents}
value={parentAccountId}
onChange={setParentAccountId}
/>
<Switch
label="Group account"
description="Totals its children. Nothing can be posted to it."
checked={isGroup}
onChange={(event) => setIsGroup(event.currentTarget.checked)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={create.isPending}
disabled={!code || !en || !am}
onClick={() =>
create.mutate({
code,
name: { en, am },
accountType,
parentAccountId: parentAccountId ?? undefined,
isGroup,
})
}
>
Create
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,56 @@
import { financeApi } from "@/auth/http";
import type { Account, AccountNode, AccountType } from "./types";
export type AccountFilters = {
search?: string;
accountType?: AccountType;
isActive?: boolean;
};
export const fetchAccountTree = async (
filters: AccountFilters = {},
): Promise<AccountNode[]> => {
const { data } = await financeApi.get<AccountNode[]>("/accounts/tree", {
params: filters,
});
return data;
};
export const fetchAccounts = async (
filters: AccountFilters = {},
): Promise<Account[]> => {
const { data } = await financeApi.get<Account[]>("/accounts", {
params: filters,
});
return data;
};
export type AccountPayload = {
code: string;
name: { en: string; am: string };
accountType: AccountType;
parentAccountId?: string;
isGroup?: boolean;
isContra?: boolean;
};
export const createAccount = async (
payload: AccountPayload,
): Promise<Account> => {
const { data } = await financeApi.post<Account>("/accounts", payload);
return data;
};
export const updateAccount = async (
id: string,
payload: Partial<Omit<AccountPayload, "code" | "accountType">> & {
isActive?: boolean;
},
): Promise<Account> => {
const { data } = await financeApi.patch<Account>(`/accounts/${id}`, payload);
return data;
};
export const deleteAccount = async (id: string): Promise<void> => {
await financeApi.delete(`/accounts/${id}`);
};

View File

@@ -0,0 +1,38 @@
import type { LocalizedName } from "@/shared/types";
export const ACCOUNT_TYPES = [
"ASSET",
"LIABILITY",
"EQUITY",
"REVENUE",
"EXPENSE",
] as const;
export type AccountType = (typeof ACCOUNT_TYPES)[number];
export type NormalBalance = "DEBIT" | "CREDIT";
export type Account = {
id: string;
organizationId: string;
code: string;
name: LocalizedName;
accountType: AccountType;
normalBalance: NormalBalance;
parentAccountId: string | null;
isGroup: boolean;
isContra: boolean;
isActive: boolean;
isSystem: boolean;
description?: LocalizedName | null;
};
export type AccountNode = Account & { children: AccountNode[] };
/** Colour per type, so the five blocks are distinguishable at a glance. */
export const ACCOUNT_TYPE_COLOR: Record<AccountType, string> = {
ASSET: "blue",
LIABILITY: "orange",
EQUITY: "grape",
REVENUE: "teal",
EXPENSE: "red",
};

View File

@@ -0,0 +1,651 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import { 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 {
ASSET_STATUS_COLOR,
createAsset,
createAssetCategory,
disposeAsset,
fetchAssetCategories,
fetchAssets,
fetchDepreciationRuns,
fetchDisposals,
fetchSchedule,
runDepreciation,
} from "./api";
const today = () => new Date().toISOString().slice(0, 10);
export function AssetsPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const [assetOpen, setAssetOpen] = useState(false);
const [catOpen, setCatOpen] = useState(false);
const [runOpen, setRunOpen] = useState(false);
const [scheduleFor, setScheduleFor] = useState<string | null>(null);
const [disposeFor, setDisposeFor] = useState<{ id: string; code: string; nbv: number } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const assets = useQuery({ queryKey: ["assets", "register"], queryFn: () => fetchAssets() });
const cats = useQuery({ queryKey: ["assets", "categories"], queryFn: fetchAssetCategories });
const runs = useQuery({ queryKey: ["assets", "runs"], queryFn: fetchDepreciationRuns });
const disposals = useQuery({ queryKey: ["assets", "disposals"], queryFn: fetchDisposals });
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["assets"] });
void queryClient.invalidateQueries({ queryKey: ["journals"] });
};
const totalCost = (assets.data ?? [])
.filter((a) => a.status !== "DISPOSED" && a.status !== "WRITTEN_OFF")
.reduce((s, a) => s + Number(a.acquisitionCost), 0);
const totalNbv = (assets.data ?? [])
.filter((a) => a.status !== "DISPOSED" && a.status !== "WRITTEN_OFF")
.reduce((s, a) => s + Number(a.netBookValue), 0);
return (
<>
<PageHeader
title="Fixed assets"
description="The register, straight-line depreciation, and disposals."
actions={
<Group gap="sm">
{can(FINANCE_PERMS.asset.manage) && (
<>
<Button variant="default" onClick={() => setCatOpen(true)}>
New category
</Button>
<Button leftSection={<IconPlus size={16} />} onClick={() => setAssetOpen(true)}>
New asset
</Button>
</>
)}
{can(FINANCE_PERMS.asset.depreciate) && (
<Button variant="light" onClick={() => setRunOpen(true)}>
Run depreciation
</Button>
)}
</Group>
}
/>
<ApiErrorAlert error={actionError} title="That action was refused" />
<Group mb="md" gap="lg">
<Text size="sm" c="dimmed">
Cost on the books: <b>{formatMoney(totalCost)}</b>
</Text>
<Text size="sm" c="dimmed">
Net book value: <b>{formatMoney(totalNbv)}</b>
</Text>
</Group>
<Tabs defaultValue="register">
<Tabs.List mb="md">
<Tabs.Tab value="register">Register ({(assets.data ?? []).length})</Tabs.Tab>
<Tabs.Tab value="runs">Depreciation runs</Tabs.Tab>
<Tabs.Tab value="disposals">Disposals</Tabs.Tab>
<Tabs.Tab value="categories">Categories</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="register">
{assets.isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (assets.data ?? []).length === 0 ? (
<Text c="dimmed">No assets yet. Define a category first, then add assets to it.</Text>
) : (
<Table.ScrollContainer minWidth={1050}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={130}>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th w={140}>Category</Table.Th>
<Table.Th w={120}>In service</Table.Th>
<Table.Th w={130} ta="right">Cost</Table.Th>
<Table.Th w={130} ta="right">Accum. depn</Table.Th>
<Table.Th w={130} ta="right">Net book value</Table.Th>
<Table.Th w={150}>Status</Table.Th>
<Table.Th w={170} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(assets.data ?? []).map((a) => (
<Table.Tr key={a.id}>
<Table.Td><Text ff="monospace" size="sm">{a.assetCode}</Text></Table.Td>
<Table.Td>{a.name}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{a.categoryName?.en}</Text>
</Table.Td>
<Table.Td>{a.inServiceDate}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(a.acquisitionCost)}</Text></Table.Td>
<Table.Td ta="right">
<Text ff="monospace" c="dimmed">{formatMoney(a.accumulatedDepreciation)}</Text>
</Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={600}>{formatMoney(a.netBookValue)}</Text></Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={ASSET_STATUS_COLOR[a.status]}>
{a.status.replace("_", " ")}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<Button size="xs" variant="subtle" onClick={() => setScheduleFor(a.id)}>
Schedule
</Button>
{a.status !== "DISPOSED" && a.status !== "WRITTEN_OFF" &&
can(FINANCE_PERMS.asset.dispose) && (
<Button
size="xs"
variant="light"
color="orange"
onClick={() => setDisposeFor({ id: a.id, code: a.assetCode, nbv: Number(a.netBookValue) })}
>
Dispose
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Tabs.Panel>
<Tabs.Panel value="runs">
<Alert icon={<IconInfoCircle size={18} />} color="blue" variant="light" mb="md">
<Text size="sm">
One run per period, posted as a single entry summarised by
category. Each charge is the gap to a cumulative target, so
rounding never accumulates and the last period lands exactly on
the depreciable base.
</Text>
</Alert>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={220}>Period</Table.Th>
<Table.Th w={130}>Run date</Table.Th>
<Table.Th w={110} ta="right">Assets</Table.Th>
<Table.Th w={160} ta="right">Charged</Table.Th>
<Table.Th w={170}>Entry</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(runs.data ?? []).map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.periodName?.en}</Table.Td>
<Table.Td>{r.runDate}</Table.Td>
<Table.Td ta="right">{r.assetCount}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.totalAmount)}</Text></Table.Td>
<Table.Td>
{r.entryNumber && (
<Badge size="sm" variant="light" color="green">{r.entryNumber}</Badge>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(runs.data ?? []).length === 0 && <Text c="dimmed">No runs yet.</Text>}
</Tabs.Panel>
<Tabs.Panel value="disposals">
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={140}>Asset</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th w={120}>Date</Table.Th>
<Table.Th w={110}>Type</Table.Th>
<Table.Th w={130} ta="right">Proceeds</Table.Th>
<Table.Th w={140} ta="right">Book value</Table.Th>
<Table.Th w={140} ta="right">Gain / loss</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(disposals.data ?? []).map((d) => (
<Table.Tr key={d.id}>
<Table.Td><Text ff="monospace" size="sm">{d.assetCode}</Text></Table.Td>
<Table.Td>{d.assetName}</Table.Td>
<Table.Td>{d.disposalDate}</Table.Td>
<Table.Td>{d.disposalType}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(d.proceeds)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(d.netBookValue)}</Text></Table.Td>
<Table.Td ta="right">
<Text ff="monospace" c={Number(d.gainLoss) >= 0 ? "green" : "red"}>
{formatMoney(d.gainLoss)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(disposals.data ?? []).length === 0 && <Text c="dimmed">Nothing disposed of yet.</Text>}
</Tabs.Panel>
<Tabs.Panel value="categories">
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={180}>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th w={160} ta="right">Default life</Table.Th>
<Table.Th w={160} ta="right">Salvage rate</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(cats.data ?? []).map((c) => (
<Table.Tr key={c.id}>
<Table.Td><Text ff="monospace" size="sm">{c.code}</Text></Table.Td>
<Table.Td>{c.name?.en}</Table.Td>
<Table.Td ta="right">{c.defaultLifeMonths} months</Table.Td>
<Table.Td ta="right">{(Number(c.defaultSalvageRate) * 100).toFixed(2)}%</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(cats.data ?? []).length === 0 && (
<Text c="dimmed">
No categories yet. A category names the three accounts an asset's
postings touch — cost, accumulated depreciation and the charge.
</Text>
)}
</Tabs.Panel>
</Tabs>
<ScheduleModal assetId={scheduleFor} onClose={() => setScheduleFor(null)} />
<RunModal
opened={runOpen}
onClose={() => setRunOpen(false)}
onDone={() => { setRunOpen(false); invalidate(); }}
onError={setActionError}
/>
<DisposeModal
target={disposeFor}
onClose={() => setDisposeFor(null)}
onDone={() => { setDisposeFor(null); invalidate(); }}
/>
<CategoryModal
opened={catOpen}
onClose={() => setCatOpen(false)}
onCreated={() => { setCatOpen(false); invalidate(); }}
/>
<AssetModal
opened={assetOpen}
onClose={() => setAssetOpen(false)}
onCreated={() => { setAssetOpen(false); invalidate(); }}
/>
</>
);
}
function ScheduleModal({ assetId, onClose }: { assetId: string | null; onClose: () => void }) {
const s = useQuery({
queryKey: ["assets", "schedule", assetId],
queryFn: () => fetchSchedule(assetId as string),
enabled: Boolean(assetId),
});
const d = s.data;
return (
<Modal opened={Boolean(assetId)} onClose={onClose} title="Depreciation schedule" size="lg">
{s.isLoading || !d ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack>
<Text size="sm" c="dimmed">
{d.assetCode} · cost {formatMoney(d.acquisitionCost)} · salvage{" "}
{formatMoney(d.salvageValue)} · {d.periodsCharged} period(s) charged ·
book value now <b>{formatMoney(d.netBookValue)}</b>
</Text>
<Table.ScrollContainer minWidth={520} mah={400}>
<Table striped withTableBorder stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th w={80}>Month</Table.Th>
<Table.Th ta="right">Charge</Table.Th>
<Table.Th ta="right">Accumulated</Table.Th>
<Table.Th ta="right">Book value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{d.schedule.map((r) => (
<Table.Tr key={r.month} bg={r.month <= d.periodsCharged ? "var(--mantine-color-default-hover)" : undefined}>
<Table.Td>{r.month}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.amount)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.accumulated)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.netBookValue)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
)}
</Modal>
);
}
function RunModal({
opened, onClose, onDone, onError,
}: { opened: boolean; onClose: () => void; onDone: () => void; onError: (e: unknown) => void }) {
const [yearId, setYearId] = useState<string | null>(null);
const [periodId, setPeriodId] = useState<string | null>(null);
const years = useQuery({ queryKey: ["fiscal", "years"], queryFn: fetchFiscalYears });
const periods = useQuery({
queryKey: ["fiscal", "periods", yearId],
queryFn: () => fetchFiscalPeriods(yearId ?? undefined),
enabled: Boolean(yearId),
});
const run = useMutation({
mutationFn: () => runDepreciation(periodId as string),
onSuccess: onDone,
onError,
});
return (
<Modal opened={opened} onClose={onClose} title="Run depreciation">
<Stack>
<ApiErrorAlert error={run.error} title="The run was refused" />
<Text size="sm" c="dimmed">
Charges every active asset one period's depreciation and posts a
single journal entry dated the period end.
</Text>
<Select
label="Fiscal year"
data={(years.data ?? []).map((y) => ({ value: y.id, label: y.code }))}
value={yearId}
onChange={(v) => { setYearId(v); setPeriodId(null); }}
/>
<Select
label="Period"
data={(periods.data ?? []).filter((p) => p.status === "OPEN").map((p) => ({
value: p.id, label: `${p.name.en} (${p.startDate}${p.endDate})`,
}))}
value={periodId}
onChange={setPeriodId}
disabled={!yearId}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button loading={run.isPending} disabled={!periodId} onClick={() => run.mutate()}>
Run
</Button>
</Group>
</Stack>
</Modal>
);
}
function DisposeModal({
target, onClose, onDone,
}: { target: { id: string; code: string; nbv: number } | null; onClose: () => void; onDone: () => void }) {
const [type, setType] = useState("SALE");
const [date, setDate] = useState(today());
const [proceeds, setProceeds] = useState<number | "">("");
const [accountId, setAccountId] = useState<string | null>(null);
const [reference, setReference] = useState("");
const cashAccounts = useQuery({
queryKey: ["accounts", "cash"],
queryFn: () => fetchAccounts({ isActive: true }),
select: (all) =>
all.filter((a) => a.accountType === "ASSET" && !a.isGroup && a.code.startsWith("111"))
.map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` })),
});
const go = useMutation({
mutationFn: () =>
disposeAsset(target!.id, {
disposalType: type, disposalDate: date,
proceeds: Number(proceeds) || 0,
proceedsAccountId: Number(proceeds) > 0 ? (accountId as string) : undefined,
reference: reference.trim() || undefined,
}),
onSuccess: onDone,
});
const gain = (Number(proceeds) || 0) - (target?.nbv ?? 0);
return (
<Modal opened={Boolean(target)} onClose={onClose} title={`Dispose ${target?.code ?? ""}`}>
<Stack>
<ApiErrorAlert error={go.error} title="Disposal refused" />
<Text size="sm" c="dimmed">
Book value now: <b>{formatMoney(target?.nbv ?? 0)}</b>
</Text>
<Select
label="Type"
data={["SALE", "SCRAP", "WRITE_OFF"]}
value={type}
onChange={(v) => setType(v ?? "SALE")}
allowDeselect={false}
/>
<TextInput label="Date" type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} />
<NumberInput
label="Proceeds"
min={0}
decimalScale={2}
thousandSeparator=","
value={proceeds}
onChange={(v) => setProceeds(v === "" ? "" : Number(v))}
/>
{Number(proceeds) > 0 && (
<Select
label="Proceeds received into"
data={cashAccounts.data ?? []}
value={accountId}
onChange={setAccountId}
searchable
/>
)}
<TextInput label="Reference" value={reference} onChange={(e) => setReference(e.currentTarget.value)} />
<Alert color={gain >= 0 ? "green" : "orange"} variant="light">
<Text size="sm">
{gain >= 0 ? "Gain" : "Loss"} of <b>{formatMoney(Math.abs(gain))}</b>
proceeds less the remaining book value.
</Text>
</Alert>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
loading={go.isPending}
disabled={Number(proceeds) > 0 && !accountId}
onClick={() => go.mutate()}
>
Dispose
</Button>
</Group>
</Stack>
</Modal>
);
}
function CategoryModal({
opened, onClose, onCreated,
}: { opened: boolean; onClose: () => void; onCreated: () => void }) {
const [code, setCode] = useState("");
const [en, setEn] = useState("");
const [assetAccountId, setAssetAccountId] = useState<string | null>(null);
const [accumId, setAccumId] = useState<string | null>(null);
const [expenseId, setExpenseId] = useState<string | null>(null);
const [life, setLife] = useState<number | "">(60);
const accounts = useQuery({ queryKey: ["accounts", "all"], queryFn: () => fetchAccounts({ isActive: true }) });
const opt = (pred: (a: { accountType: string; isGroup: boolean; isContra: boolean }) => boolean) =>
(accounts.data ?? []).filter((a) => !a.isGroup && pred(a))
.map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` }));
const create = useMutation({
mutationFn: () =>
createAssetCategory({
code: code.trim(), name: { en: en.trim(), am: en.trim() },
assetAccountId: assetAccountId as string,
accumulatedAccountId: accumId as string,
expenseAccountId: expenseId as string,
defaultLifeMonths: Number(life) || 60,
}),
onSuccess: () => { setCode(""); setEn(""); onCreated(); },
});
return (
<Modal opened={opened} onClose={onClose} title="New asset category" size="lg">
<Stack>
<ApiErrorAlert error={create.error} title="Could not create the category" />
<TextInput label="Code" required value={code} onChange={(e) => setCode(e.currentTarget.value)} />
<TextInput label="Name" required value={en} onChange={(e) => setEn(e.currentTarget.value)} />
<Select
label="Asset cost account"
data={opt((a) => a.accountType === "ASSET" && !a.isContra)}
value={assetAccountId} onChange={setAssetAccountId} searchable required
/>
<Select
label="Accumulated depreciation account"
description="Must be a CONTRA asset account, or the balance sheet would add depreciation to assets"
data={opt((a) => a.accountType === "ASSET" && a.isContra)}
value={accumId} onChange={setAccumId} searchable required
/>
<Select
label="Depreciation expense account"
data={opt((a) => a.accountType === "EXPENSE")}
value={expenseId} onChange={setExpenseId} searchable required
/>
<NumberInput
label="Default useful life (months)"
min={1} max={1200}
value={life}
onChange={(v) => setLife(v === "" ? "" : Number(v))}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
loading={create.isPending}
disabled={!code.trim() || !en.trim() || !assetAccountId || !accumId || !expenseId}
onClick={() => create.mutate()}
>
Create
</Button>
</Group>
</Stack>
</Modal>
);
}
function AssetModal({
opened, onClose, onCreated,
}: { opened: boolean; onClose: () => void; onCreated: () => void }) {
const [categoryId, setCategoryId] = useState<string | null>(null);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [acqDate, setAcqDate] = useState(today());
const [inService, setInService] = useState("");
const [cost, setCost] = useState<number | "">("");
const [salvage, setSalvage] = useState<number | "">("");
const [life, setLife] = useState<number | "">("");
const [fundingId, setFundingId] = useState<string | null>(null);
const cats = useQuery({ queryKey: ["assets", "categories"], queryFn: fetchAssetCategories });
const cashAccounts = useQuery({
queryKey: ["accounts", "cash"],
queryFn: () => fetchAccounts({ isActive: true }),
select: (all) =>
all.filter((a) => a.accountType === "ASSET" && !a.isGroup && a.code.startsWith("111"))
.map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` })),
});
const create = useMutation({
mutationFn: () =>
createAsset({
assetCategoryId: categoryId as string,
assetCode: code.trim(), name: name.trim(),
acquisitionDate: acqDate,
inServiceDate: inService || undefined,
acquisitionCost: Number(cost),
salvageValue: salvage === "" ? undefined : Number(salvage),
usefulLifeMonths: life === "" ? undefined : Number(life),
fundingAccountId: fundingId ?? undefined,
}),
onSuccess: () => { setCode(""); setName(""); setCost(""); onCreated(); },
});
return (
<Modal opened={opened} onClose={onClose} title="New asset" size="lg">
<Stack>
<ApiErrorAlert error={create.error} title="Could not add the asset" />
<Select
label="Category"
required
data={(cats.data ?? []).map((c) => ({ value: c.id, label: `${c.code}${c.name.en}` }))}
value={categoryId} onChange={setCategoryId}
/>
<Group grow>
<TextInput label="Asset code" required value={code} onChange={(e) => setCode(e.currentTarget.value)} />
<TextInput label="Name" required value={name} onChange={(e) => setName(e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Acquired" type="date" value={acqDate} onChange={(e) => setAcqDate(e.currentTarget.value)} />
<TextInput
label="In service"
type="date"
description="Depreciation runs from here"
value={inService}
onChange={(e) => setInService(e.currentTarget.value)}
/>
</Group>
<Group grow>
<NumberInput label="Cost" min={0.01} decimalScale={2} thousandSeparator=","
value={cost} onChange={(v) => setCost(v === "" ? "" : Number(v))} />
<NumberInput label="Salvage value" min={0} decimalScale={2} thousandSeparator=","
value={salvage} onChange={(v) => setSalvage(v === "" ? "" : Number(v))} />
<NumberInput label="Life (months)" min={1}
value={life} onChange={(v) => setLife(v === "" ? "" : Number(v))} />
</Group>
<Select
label="Post acquisition from"
description="Leave empty when the asset already reached the books through an approved supplier bill — posting again would double the cost"
placeholder="Do not post"
clearable
data={cashAccounts.data ?? []}
value={fundingId}
onChange={setFundingId}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
loading={create.isPending}
disabled={!categoryId || !code.trim() || !name.trim() || !cost}
onClick={() => create.mutate()}
>
Add asset
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,279 @@
import { financeApi } from "@/auth/http";
import type { LocalizedName } from "@/shared/types";
export type AssetStatus =
| "ACTIVE"
| "FULLY_DEPRECIATED"
| "DISPOSED"
| "WRITTEN_OFF";
export type AssetRow = {
id: string;
assetCode: string;
name: string;
serialNumber: string | null;
acquisitionDate: string;
inServiceDate: string;
acquisitionCost: number | string;
salvageValue: number | string;
usefulLifeMonths: number;
accumulatedDepreciation: number | string;
netBookValue: number | string;
status: AssetStatus;
categoryCode: string;
categoryName: LocalizedName;
costCenterCode: string | null;
};
export type AssetCategory = {
id: string;
code: string;
name: LocalizedName;
defaultLifeMonths: number;
defaultSalvageRate: string;
isActive: boolean;
};
export type ScheduleRow = {
month: number;
amount: number;
accumulated: number;
netBookValue: number;
};
export type DepreciationRun = {
id: string;
runDate: string;
assetCount: number;
totalAmount: number | string;
entryNumber: string | null;
periodName: LocalizedName;
periodNumber: number;
};
export type Disposal = {
id: string;
disposalDate: string;
disposalType: string;
proceeds: number | string;
netBookValue: number | string;
gainLoss: number | string;
reference: string | null;
assetCode: string;
assetName: string;
};
export const ASSET_STATUS_COLOR: Record<AssetStatus, string> = {
ACTIVE: "green",
FULLY_DEPRECIATED: "blue",
DISPOSED: "gray",
WRITTEN_OFF: "red",
};
export const fetchAssets = async (status?: string): Promise<AssetRow[]> => {
const { data } = await financeApi.get("/assets", {
params: status ? { status } : undefined,
});
return data;
};
export const fetchAssetCategories = async (): Promise<AssetCategory[]> => {
const { data } = await financeApi.get("/assets/categories");
return data;
};
export const createAssetCategory = async (payload: {
code: string;
name: { en: string; am: string };
assetAccountId: string;
accumulatedAccountId: string;
expenseAccountId: string;
defaultLifeMonths: number;
defaultSalvageRate?: number;
}) => {
const { data } = await financeApi.post("/assets/categories", payload);
return data;
};
export const createAsset = async (payload: {
assetCategoryId: string;
assetCode: string;
name: string;
acquisitionDate: string;
inServiceDate?: string;
acquisitionCost: number;
salvageValue?: number;
usefulLifeMonths?: number;
costCenterId?: string;
fundingAccountId?: string;
}) => {
const { data } = await financeApi.post("/assets", payload);
return data;
};
export const fetchSchedule = async (
id: string,
): Promise<{
assetCode: string;
acquisitionCost: number;
salvageValue: number;
netBookValue: number;
periodsCharged: number;
schedule: ScheduleRow[];
}> => {
const { data } = await financeApi.get(`/assets/${id}/schedule`);
return data;
};
export const fetchDepreciationRuns = async (): Promise<DepreciationRun[]> => {
const { data } = await financeApi.get("/assets/depreciation/runs");
return data;
};
export const runDepreciation = async (fiscalPeriodId: string) => {
const { data } = await financeApi.post("/assets/depreciation/run", {
fiscalPeriodId,
});
return data;
};
export const fetchDisposals = async (): Promise<Disposal[]> => {
const { data } = await financeApi.get("/assets/disposals");
return data;
};
export const disposeAsset = async (
id: string,
payload: {
disposalType: string;
disposalDate: string;
proceeds?: number;
proceedsAccountId?: string;
reference?: string;
},
) => {
const { data } = await financeApi.post(`/assets/${id}/dispose`, payload);
return data;
};
// ── reports (4.6) ──────────────────────────────────────────────────────────
export type AccountLine = {
accountCode: string;
accountName: LocalizedName;
accountType: string;
isContra?: boolean;
};
export type TrialBalance = {
rows: (AccountLine & { normalBalance: string; debit: number; credit: number })[];
totalDebit: number;
totalCredit: number;
balanced: boolean;
difference: number;
};
export type ProfitAndLoss = {
dateFrom: string;
dateTo: string;
revenue: (AccountLine & { amount: number })[];
expenses: (AccountLine & { amount: number })[];
totalRevenue: number;
totalExpenses: number;
netResult: number;
};
export type BalanceSheet = {
asOf: string;
assets: (AccountLine & { balance: number })[];
liabilities: (AccountLine & { balance: number })[];
equity: (AccountLine & { balance: number })[];
totalAssets: number;
totalLiabilities: number;
totalEquity: number;
retainedResult: number;
equityWithResult: number;
balanced: boolean;
difference: number;
};
export type CashMovement = {
accounts: {
accountCode: string;
accountName: LocalizedName;
opening: number;
cashIn: number;
cashOut: number;
closing: number;
}[];
totalOpening: number;
totalIn: number;
totalOut: number;
totalClosing: number;
};
export type GeneralLedger = {
openingBalance: number;
closingBalance: number;
lines: {
entryDate: string;
entryNumber: string;
memo: string;
journalType: string;
description: string | null;
costCenterCode: string | null;
debit: number;
credit: number;
balance: number;
}[];
};
export const fetchTrialBalance = async (
dateFrom: string,
dateTo: string,
): Promise<TrialBalance> => {
const { data } = await financeApi.get<TrialBalance>("/reports/trial-balance", {
params: { dateFrom, dateTo },
});
return data;
};
export const fetchProfitAndLoss = async (
dateFrom: string,
dateTo: string,
): Promise<ProfitAndLoss> => {
const { data } = await financeApi.get<ProfitAndLoss>(
"/reports/profit-and-loss",
{ params: { dateFrom, dateTo } },
);
return data;
};
export const fetchBalanceSheet = async (asOf: string): Promise<BalanceSheet> => {
const { data } = await financeApi.get<BalanceSheet>("/reports/balance-sheet", {
params: { asOf },
});
return data;
};
export const fetchCashMovement = async (
dateFrom: string,
dateTo: string,
): Promise<CashMovement> => {
const { data } = await financeApi.get<CashMovement>("/reports/cash-movement", {
params: { dateFrom, dateTo },
});
return data;
};
export const fetchGeneralLedger = async (
accountId: string,
dateFrom: string,
dateTo: string,
): Promise<GeneralLedger> => {
const { data } = await financeApi.get<GeneralLedger>(
"/reports/general-ledger",
{ params: { accountId, dateFrom, dateTo } },
);
return data;
};

View File

@@ -0,0 +1,92 @@
import {
Button,
Card,
Center,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useState, type FormEvent } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@/auth/AuthContext";
import { apiErrorMessage } from "@/auth/http";
import { AUTH_API_URL } from "@/config/env";
export function LoginPage() {
const { user, login } = useAuth();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (user) {
const from = (location.state as { from?: { pathname?: string } })?.from
?.pathname;
return <Navigate to={from ?? "/"} replace />;
}
const submit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(email, password);
} catch (err) {
setError(apiErrorMessage(err));
} finally {
setIsSubmitting(false);
}
};
return (
<Center h="100vh">
<Card withBorder w={400} p="xl">
<form onSubmit={submit}>
<Stack>
<Stack gap={2}>
<Title order={3}>EDR Finance</Title>
<Text size="sm" c="dimmed">
Sign in with your EDR account.
</Text>
</Stack>
<TextInput
label="Email or username"
required
autoFocus
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
<PasswordInput
label="Password"
required
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
/>
{error && (
<Text size="sm" c="red" style={{ whiteSpace: "pre-line" }}>
{error}
</Text>
)}
<Button type="submit" loading={isSubmitting} fullWidth>
Sign in
</Button>
{/* Where credentials go is worth stating: login is repointable at a
central IAM service, so a failure here may be an auth-host
problem rather than a Finance one. */}
<Text size="xs" c="dimmed" ta="center">
Authenticating against {AUTH_API_URL}
</Text>
</Stack>
</form>
</Card>
</Center>
);
}

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

View File

@@ -0,0 +1,469 @@
import { useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCheck,
IconClock,
IconInfoCircle,
} from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
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 {
CHECK_COLOR,
fetchCutover,
fetchReadiness,
importOpeningBalances,
parseOpeningBalances,
setCutover,
type CheckStatus,
type ImportResult,
} from "./api";
const CHECK_ICON: Record<CheckStatus, typeof IconCheck> = {
PASS: IconCheck,
FAIL: IconAlertTriangle,
PENDING: IconClock,
};
const SAMPLE = `account,debit,credit,description
1111,50000,,Cash at cutover
1121,30000,,Freight receivables
2111,,12000,Trade payables`;
export function CutoverPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const canManage = can(FINANCE_PERMS.period.manage);
const canImport = can(FINANCE_PERMS.journal.create);
const readiness = useQuery({
queryKey: ["cutover", "readiness"],
queryFn: fetchReadiness,
});
const settings = useQuery({ queryKey: ["cutover"], queryFn: fetchCutover });
const [dateOpen, setDateOpen] = useState(false);
const [pasted, setPasted] = useState("");
const [entryDate, setEntryDate] = useState("");
const [memo, setMemo] = useState("");
const [result, setResult] = useState<ImportResult | null>(null);
const parsed = useMemo(() => parseOpeningBalances(pasted), [pasted]);
const good = parsed.filter((row) => row.ok);
const bad = parsed.filter((row) => !row.ok);
// The plug shown here mirrors what the server will calculate. It is a
// preview, never the value sent — the server computes its own.
const totals = good.reduce(
(acc, row) => {
if (!row.ok) return acc;
return {
debit: acc.debit + (row.value.debit ?? 0),
credit: acc.credit + (row.value.credit ?? 0),
};
},
{ debit: 0, credit: 0 },
);
const plug = Math.round((totals.debit - totals.credit) * 100) / 100;
const doImport = useMutation({
mutationFn: () =>
importOpeningBalances({
entryDate: entryDate || settings.data?.cutoverDate || "",
memo: memo.trim() || undefined,
lines: good.flatMap((row) => (row.ok ? [row.value] : [])),
}),
onSuccess: (data) => {
setResult(data);
setPasted("");
setMemo("");
void queryClient.invalidateQueries({ queryKey: ["cutover"] });
void queryClient.invalidateQueries({ queryKey: ["journals"] });
},
});
const saveDate = useMutation({
mutationFn: (payload: { cutoverDate: string; cutoverNote?: string }) =>
setCutover(payload),
onSuccess: () => {
setDateOpen(false);
void queryClient.invalidateQueries({ queryKey: ["cutover"] });
},
});
const cutoverDate = readiness.data?.cutoverDate ?? null;
return (
<>
<PageHeader
title="Cutover"
description="Going live: the opening balances, and whether the books are ready to carry them."
actions={
canManage ? (
<Button onClick={() => setDateOpen(true)}>
{cutoverDate ? "Change cutover date" : "Set cutover date"}
</Button>
) : undefined
}
/>
<Alert icon={<IconInfoCircle size={18} />} color="blue" mb="lg">
The cutover date is the boundary between the two ways Finance can answer a
question about the past. Before it, history is reported by reading the
freight, passenger and HR systems directly. On or after it, this ledger is
the answer.
</Alert>
{/* ── Readiness ──────────────────────────────────────────────────── */}
<Card withBorder mb="lg">
<Group justify="space-between" mb="sm">
<Title order={4}>Readiness</Title>
{readiness.data ? (
<Badge
size="lg"
color={readiness.data.ready ? "green" : "orange"}
variant={readiness.data.ready ? "filled" : "light"}
>
{readiness.data.ready ? "Ready to go live" : "Not ready"}
</Badge>
) : null}
</Group>
{readiness.isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : (
<Stack gap="xs">
{(readiness.data?.checks ?? []).map((check) => {
const Icon = CHECK_ICON[check.status];
return (
<Group key={check.key} align="flex-start" wrap="nowrap">
<ThemeIcon
size="sm"
radius="xl"
variant="light"
color={CHECK_COLOR[check.status]}
>
<Icon size={14} />
</ThemeIcon>
<div>
<Text fw={600} size="sm">
{check.title}
</Text>
<Text size="sm" c="dimmed">
{check.detail}
</Text>
</div>
</Group>
);
})}
</Stack>
)}
{readiness.data?.cutoverNote ? (
<Text size="sm" c="dimmed" mt="md" fs="italic">
{readiness.data.cutoverNote}
</Text>
) : null}
</Card>
{/* ── Opening balances ───────────────────────────────────────────── */}
{canImport ? (
<Card withBorder>
<Title order={4} mb="xs">
Opening balances
</Title>
<Text size="sm" c="dimmed" mb="md">
Paste rows as <Text span ff="monospace">account, debit, credit, description</Text>{" "}
commas or tabs, so a spreadsheet paste works as it is. A batch need
not balance on its own: the difference goes to{" "}
<Text span fw={600}>3900 Opening Balance Suspense</Text>, which must
reach zero once every batch is in. That is the check that the
migration was entered correctly.
</Text>
<ApiErrorAlert
error={doImport.error}
title="The import was refused"
/>
{result ? (
<Alert color="green" mb="md" title="Draft created">
<Text size="sm">
{result.imported} line(s) became{" "}
<Text span fw={600}>{result.entry.entryNumber}</Text> as a DRAFT.
Nothing has been posted review it, then post it like any other
entry.
{result.suspensePlug !== 0 ? (
<>
{" "}A suspense plug of {formatMoney(Math.abs(result.suspensePlug))} was
calculated.
</>
) : (
" The batch balanced on its own, so no plug was needed."
)}
</Text>
<Button
component={Link}
to={`/journals/${result.entry.id}`}
size="xs"
mt="sm"
variant="light"
>
Open {result.entry.entryNumber}
</Button>
</Alert>
) : null}
<Group grow mb="sm" align="flex-start">
<TextInput
label="Entry date"
type="date"
description="Defaults to the cutover date"
value={entryDate || cutoverDate || ""}
onChange={(event) => setEntryDate(event.currentTarget.value)}
/>
<TextInput
label="Memo"
placeholder="Opening balances — cash and receivables"
value={memo}
onChange={(event) => setMemo(event.currentTarget.value)}
/>
</Group>
<Textarea
label="Rows"
placeholder={SAMPLE}
autosize
minRows={6}
maxRows={16}
value={pasted}
onChange={(event) => setPasted(event.currentTarget.value)}
/>
{parsed.length > 0 ? (
<>
{bad.length > 0 ? (
<Alert color="red" mt="md" title={`${bad.length} row(s) could not be read`}>
<Stack gap={4}>
{bad.map((row) =>
row.ok ? null : (
<Text key={row.line} size="sm" ff="monospace">
line {row.line}: {row.error} {row.raw.slice(0, 60)}
</Text>
),
)}
</Stack>
</Alert>
) : null}
<Table mt="md" withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th ta="right">Debit</Table.Th>
<Table.Th ta="right">Credit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{good.map((row) =>
row.ok ? (
<Table.Tr key={row.line}>
<Table.Td>
<Text ff="monospace" size="sm">
{row.value.accountCode}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{row.value.description ?? "—"}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">
{row.value.debit ? formatMoney(row.value.debit) : ""}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">
{row.value.credit ? formatMoney(row.value.credit) : ""}
</Text>
</Table.Td>
</Table.Tr>
) : null,
)}
{plug !== 0 ? (
<Table.Tr>
<Table.Td>
<Text ff="monospace" size="sm" c="dimmed">
3900
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" fs="italic">
Suspense plug calculated, not entered
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" c="dimmed">
{plug < 0 ? formatMoney(Math.abs(plug)) : ""}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" c="dimmed">
{plug > 0 ? formatMoney(plug) : ""}
</Text>
</Table.Td>
</Table.Tr>
) : null}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={2}>
<Text fw={700}>Total</Text>
</Table.Td>
<Table.Td ta="right">
<Text fw={700} ff="monospace">
{formatMoney(
totals.debit + (plug < 0 ? Math.abs(plug) : 0),
)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text fw={700} ff="monospace">
{formatMoney(totals.credit + (plug > 0 ? plug : 0))}
</Text>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
<Group justify="flex-end" mt="md">
<Button
loading={doImport.isPending}
disabled={
good.length === 0 ||
bad.length > 0 ||
!(entryDate || cutoverDate)
}
onClick={() => doImport.mutate()}
>
Create draft entry
</Button>
</Group>
{bad.length > 0 ? (
<Text size="sm" c="dimmed" ta="right">
Fix the unreadable rows first importing part of a batch would
put a wrong plug in the ledger.
</Text>
) : null}
</>
) : null}
</Card>
) : null}
<SetCutoverModal
opened={dateOpen}
current={settings.data ?? null}
pending={saveDate.isPending}
error={saveDate.error}
onClose={() => setDateOpen(false)}
onSave={(payload) => saveDate.mutate(payload)}
/>
</>
);
}
function SetCutoverModal({
opened,
current,
pending,
error,
onClose,
onSave,
}: {
opened: boolean;
current: { cutoverDate: string | null; cutoverNote: string | null } | null;
pending: boolean;
error: unknown;
onClose: () => void;
onSave: (payload: { cutoverDate: string; cutoverNote?: string }) => void;
}) {
const [date, setDate] = useState("");
const [note, setNote] = useState("");
return (
<Modal
opened={opened}
onClose={onClose}
title={current?.cutoverDate ? "Change the cutover date" : "Set the cutover date"}
>
<Stack>
<ApiErrorAlert error={error} title="Could not save the cutover date" />
{current?.cutoverDate ? (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
Currently {current.cutoverDate}. Moving it changes which entries count
as history and which count as live, so anything already posted on the
wrong side of the new date will be reported by the readiness check.
</Alert>
) : null}
<TextInput
label="Cutover date"
type="date"
required
description="Usually the first day of a fiscal year"
value={date || current?.cutoverDate || ""}
onChange={(event) => setDate(event.currentTarget.value)}
/>
<Textarea
label="Why this date"
description="Recorded permanently — the explanation an auditor reads"
autosize
minRows={2}
value={note || current?.cutoverNote || ""}
onChange={(event) => setNote(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={pending}
disabled={!(date || current?.cutoverDate)}
onClick={() =>
onSave({
cutoverDate: date || (current?.cutoverDate as string),
cutoverNote: (note || current?.cutoverNote) ?? undefined,
})
}
>
Save
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,173 @@
import { financeApi } from "@/auth/http";
import type { JournalEntryDetail } from "@/features/journals/types";
export type CheckStatus = "PASS" | "FAIL" | "PENDING";
export type ReadinessCheck = {
key: string;
title: string;
status: CheckStatus;
detail: string;
};
export type CutoverReadiness = {
cutoverDate: string | null;
cutoverNote: string | null;
ready: boolean;
checks: ReadinessCheck[];
};
export type OrgSettings = {
id: string;
organizationId: string;
cutoverDate: string | null;
cutoverNote: string | null;
};
export type OpeningBalanceLine = {
accountCode: string;
debit?: number;
credit?: number;
description?: string;
};
export type ImportResult = {
entry: JournalEntryDetail;
imported: number;
totalDebit: number;
totalCredit: number;
suspensePlug: number;
};
export const CHECK_COLOR: Record<CheckStatus, string> = {
PASS: "green",
FAIL: "red",
PENDING: "gray",
};
export const fetchCutover = async (): Promise<OrgSettings | null> => {
const { data } = await financeApi.get<OrgSettings | null>("/cutover");
return data;
};
export const fetchReadiness = async (): Promise<CutoverReadiness> => {
const { data } = await financeApi.get<CutoverReadiness>("/cutover/readiness");
return data;
};
export const setCutover = async (payload: {
cutoverDate: string;
cutoverNote?: string;
}): Promise<OrgSettings> => {
const { data } = await financeApi.put<OrgSettings>("/cutover", payload);
return data;
};
export const importOpeningBalances = async (payload: {
entryDate: string;
memo?: string;
lines: OpeningBalanceLine[];
}): Promise<ImportResult> => {
const { data } = await financeApi.post<ImportResult>(
"/cutover/opening-balances",
payload,
);
return data;
};
/** One parsed row, or the reason the row could not be read. */
export type ParsedRow =
| { ok: true; line: number; value: OpeningBalanceLine }
| { ok: false; line: number; raw: string; error: string };
/**
* Parse pasted opening balances.
*
* Deliberately forgiving about SHAPE and strict about MEANING: it accepts
* commas or tabs (a spreadsheet paste arrives tab-separated), skips blank lines,
* drops a header row, and strips thousands separators and currency symbols —
* because the source of this data is someone's exported trial balance, not a
* clean API payload.
*
* It does NOT try to be clever about which account a code means, and it does not
* silently repair an ambiguous row: a line with two amounts is reported, not
* guessed at. The server re-validates everything regardless; this exists so the
* person pasting sees the problem beside the row that caused it.
*/
export function parseOpeningBalances(text: string): ParsedRow[] {
const rows: ParsedRow[] = [];
text.split(/\r?\n/).forEach((raw, index) => {
const line = index + 1;
if (!raw.trim()) return;
const cells = raw.split(/\t|,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map((cell) =>
cell.trim().replace(/^"|"$/g, "").trim(),
);
// A header row — recognised by its first cell being non-numeric text that
// looks like a label rather than an account code.
if (index === 0 && /^(account|code|acct)/i.test(cells[0] ?? "")) return;
const [code, debitRaw, creditRaw, ...rest] = cells;
if (!code) {
rows.push({ ok: false, line, raw, error: "No account code." });
return;
}
const num = (value: string | undefined): number => {
if (!value || !value.trim()) return 0;
// Strip thousands separators, currency symbols and spaces; keep the sign.
const cleaned = value.replace(/[^0-9.\-]/g, "");
// A cell that HAD content but nothing numeric in it ("abc", "n/a") is a
// typo, not an empty column — reporting it as "no amount" would send the
// reader looking at the wrong thing.
if (!cleaned || cleaned === "-") return NaN;
const parsed = Number(cleaned);
return Number.isFinite(parsed) ? parsed : NaN;
};
const debit = num(debitRaw);
const credit = num(creditRaw);
if (Number.isNaN(debit) || Number.isNaN(credit)) {
rows.push({ ok: false, line, raw, error: "Amount is not a number." });
return;
}
if (debit < 0 || credit < 0) {
rows.push({
ok: false,
line,
raw,
error: "Negative amount — put it on the other side instead.",
});
return;
}
if (debit > 0 && credit > 0) {
rows.push({
ok: false,
line,
raw,
error: "Both a debit and a credit on one row.",
});
return;
}
if (debit === 0 && credit === 0) {
rows.push({ ok: false, line, raw, error: "No amount on this row." });
return;
}
rows.push({
ok: true,
line,
value: {
accountCode: code,
debit: debit || undefined,
credit: credit || undefined,
description: rest.join(" ").trim() || undefined,
},
});
});
return rows;
}

View File

@@ -0,0 +1,259 @@
import { Alert, Card, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
IconAlertTriangle,
IconCalendarStats,
IconCheck,
IconFileText,
IconRocket,
} from "@tabler/icons-react";
import type { ReactNode } from "react";
import { useAuth } from "@/auth/AuthContext";
import { FINANCE_PERMS } from "@/auth/permissions";
import { PageHeader } from "@/shared/components/PageHeader";
import { localized } from "@/shared/lib/localizedName";
import { fetchFiscalPeriods, fetchFiscalYears } from "@/features/periods/api";
import { fetchJournals } from "@/features/journals/api";
import { fetchTrialBalance } from "@/features/assets/api";
import { fetchReadiness } from "@/features/cutover/api";
/**
* The Finance landing page.
*
* It used to list what the signed-in user MAY do — right for a shell with no
* ledger behind it, wrong now that there is one. Each tile below answers a
* question someone actually opens this app to ask, and every one is gated by
* the same permission as the screen it summarises, so a reader is never shown
* a figure they could not go and check.
*/
/** Today as `YYYY-MM-DD` in LOCAL time. `toISOString()` would shift the date
* backwards east of UTC and silently ask for the wrong period. */
const todayIso = () => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
};
function Tile({
label,
value,
detail,
icon,
loading,
tone = "default",
testId,
}: {
label: string;
value: ReactNode;
detail?: ReactNode;
icon: ReactNode;
loading?: boolean;
tone?: "default" | "good" | "warn";
testId: string;
}) {
return (
<Card withBorder padding="lg" data-testid={testId}>
<Stack gap={6}>
<Group gap={8} c="dimmed">
{icon}
<Text size="sm">{label}</Text>
</Group>
{loading ? (
<Skeleton height={28} width="60%" />
) : (
<Text
fw={700}
fz="xl"
c={tone === "good" ? "teal" : tone === "warn" ? "orange" : undefined}
>
{value}
</Text>
)}
{detail ? (
<Text size="xs" c="dimmed">
{detail}
</Text>
) : null}
</Stack>
</Card>
);
}
export function DashboardPage() {
const { user, can } = useAuth();
const name = localized(user?.name, "en") || user?.username || "";
const canPeriods = can(FINANCE_PERMS.period.view);
const canJournals = can(FINANCE_PERMS.journal.view);
const canReports = can(FINANCE_PERMS.report.view);
const years = useQuery({
queryKey: ["dashboard", "fiscal-years"],
queryFn: fetchFiscalYears,
enabled: canPeriods,
retry: false,
});
const openYear =
years.data?.find((year) => year.status === "OPEN") ?? years.data?.[0];
const periods = useQuery({
queryKey: ["dashboard", "fiscal-periods", openYear?.id],
queryFn: () => fetchFiscalPeriods(openYear!.id),
enabled: canPeriods && Boolean(openYear?.id),
retry: false,
});
const today = todayIso();
const currentPeriod = periods.data?.find(
(period) => period.startDate <= today && today <= period.endDate,
);
const drafts = useQuery({
queryKey: ["dashboard", "draft-journals"],
// `total`, not the page length — the point is how many exist, not how many
// fit on a page.
queryFn: () => fetchJournals({ status: "DRAFT", limit: 1 }),
enabled: canJournals,
retry: false,
});
const trialBalance = useQuery({
queryKey: ["dashboard", "trial-balance", openYear?.startDate, today],
queryFn: () => fetchTrialBalance(openYear!.startDate, today),
enabled: canReports && Boolean(openYear?.startDate),
retry: false,
});
const readiness = useQuery({
queryKey: ["dashboard", "readiness"],
queryFn: fetchReadiness,
enabled: canPeriods,
retry: false,
});
const tiles: ReactNode[] = [];
if (canPeriods) {
tiles.push(
<Tile
key="period"
testId="tile-current-period"
label="Current period"
icon={<IconCalendarStats size={16} stroke={1.7} />}
loading={years.isLoading || periods.isLoading}
value={
currentPeriod
? localized(currentPeriod.name, "en") ||
`Period ${currentPeriod.periodNumber}`
: "None open"
}
detail={
currentPeriod
? `${currentPeriod.status} · ${currentPeriod.startDate}${currentPeriod.endDate}`
: openYear
? `No period in ${openYear.code} covers today`
: "No fiscal year is open"
}
/>,
);
}
if (canJournals) {
const count = drafts.data?.total ?? 0;
tiles.push(
<Tile
key="drafts"
testId="tile-draft-journals"
label="Unposted drafts"
icon={<IconFileText size={16} stroke={1.7} />}
loading={drafts.isLoading}
value={count}
tone={count > 0 ? "warn" : "default"}
detail={
count > 0
? "Draft entries do not affect any balance until posted."
: "Every entry is posted."
}
/>,
);
}
if (canReports) {
const balanced = trialBalance.data?.balanced;
tiles.push(
<Tile
key="tb"
testId="tile-trial-balance"
label="Trial balance"
icon={
balanced === false ? (
<IconAlertTriangle size={16} stroke={1.7} />
) : (
<IconCheck size={16} stroke={1.7} />
)
}
loading={years.isLoading || trialBalance.isLoading}
value={
balanced === undefined
? "—"
: balanced
? "Balanced"
: "Out of balance"
}
tone={balanced === false ? "warn" : balanced ? "good" : "default"}
detail={
trialBalance.data
? balanced
? `Debits equal credits, year to ${today}`
: `Off by ${trialBalance.data.difference}`
: undefined
}
/>,
);
}
if (canPeriods) {
const cutoverDate = readiness.data?.cutoverDate;
tiles.push(
<Tile
key="golive"
testId="tile-go-live"
label="Go-live date"
icon={<IconRocket size={16} stroke={1.7} />}
loading={readiness.isLoading}
value={cutoverDate ?? "Not set"}
detail={
readiness.data
? readiness.data.ready
? "All readiness checks pass."
: `${readiness.data.checks.filter((c) => c.status !== "PASS").length} readiness check(s) outstanding`
: undefined
}
/>,
);
}
return (
<>
<PageHeader
title={name ? `Welcome, ${name}` : "Finance"}
description="General ledger, receivables, payables, budgets and assets for EDR."
/>
{tiles.length === 0 ? (
<Alert color="yellow" title="No Finance permissions">
Your account is signed in but holds no Finance permission. Ask an
administrator for a Finance role, then sign out and back in
permissions are snapshotted at login.
</Alert>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
{tiles}
</SimpleGrid>
)}
</>
);
}

View File

@@ -0,0 +1,335 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
Title,
Tooltip,
} from "@mantine/core";
import { IconArrowBackUp, IconInfoCircle, IconTrash } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
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 { discardJournal, fetchJournal, postJournal, reverseJournal } from "./api";
import { STATUS_COLOR } from "./types";
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
{label}
</Text>
<Text size="sm">{value ?? "—"}</Text>
</Stack>
);
}
export function JournalDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { can } = useAuth();
const [reverseOpen, setReverseOpen] = useState(false);
const [reason, setReason] = useState("");
const [actionError, setActionError] = useState<unknown>(null);
const entry = useQuery({
queryKey: ["journals", id],
queryFn: () => fetchJournal(id),
enabled: Boolean(id),
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["journals"] });
};
const post = useMutation({
mutationFn: () => postJournal(id),
onMutate: () => setActionError(null),
onSuccess: invalidate,
onError: setActionError,
});
const reverse = useMutation({
mutationFn: () => reverseJournal(id, { reason }),
onMutate: () => setActionError(null),
onSuccess: (created) => {
invalidate();
setReverseOpen(false);
setReason("");
navigate(`/journals/${created.id}`);
},
onError: setActionError,
});
const discard = useMutation({
mutationFn: () => discardJournal(id),
onMutate: () => setActionError(null),
onSuccess: () => {
invalidate();
navigate("/journals");
},
onError: setActionError,
});
if (entry.isLoading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
if (entry.error || !entry.data) {
return <ApiErrorAlert error={entry.error} title="Could not load the entry" />;
}
const data = entry.data;
const isDraft = data.status === "DRAFT";
const isPosted = data.status === "POSTED";
return (
<>
<PageHeader
title={data.entryNumber}
description={data.memo}
actions={
<Group gap="sm">
<Button variant="default" onClick={() => navigate("/journals")}>
Back
</Button>
{isDraft && can(FINANCE_PERMS.journal.create) && (
<Button
variant="default"
color="red"
leftSection={<IconTrash size={16} />}
loading={discard.isPending}
onClick={() => discard.mutate()}
>
Discard
</Button>
)}
{/* Posting is a separate permission from preparing — a segregation
of duty, so the reason for a missing button is spelled out
rather than left as an absence. */}
{isDraft &&
(can(FINANCE_PERMS.journal.post) ? (
<Button loading={post.isPending} onClick={() => post.mutate()}>
Post to ledger
</Button>
) : (
<Tooltip label="Posting is a separate approval from preparing. Ask someone who holds it.">
<Button disabled>Post to ledger</Button>
</Tooltip>
))}
{isPosted && can(FINANCE_PERMS.journal.reverse) && (
<Button
variant="light"
color="orange"
leftSection={<IconArrowBackUp size={16} />}
onClick={() => setReverseOpen(true)}
>
Reverse
</Button>
)}
</Group>
}
/>
<ApiErrorAlert error={actionError} title="The ledger refused that" />
{data.status === "REVERSED" && (
<Alert
icon={<IconInfoCircle size={18} />}
color="orange"
mb="md"
title="This entry has been reversed"
>
Its effect has been undone by a mirrored entry. Both remain in the
ledger that is the audit trail.{" "}
{data.reversedByEntryId && (
<Text
component="span"
td="underline"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/journals/${data.reversedByEntryId}`)}
>
Open the reversal
</Text>
)}
</Alert>
)}
{data.reversesEntryId && (
<Alert icon={<IconInfoCircle size={18} />} color="blue" mb="md">
This is a reversing entry.{" "}
<Text
component="span"
td="underline"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/journals/${data.reversesEntryId}`)}
>
Open the entry it reverses
</Text>
{data.reversalReason ? ` — reason: ${data.reversalReason}` : ""}
</Alert>
)}
{isPosted && (
<Alert color="green" mb="md" variant="light">
Posted to the ledger. It can no longer be edited or deleted a
correction is a reversing entry.
</Alert>
)}
<Card withBorder mb="md" padding="lg">
<SimpleGrid cols={{ base: 2, sm: 3, md: 5 }} spacing="lg">
<Field
label="Status"
value={
<Badge size="sm" variant="light" color={STATUS_COLOR[data.status]}>
{data.status}
</Badge>
}
/>
<Field label="Entry date" value={data.entryDate} />
<Field label="Type" value={data.journalType} />
<Field label="Reference" value={data.reference} />
<Field label="Currency" value={data.currency} />
<Field
label="Posted at"
value={data.postedAt ? new Date(data.postedAt).toLocaleString() : "—"}
/>
<Field
label="Source"
value={
data.sourceModule ? `${data.sourceModule}:${data.sourceId}` : "Manual"
}
/>
</SimpleGrid>
</Card>
<Title order={4} mb="xs">
Lines
</Title>
<Table.ScrollContainer minWidth={760}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={60}>#</Table.Th>
<Table.Th w={120}>Account</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th w={150} ta="right">
Debit
</Table.Th>
<Table.Th w={150} ta="right">
Credit
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.lines.map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.lineNumber}</Table.Td>
<Table.Td>
<Text ff="monospace" size="sm">
{line.accountCode}
</Text>
</Table.Td>
<Table.Td>{line.accountName?.en}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{line.description ?? "—"}
</Text>
</Table.Td>
{/* A ledger shows the amount in one column and leaves the other
blank — never a negative number in both. */}
<Table.Td ta="right">
<Text ff="monospace">
{Number(line.debit) > 0 ? formatMoney(line.debit) : ""}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">
{Number(line.credit) > 0 ? formatMoney(line.credit) : ""}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={4}>
<Text fw={600}>Total</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" fw={700}>
{formatMoney(data.totalDebit)}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" fw={700}>
{formatMoney(data.totalCredit)}
</Text>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
</Table.ScrollContainer>
<Modal
opened={reverseOpen}
onClose={() => setReverseOpen(false)}
title={`Reverse ${data.entryNumber}`}
>
<Stack>
<Text size="sm" c="dimmed">
This writes a NEW entry with the debits and credits swapped, dated
today. The original stays in the ledger exactly as it is that pair
is the audit trail.
</Text>
<Textarea
label="Reason"
description="Recorded permanently on both entries."
required
autosize
minRows={3}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setReverseOpen(false)}>
Cancel
</Button>
<Button
color="orange"
disabled={!reason.trim()}
loading={reverse.isPending}
onClick={() => reverse.mutate()}
>
Reverse
</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@@ -0,0 +1,184 @@
import { useState } from "react";
import {
Badge,
Button,
Group,
Loader,
Pagination,
Select,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
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 { fetchJournals } from "./api";
import {
JOURNAL_STATUSES,
JOURNAL_TYPES,
STATUS_COLOR,
type JournalStatus,
type JournalType,
} from "./types";
export function JournalsPage() {
const navigate = useNavigate();
const { can } = useAuth();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<JournalStatus | null>(null);
const [journalType, setJournalType] = useState<JournalType | null>(null);
const journals = useQuery({
queryKey: ["journals", { page, search, status, journalType }],
queryFn: () =>
fetchJournals({
page,
limit: 25,
search: search.trim() || undefined,
status: status ?? undefined,
journalType: journalType ?? undefined,
}),
});
const items = journals.data?.items ?? [];
return (
<>
<PageHeader
title="Journals"
description="Every entry in the ledger. A posted entry can never be edited — corrections are reversals."
actions={
can(FINANCE_PERMS.journal.create) && (
<Button
leftSection={<IconPlus size={16} />}
onClick={() => navigate("/journals/new")}
>
New entry
</Button>
)
}
/>
<ApiErrorAlert error={journals.error} title="Could not load journals" />
<Group mb="md" gap="sm">
<TextInput
placeholder="Search number, memo or reference"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
w={300}
/>
<Select
placeholder="All statuses"
clearable
data={JOURNAL_STATUSES.map((s) => ({ value: s, label: s }))}
value={status}
onChange={(value) => {
setStatus((value as JournalStatus) ?? null);
setPage(1);
}}
w={160}
/>
<Select
placeholder="All types"
clearable
data={JOURNAL_TYPES.map((t) => ({ value: t, label: t }))}
value={journalType}
onChange={(value) => {
setJournalType((value as JournalType) ?? null);
setPage(1);
}}
w={190}
/>
</Group>
{journals.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : items.length === 0 ? (
<Text c="dimmed">
No entries yet. The ledger is empty until something is posted into it.
</Text>
) : (
<Stack>
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={160}>Number</Table.Th>
<Table.Th w={120}>Date</Table.Th>
<Table.Th>Memo</Table.Th>
<Table.Th w={140}>Type</Table.Th>
<Table.Th w={120}>Status</Table.Th>
<Table.Th w={140} ta="right">
Amount
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((entry) => (
<Table.Tr
key={entry.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/journals/${entry.id}`)}
>
<Table.Td>
<Text ff="monospace" size="sm">
{entry.entryNumber}
</Text>
</Table.Td>
<Table.Td>{entry.entryDate}</Table.Td>
<Table.Td>
<Text lineClamp={1}>{entry.memo}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{entry.journalType}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[entry.status]}
>
{entry.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">{formatMoney(entry.totalDebit)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{(journals.data?.pageCount ?? 1) > 1 && (
<Group justify="center">
<Pagination
total={journals.data?.pageCount ?? 1}
value={page}
onChange={setPage}
/>
</Group>
)}
</Stack>
)}
</>
);
}

View File

@@ -0,0 +1,308 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Alert,
Button,
Card,
Group,
NumberInput,
Select,
SimpleGrid,
Table,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { PageHeader } from "@/shared/components/PageHeader";
import { ApiErrorAlert } from "@/shared/components/ApiErrorAlert";
import { formatMoney } from "@/shared/lib/formatMoney";
import { fetchAccounts } from "../accounts/api";
import { createJournal, type CreateJournalPayload } from "./api";
import { JOURNAL_TYPES, type JournalType } from "./types";
type DraftLine = {
key: string;
accountId: string | null;
debit: number | "";
credit: number | "";
description: string;
};
const emptyLine = (): DraftLine => ({
key: Math.random().toString(36).slice(2),
accountId: null,
debit: "",
credit: "",
description: "",
});
/** Rounds to the cent for display and comparison, matching the server. */
const round2 = (value: number) => Math.round((value + Number.EPSILON) * 100) / 100;
const today = () => new Date().toISOString().slice(0, 10);
export function NewJournalPage() {
const navigate = useNavigate();
const [entryDate, setEntryDate] = useState(today());
const [memo, setMemo] = useState("");
const [reference, setReference] = useState("");
const [journalType, setJournalType] = useState<JournalType>("GENERAL");
const [lines, setLines] = useState<DraftLine[]>([emptyLine(), emptyLine()]);
// Only postable accounts are offered. A group account would be a guaranteed
// rejection, so it is never presented as a choice in the first place.
const accounts = useQuery({
queryKey: ["accounts", "postable"],
queryFn: () => fetchAccounts({ isActive: true }),
select: (all) =>
all
.filter((account) => !account.isGroup && account.isActive)
.map((account) => ({
value: account.id,
label: `${account.code}${account.name.en}`,
})),
});
const totals = useMemo(() => {
const debit = round2(
lines.reduce((sum, line) => sum + (Number(line.debit) || 0), 0),
);
const credit = round2(
lines.reduce((sum, line) => sum + (Number(line.credit) || 0), 0),
);
return { debit, credit, difference: round2(debit - credit) };
}, [lines]);
const balanced = totals.difference === 0 && totals.debit > 0;
const create = useMutation({
mutationFn: (payload: CreateJournalPayload) => createJournal(payload),
onSuccess: (entry) => navigate(`/journals/${entry.id}`),
});
const update = (key: string, patch: Partial<DraftLine>) =>
setLines((current) =>
current.map((line) => (line.key === key ? { ...line, ...patch } : line)),
);
const submit = () => {
create.mutate({
entryDate,
memo: memo.trim(),
journalType,
reference: reference.trim() || undefined,
lines: lines
.filter((line) => line.accountId)
.map((line) => ({
accountId: line.accountId as string,
debit: Number(line.debit) || 0,
credit: Number(line.credit) || 0,
description: line.description.trim() || undefined,
})),
});
};
const usableLines = lines.filter(
(line) => line.accountId && (Number(line.debit) || Number(line.credit)),
).length;
return (
<>
<PageHeader
title="New journal entry"
description="Saved as a draft. Posting it to the ledger is a separate, permanent step."
actions={
<Group gap="sm">
<Button variant="default" onClick={() => navigate("/journals")}>
Cancel
</Button>
<Button
disabled={!balanced || !memo.trim() || usableLines < 2}
loading={create.isPending}
onClick={submit}
>
Save draft
</Button>
</Group>
}
/>
<ApiErrorAlert error={create.error} title="The ledger refused that" />
<Card withBorder mb="md" padding="lg">
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
<TextInput
label="Entry date"
type="date"
description="Decides which period it lands in"
required
value={entryDate}
onChange={(event) => setEntryDate(event.currentTarget.value)}
/>
<Select
label="Type"
data={JOURNAL_TYPES.filter((t) => t !== "REVERSAL").map((t) => ({
value: t,
label: t,
}))}
value={journalType}
onChange={(value) => setJournalType((value as JournalType) ?? "GENERAL")}
allowDeselect={false}
/>
<TextInput
label="Reference"
placeholder="Invoice or receipt number"
value={reference}
onChange={(event) => setReference(event.currentTarget.value)}
/>
</SimpleGrid>
<Textarea
mt="md"
label="Memo"
description="What this entry records — it is the explanation an auditor reads"
required
autosize
minRows={2}
value={memo}
onChange={(event) => setMemo(event.currentTarget.value)}
/>
</Card>
<Table.ScrollContainer minWidth={900}>
<Table withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th w={170}>Debit</Table.Th>
<Table.Th w={170}>Credit</Table.Th>
<Table.Th w={50} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((line) => (
<Table.Tr key={line.key}>
<Table.Td>
<Select
placeholder="Select an account"
searchable
data={accounts.data ?? []}
value={line.accountId}
onChange={(value) => update(line.key, { accountId: value })}
/>
</Table.Td>
<Table.Td>
<TextInput
placeholder="Optional"
value={line.description}
onChange={(event) =>
update(line.key, { description: event.currentTarget.value })
}
/>
</Table.Td>
{/* Typing in one side clears the other: a line is a debit or a
credit, never both, so the form makes the invalid state
unreachable rather than reporting it afterwards. */}
<Table.Td>
<NumberInput
placeholder="0.00"
min={0}
decimalScale={2}
thousandSeparator=","
value={line.debit}
onChange={(value) =>
update(line.key, {
debit: value === "" ? "" : Number(value),
credit: value === "" || Number(value) === 0 ? line.credit : "",
})
}
/>
</Table.Td>
<Table.Td>
<NumberInput
placeholder="0.00"
min={0}
decimalScale={2}
thousandSeparator=","
value={line.credit}
onChange={(value) =>
update(line.key, {
credit: value === "" ? "" : Number(value),
debit: value === "" || Number(value) === 0 ? line.debit : "",
})
}
/>
</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
disabled={lines.length <= 2}
onClick={() =>
setLines((current) =>
current.filter((l) => l.key !== line.key),
)
}
aria-label="Remove line"
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={2}>
<Text fw={600}>Total</Text>
</Table.Td>
<Table.Td>
<Text ff="monospace" fw={700}>
{formatMoney(totals.debit)}
</Text>
</Table.Td>
<Table.Td>
<Text ff="monospace" fw={700}>
{formatMoney(totals.credit)}
</Text>
</Table.Td>
<Table.Td />
</Table.Tr>
</Table.Tfoot>
</Table>
</Table.ScrollContainer>
<Group mt="md" justify="space-between">
<Button
variant="default"
leftSection={<IconPlus size={16} />}
onClick={() => setLines((current) => [...current, emptyLine()])}
>
Add line
</Button>
{/* The balance is shown live, with the exact shortfall, so the user
fixes it here rather than discovering it from a server rejection. */}
{totals.debit === 0 && totals.credit === 0 ? (
<Text size="sm" c="dimmed">
Enter the debits and credits.
</Text>
) : balanced ? (
<Alert color="green" variant="light" py={6}>
Balanced {formatMoney(totals.debit)} on each side
</Alert>
) : (
<Alert color="red" variant="light" py={6}>
Out of balance by {formatMoney(Math.abs(totals.difference))} {" "}
{totals.difference > 0 ? "credits" : "debits"} are short
</Alert>
)}
</Group>
</>
);
}

View File

@@ -0,0 +1,80 @@
import { financeApi } from "@/auth/http";
import type {
JournalEntryDetail,
JournalPage,
JournalStatus,
JournalType,
} from "./types";
export type JournalFilters = {
page?: number;
limit?: number;
search?: string;
status?: JournalStatus;
journalType?: JournalType;
accountId?: string;
fiscalPeriodId?: string;
dateFrom?: string;
dateTo?: string;
};
export const fetchJournals = async (
filters: JournalFilters = {},
): Promise<JournalPage> => {
const { data } = await financeApi.get<JournalPage>("/journals", {
params: filters,
});
return data;
};
export const fetchJournal = async (id: string): Promise<JournalEntryDetail> => {
const { data } = await financeApi.get<JournalEntryDetail>(`/journals/${id}`);
return data;
};
export type JournalLinePayload = {
accountId: string;
debit?: number;
credit?: number;
description?: string;
};
export type CreateJournalPayload = {
entryDate: string;
memo: string;
journalType?: JournalType;
reference?: string;
lines: JournalLinePayload[];
};
export const createJournal = async (
payload: CreateJournalPayload,
): Promise<JournalEntryDetail> => {
const { data } = await financeApi.post<JournalEntryDetail>(
"/journals",
payload,
);
return data;
};
export const postJournal = async (id: string): Promise<JournalEntryDetail> => {
const { data } = await financeApi.post<JournalEntryDetail>(
`/journals/${id}/post`,
);
return data;
};
export const reverseJournal = async (
id: string,
payload: { reason: string; reversalDate?: string },
): Promise<JournalEntryDetail> => {
const { data } = await financeApi.post<JournalEntryDetail>(
`/journals/${id}/reverse`,
payload,
);
return data;
};
export const discardJournal = async (id: string): Promise<void> => {
await financeApi.delete(`/journals/${id}`);
};

View File

@@ -0,0 +1,63 @@
import type { LocalizedName, Paginated } from "@/shared/types";
export const JOURNAL_STATUSES = ["DRAFT", "POSTED", "REVERSED"] as const;
export type JournalStatus = (typeof JOURNAL_STATUSES)[number];
export const JOURNAL_TYPES = [
"GENERAL",
"SALES",
"PURCHASE",
"CASH_RECEIPT",
"CASH_PAYMENT",
"PAYROLL",
"DEPRECIATION",
"OPENING",
"REVERSAL",
] as const;
export type JournalType = (typeof JOURNAL_TYPES)[number];
export type JournalLine = {
id: string;
journalEntryId: string;
lineNumber: number;
accountId: string;
debit: number | string;
credit: number | string;
description: string | null;
costCenterId: string | null;
accountCode: string;
accountName: LocalizedName;
};
export type JournalEntry = {
id: string;
organizationId: string;
entryNumber: string;
entryDate: string;
fiscalPeriodId: string;
journalType: JournalType;
status: JournalStatus;
memo: string;
reference: string | null;
sourceModule: string | null;
sourceId: string | null;
totalDebit: number | string;
totalCredit: number | string;
currency: string;
preparedBy: string | null;
postedBy: string | null;
postedAt: string | null;
reversedByEntryId: string | null;
reversesEntryId: string | null;
reversalReason: string | null;
};
export type JournalEntryDetail = JournalEntry & { lines: JournalLine[] };
export type JournalPage = Paginated<JournalEntry>;
export const STATUS_COLOR: Record<JournalStatus, string> = {
DRAFT: "gray",
POSTED: "green",
REVERSED: "orange",
};

View File

@@ -0,0 +1,774 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from "@mantine/core";
import { 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 {
BILL_STATUS_COLOR,
approveBill,
createBill,
createSupplier,
fetchBill,
fetchBills,
fetchPayablesAging,
fetchSuppliers,
recordPayment,
type BillDetail,
} from "./api";
const today = () => new Date().toISOString().slice(0, 10);
export function PayablesPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const [billOpen, setBillOpen] = useState(false);
const [supplierOpen, setSupplierOpen] = useState(false);
const [openBillId, setOpenBillId] = useState<string | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const bills = useQuery({ queryKey: ["payables", "bills"], queryFn: fetchBills });
const suppliers = useQuery({
queryKey: ["payables", "suppliers"],
queryFn: () => fetchSuppliers(),
});
const aging = useQuery({
queryKey: ["payables", "aging"],
queryFn: () => fetchPayablesAging(today()),
});
const invalidate = () =>
void queryClient.invalidateQueries({ queryKey: ["payables"] });
return (
<>
<PageHeader
title="Payables"
description="Supplier bills, payments and aging. Entering a bill and authorising it are separate permissions."
actions={
<Group gap="sm">
{can(FINANCE_PERMS.payable.manageSupplier) && (
<Button variant="default" onClick={() => setSupplierOpen(true)}>
New supplier
</Button>
)}
{can(FINANCE_PERMS.payable.manageBill) && (
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setBillOpen(true)}
>
New bill
</Button>
)}
</Group>
}
/>
<ApiErrorAlert error={actionError} title="That action was refused" />
<Tabs defaultValue="bills">
<Tabs.List mb="md">
<Tabs.Tab value="bills">Bills ({(bills.data ?? []).length})</Tabs.Tab>
<Tabs.Tab value="suppliers">
Suppliers ({(suppliers.data ?? []).length})
</Tabs.Tab>
<Tabs.Tab value="aging">Aging</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="bills">
{bills.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (bills.data ?? []).length === 0 ? (
<Text c="dimmed">No bills yet.</Text>
) : (
<Table.ScrollContainer minWidth={950}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={160}>Bill</Table.Th>
<Table.Th>Supplier</Table.Th>
<Table.Th w={120}>Date</Table.Th>
<Table.Th w={120}>Due</Table.Th>
<Table.Th w={140}>Status</Table.Th>
<Table.Th w={140} ta="right">Total</Table.Th>
<Table.Th w={140} ta="right">Outstanding</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(bills.data ?? []).map((b) => {
const outstanding =
Number(b.totalAmount) - Number(b.paidAmount);
return (
<Table.Tr
key={b.id}
style={{ cursor: "pointer" }}
onClick={() => setOpenBillId(b.id)}
>
<Table.Td>
<Text ff="monospace" size="sm">{b.billNumber}</Text>
{b.supplierInvoiceNumber && (
<Text size="xs" c="dimmed">
inv {b.supplierInvoiceNumber}
</Text>
)}
</Table.Td>
<Table.Td>{b.supplierName}</Table.Td>
<Table.Td>{b.billDate}</Table.Td>
<Table.Td>{b.dueDate ?? "—"}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={BILL_STATUS_COLOR[b.status]}
>
{b.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">{formatMoney(b.totalAmount)}</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" c={outstanding > 0 ? undefined : "dimmed"}>
{formatMoney(outstanding)}
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Tabs.Panel>
<Tabs.Panel value="suppliers">
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={140}>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th w={160}>TIN</Table.Th>
<Table.Th w={200}>Bank</Table.Th>
<Table.Th w={110}>Active</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(suppliers.data ?? []).map((s) => (
<Table.Tr key={s.id}>
<Table.Td>
<Text ff="monospace" size="sm">{s.code}</Text>
</Table.Td>
<Table.Td>{s.name}</Table.Td>
<Table.Td>{s.tin ?? "—"}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{[s.bankName, s.bankAccount].filter(Boolean).join(" · ") || "—"}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={s.isActive ? "green" : "red"}>
{s.isActive ? "active" : "inactive"}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(suppliers.data ?? []).length === 0 && (
<Text c="dimmed">No suppliers yet.</Text>
)}
</Tabs.Panel>
<Tabs.Panel value="aging">
{(aging.data ?? []).length === 0 ? (
<Text c="dimmed">Nothing outstanding.</Text>
) : (
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Bucket</Table.Th>
<Table.Th w={110}>Currency</Table.Th>
<Table.Th w={120} ta="right">Bills</Table.Th>
<Table.Th w={180} ta="right">Outstanding</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(aging.data ?? []).map((r) => (
<Table.Tr key={`${r.bucket}-${r.currency}`}>
<Table.Td>{r.bucket}</Table.Td>
<Table.Td>{r.currency}</Table.Td>
<Table.Td ta="right">{r.documentCount}</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">{formatMoney(r.amount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Tabs.Panel>
</Tabs>
<BillDrawer
billId={openBillId}
onClose={() => setOpenBillId(null)}
onChanged={invalidate}
onError={setActionError}
/>
<NewBillModal
opened={billOpen}
onClose={() => setBillOpen(false)}
onCreated={() => {
setBillOpen(false);
invalidate();
}}
/>
<NewSupplierModal
opened={supplierOpen}
onClose={() => setSupplierOpen(false)}
onCreated={() => {
setSupplierOpen(false);
invalidate();
}}
/>
</>
);
}
function BillDrawer({
billId,
onClose,
onChanged,
onError,
}: {
billId: string | null;
onClose: () => void;
onChanged: () => void;
onError: (e: unknown) => void;
}) {
const { can } = useAuth();
const [payOpen, setPayOpen] = useState(false);
const bill = useQuery({
queryKey: ["payables", "bill", billId],
queryFn: () => fetchBill(billId as string),
enabled: Boolean(billId),
});
const approve = useMutation({
mutationFn: () => approveBill(billId as string),
onSuccess: () => {
onChanged();
void bill.refetch();
},
onError,
});
const data = bill.data as BillDetail | undefined;
const outstanding = data
? Number(data.totalAmount) - Number(data.paidAmount)
: 0;
return (
<Modal opened={Boolean(billId)} onClose={onClose} size="xl" title={data?.billNumber ?? "Bill"}>
{bill.isLoading || !data ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack>
<Group>
<Badge variant="light" color={BILL_STATUS_COLOR[data.status]}>
{data.status}
</Badge>
<Text size="sm" c="dimmed">
{data.supplier?.name} · {data.billDate}
{data.dueDate ? ` · due ${data.dueDate}` : ""}
</Text>
</Group>
{data.status === "DRAFT" && (
<Alert color="gray" variant="light">
This bill is a draft nothing has reached the ledger yet.
</Alert>
)}
<Table withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={90}>Account</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th w={150} ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.lines.map((l) => (
<Table.Tr key={l.id}>
<Table.Td><Text ff="monospace" size="sm">{l.accountCode}</Text></Table.Td>
<Table.Td>{l.description ?? l.accountName?.en}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(l.amount)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={2}><Text size="sm">Subtotal</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(data.subtotal)}</Text></Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td colSpan={2}><Text size="sm">VAT</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(data.taxAmount)}</Text></Table.Td>
</Table.Tr>
{Number(data.withholdingAmount) > 0 && (
<Table.Tr>
<Table.Td colSpan={2}>
<Tooltip label="Withheld from the supplier and owed to the revenue authority instead">
<Text size="sm">Withholding</Text>
</Tooltip>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace" c="orange">
{formatMoney(data.withholdingAmount)}
</Text>
</Table.Td>
</Table.Tr>
)}
<Table.Tr>
<Table.Td colSpan={2}><Text fw={700}>Total</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={700}>{formatMoney(data.totalAmount)}</Text></Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td colSpan={2}><Text size="sm">Paid</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(data.paidAmount)}</Text></Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
{data.payments.length > 0 && (
<>
<Title order={6}>Payments</Title>
<Table withTableBorder>
<Table.Tbody>
{data.payments.map((p) => (
<Table.Tr key={p.id}>
<Table.Td w={140}><Text ff="monospace" size="sm">{p.paymentNumber}</Text></Table.Td>
<Table.Td w={120}>{p.paymentDate}</Table.Td>
<Table.Td w={100}>{p.method}</Table.Td>
<Table.Td>{p.reference ?? "—"}</Table.Td>
<Table.Td ta="right" w={140}>
<Text ff="monospace">{formatMoney(p.amount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</>
)}
<Group justify="flex-end">
{data.status === "DRAFT" &&
(can(FINANCE_PERMS.payable.approveBill) ? (
<Button loading={approve.isPending} onClick={() => approve.mutate()}>
Approve &amp; post
</Button>
) : (
<Tooltip label="Approving is a separate authorisation from entering the bill. Ask someone who holds it.">
<Button disabled>Approve &amp; post</Button>
</Tooltip>
))}
{outstanding > 0 &&
data.status !== "DRAFT" &&
can(FINANCE_PERMS.payable.recordPayment) && (
<Button onClick={() => setPayOpen(true)}>Record payment</Button>
)}
</Group>
<PaymentModal
opened={payOpen}
billId={data.id}
outstanding={outstanding}
onClose={() => setPayOpen(false)}
onPaid={() => {
setPayOpen(false);
onChanged();
void bill.refetch();
}}
/>
</Stack>
)}
</Modal>
);
}
function PaymentModal({
opened,
billId,
outstanding,
onClose,
onPaid,
}: {
opened: boolean;
billId: string;
outstanding: number;
onClose: () => void;
onPaid: () => void;
}) {
const [amount, setAmount] = useState<number | "">(outstanding);
const [paymentDate, setPaymentDate] = useState(today());
const [method, setMethod] = useState("BANK");
const [accountId, setAccountId] = useState<string | null>(null);
const [reference, setReference] = useState("");
// Only cash/bank accounts — the server rejects anything else.
const cashAccounts = useQuery({
queryKey: ["accounts", "cash"],
queryFn: () => fetchAccounts({ isActive: true }),
select: (all) =>
all
.filter((a) => a.accountType === "ASSET" && !a.isGroup && a.code.startsWith("111"))
.map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` })),
});
const pay = useMutation({
mutationFn: () =>
recordPayment(billId, {
paymentDate,
amount: Number(amount),
method,
paidFromAccountId: accountId as string,
reference: reference.trim() || undefined,
}),
onSuccess: onPaid,
});
return (
<Modal opened={opened} onClose={onClose} title="Record payment">
<Stack>
<ApiErrorAlert error={pay.error} title="Payment refused" />
<Text size="sm" c="dimmed">
Outstanding: {formatMoney(outstanding)}
</Text>
<NumberInput
label="Amount"
min={0.01}
max={outstanding}
decimalScale={2}
thousandSeparator=","
value={amount}
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Payment date"
type="date"
value={paymentDate}
onChange={(e) => setPaymentDate(e.currentTarget.value)}
/>
<Select
label="Method"
data={["BANK", "CASH", "CHEQUE", "MOBILE"]}
value={method}
onChange={(v) => setMethod(v ?? "BANK")}
allowDeselect={false}
/>
<Select
label="Paid from"
data={cashAccounts.data ?? []}
value={accountId}
onChange={setAccountId}
searchable
/>
<TextInput
label="Reference"
placeholder="Cheque or transfer number"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
loading={pay.isPending}
disabled={!accountId || !amount}
onClick={() => pay.mutate()}
>
Record
</Button>
</Group>
</Stack>
</Modal>
);
}
function NewSupplierModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [tin, setTin] = useState("");
const [bankName, setBankName] = useState("");
const [bankAccount, setBankAccount] = useState("");
const create = useMutation({
mutationFn: () =>
createSupplier({
code: code.trim(),
name: name.trim(),
tin: tin.trim() || undefined,
bankName: bankName.trim() || undefined,
bankAccount: bankAccount.trim() || undefined,
}),
onSuccess: () => {
setCode(""); setName(""); setTin(""); setBankName(""); setBankAccount("");
onCreated();
},
});
return (
<Modal opened={opened} onClose={onClose} title="New supplier">
<Stack>
<ApiErrorAlert error={create.error} title="Could not create the supplier" />
<TextInput label="Code" required value={code} onChange={(e) => setCode(e.currentTarget.value)} />
<TextInput label="Name" required value={name} onChange={(e) => setName(e.currentTarget.value)} />
<TextInput label="TIN" value={tin} onChange={(e) => setTin(e.currentTarget.value)} />
<Group grow>
<TextInput label="Bank" value={bankName} onChange={(e) => setBankName(e.currentTarget.value)} />
<TextInput label="Account" value={bankAccount} onChange={(e) => setBankAccount(e.currentTarget.value)} />
</Group>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button loading={create.isPending} disabled={!code.trim() || !name.trim()} onClick={() => create.mutate()}>
Create
</Button>
</Group>
</Stack>
</Modal>
);
}
function NewBillModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [supplierId, setSupplierId] = useState<string | null>(null);
const [invoiceNumber, setInvoiceNumber] = useState("");
const [billDate, setBillDate] = useState(today());
const [dueDate, setDueDate] = useState("");
const [taxAmount, setTaxAmount] = useState<number | "">("");
const [withholding, setWithholding] = useState<number | "">("");
const [description, setDescription] = useState("");
const [lines, setLines] = useState<
{ key: string; accountId: string | null; description: string; amount: number | "" }[]
>([{ key: "1", accountId: null, description: "", amount: "" }]);
const suppliers = useQuery({
queryKey: ["payables", "suppliers"],
queryFn: () => fetchSuppliers(),
select: (all) =>
all.filter((s) => s.isActive).map((s) => ({ value: s.id, label: `${s.code}${s.name}` })),
});
// Expense and asset accounts only — a bill line credits nothing.
const accounts = useQuery({
queryKey: ["accounts", "expense-postable"],
queryFn: () => fetchAccounts({ isActive: true }),
select: (all) =>
all
.filter((a) => !a.isGroup && a.isActive && ["EXPENSE", "ASSET"].includes(a.accountType))
.map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` })),
});
const subtotal = lines.reduce((s, l) => s + (Number(l.amount) || 0), 0);
const total = subtotal + (Number(taxAmount) || 0);
const create = useMutation({
mutationFn: () =>
createBill({
supplierId: supplierId as string,
supplierInvoiceNumber: invoiceNumber.trim() || undefined,
billDate,
dueDate: dueDate || undefined,
taxAmount: Number(taxAmount) || 0,
withholdingAmount: Number(withholding) || 0,
description: description.trim() || undefined,
lines: lines
.filter((l) => l.accountId && Number(l.amount) > 0)
.map((l) => ({
accountId: l.accountId as string,
description: l.description.trim() || undefined,
amount: Number(l.amount),
})),
}),
onSuccess: onCreated,
});
const usable = lines.filter((l) => l.accountId && Number(l.amount) > 0).length;
return (
<Modal opened={opened} onClose={onClose} title="New supplier bill" size="xl">
<Stack>
<ApiErrorAlert error={create.error} title="Could not create the bill" />
<Group grow>
<Select
label="Supplier"
required
searchable
data={suppliers.data ?? []}
value={supplierId}
onChange={setSupplierId}
/>
<TextInput
label="Supplier invoice number"
description="Entering the same one twice is refused"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.currentTarget.value)}
/>
</Group>
<Group grow>
<TextInput label="Bill date" type="date" value={billDate} onChange={(e) => setBillDate(e.currentTarget.value)} />
<TextInput label="Due date" type="date" value={dueDate} onChange={(e) => setDueDate(e.currentTarget.value)} />
</Group>
<Table withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th w={160}>Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((l) => (
<Table.Tr key={l.key}>
<Table.Td>
<Select
placeholder="Expense account"
searchable
data={accounts.data ?? []}
value={l.accountId}
onChange={(v) =>
setLines((c) => c.map((x) => (x.key === l.key ? { ...x, accountId: v } : x)))
}
/>
</Table.Td>
<Table.Td>
<TextInput
value={l.description}
onChange={(e) =>
setLines((c) =>
c.map((x) => (x.key === l.key ? { ...x, description: e.target.value } : x)),
)
}
/>
</Table.Td>
<Table.Td>
<NumberInput
min={0}
decimalScale={2}
thousandSeparator=","
value={l.amount}
onChange={(v) =>
setLines((c) =>
c.map((x) => (x.key === l.key ? { ...x, amount: v === "" ? "" : Number(v) } : x)),
)
}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group>
<Button
variant="default"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() =>
setLines((c) => [
...c,
{ key: Math.random().toString(36).slice(2), accountId: null, description: "", amount: "" },
])
}
>
Add line
</Button>
</Group>
<Group grow>
<NumberInput
label="VAT"
min={0}
decimalScale={2}
value={taxAmount}
onChange={(v) => setTaxAmount(v === "" ? "" : Number(v))}
/>
<NumberInput
label="Withholding"
description="Reduces what the supplier is paid, not the expense"
min={0}
decimalScale={2}
value={withholding}
onChange={(v) => setWithholding(v === "" ? "" : Number(v))}
/>
</Group>
<Textarea
label="Description"
autosize
minRows={2}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Group justify="space-between">
<Text size="sm" c="dimmed">
Subtotal {formatMoney(subtotal)} + VAT {formatMoney(Number(taxAmount) || 0)} ={" "}
<b>{formatMoney(total)}</b>
</Text>
<Group>
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
loading={create.isPending}
disabled={!supplierId || usable === 0}
onClick={() => create.mutate()}
>
Save draft
</Button>
</Group>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,449 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { IconInfoCircle } 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 {
fetchDisbursement,
fetchPayrollRuns,
fetchRemittances,
fetchStatutoryOutstanding,
postPayroll,
recordRemittance,
} from "./api";
const today = () => new Date().toISOString().slice(0, 10);
const STATUTORY_BY_CODE: Record<string, string> = {
"2121": "INCOME_TAX",
"2122": "PENSION",
"2123": "VAT",
"2124": "WITHHOLDING",
};
export function PayrollPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const [registerRunId, setRegisterRunId] = useState<string | null>(null);
const [remitFor, setRemitFor] = useState<{ code: string; owed: number } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const runs = useQuery({ queryKey: ["payables", "payroll"], queryFn: fetchPayrollRuns });
const outstanding = useQuery({
queryKey: ["payables", "statutory"],
queryFn: fetchStatutoryOutstanding,
});
const remittances = useQuery({
queryKey: ["payables", "remittances"],
queryFn: fetchRemittances,
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["payables"] });
void queryClient.invalidateQueries({ queryKey: ["journals"] });
};
const post = useMutation({
mutationFn: (runId: string) => postPayroll(runId),
onMutate: () => setActionError(null),
onSuccess: invalidate,
onError: setActionError,
});
return (
<>
<PageHeader
title="Payroll & statutory"
description="Post HR's approved payroll to the ledger, produce the disbursement list, and remit what is withheld."
/>
<ApiErrorAlert error={actionError} title="That action was refused" />
<Tabs defaultValue="runs">
<Tabs.List mb="md">
<Tabs.Tab value="runs">Payroll runs</Tabs.Tab>
<Tabs.Tab value="statutory">Statutory liabilities</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="runs">
<Alert icon={<IconInfoCircle size={18} />} color="blue" variant="light" mb="md">
<Text size="sm">
Payroll is calculated in HR and read from there Finance stores no
copy. Only <b>APPROVED</b> runs appear: a run that can still be
recalculated would leave the ledger describing a payroll that no
longer exists. The entry is dated the period end, because the cost
belongs to the month worked even when the money leaves later.
</Text>
</Alert>
{runs.isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (runs.data ?? []).length === 0 ? (
<Text c="dimmed">
No approved payroll runs. Either none exist yet, or HR's payroll
tables are not present in this database.
</Text>
) : (
<Table.ScrollContainer minWidth={980}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={200}>Period</Table.Th>
<Table.Th w={90} ta="right">Staff</Table.Th>
<Table.Th w={140} ta="right">Gross</Table.Th>
<Table.Th w={140} ta="right">Net</Table.Th>
<Table.Th w={130} ta="right">PAYE</Table.Th>
<Table.Th w={160}>Posted</Table.Th>
<Table.Th w={230} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(runs.data ?? []).map((r) => (
<Table.Tr key={r.id}>
<Table.Td>
<Text size="sm">{r.periodStart} → {r.periodEnd}</Text>
<Badge size="xs" variant="light">{r.status}</Badge>
</Table.Td>
<Table.Td ta="right">{r.employeeCount}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.totalGross)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.totalNet)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.totalIncomeTax)}</Text></Table.Td>
<Table.Td>
{r.entryNumber ? (
<Badge size="sm" variant="light" color="green">{r.entryNumber}</Badge>
) : (
<Text size="sm" c="dimmed">not posted</Text>
)}
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
<Button size="xs" variant="default" onClick={() => setRegisterRunId(r.id)}>
Disbursement list
</Button>
{!r.entryNumber &&
(can(FINANCE_PERMS.payable.postPayroll) ? (
<Button
size="xs"
loading={post.isPending && post.variables === r.id}
onClick={() => post.mutate(r.id)}
>
Post to GL
</Button>
) : (
<Tooltip label="Posting payroll is a separate permission">
<Button size="xs" disabled>Post to GL</Button>
</Tooltip>
))}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Tabs.Panel>
<Tabs.Panel value="statutory">
<Alert icon={<IconInfoCircle size={18} />} color="blue" variant="light" mb="md">
<Text size="sm">
These balances are read straight off the posted ledger, not from a
stored total — the ledger is the authority. Money withheld from
staff or suppliers is held on someone else's behalf, and a late
remittance carries a penalty a late supplier payment does not.
</Text>
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="lg">
{(outstanding.data ?? []).map((row) => {
const owed = Number(row.outstanding);
return (
<Card key={row.accountCode} withBorder padding="lg">
<Stack gap={4}>
<Text size="xs" c="dimmed" tt="uppercase">
{row.accountCode} {row.accountName?.en}
</Text>
<Text size="xl" fw={700} ff="monospace">
{formatMoney(owed)}
</Text>
{owed > 0 && can(FINANCE_PERMS.payable.manageStatutory) && (
<Button
size="xs"
mt="xs"
onClick={() => setRemitFor({ code: row.accountCode, owed })}
>
Remit
</Button>
)}
</Stack>
</Card>
);
})}
</SimpleGrid>
<Title order={5} mb="xs">Remittances made</Title>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={160}>Type</Table.Th>
<Table.Th w={110}>Period</Table.Th>
<Table.Th w={130}>Paid</Table.Th>
<Table.Th>Receipt</Table.Th>
<Table.Th w={160} ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(remittances.data ?? []).map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.statutoryType}</Table.Td>
<Table.Td>{r.period}</Table.Td>
<Table.Td>{r.paidDate}</Table.Td>
<Table.Td>{r.receiptNumber ?? r.reference ?? "—"}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.amount)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(remittances.data ?? []).length === 0 && (
<Text c="dimmed">Nothing remitted yet.</Text>
)}
</Tabs.Panel>
</Tabs>
<DisbursementModal
runId={registerRunId}
onClose={() => setRegisterRunId(null)}
/>
<RemitModal
target={remitFor}
onClose={() => setRemitFor(null)}
onDone={() => {
setRemitFor(null);
invalidate();
}}
/>
</>
);
}
function DisbursementModal({
runId,
onClose,
}: {
runId: string | null;
onClose: () => void;
}) {
const register = useQuery({
queryKey: ["payables", "disbursement", runId],
queryFn: () => fetchDisbursement(runId as string),
enabled: Boolean(runId),
});
const data = register.data;
/** CSV of the payment list — what a bank actually needs. */
const exportCsv = () => {
if (!data) return;
const header = "Employee number,Name,Mode,Bank account,Net pay";
const body = data.rows
.map((r) =>
[r.employeeNumber, r.employeeName, r.salaryMode, r.bankAccount ?? "", r.netPay]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(","),
)
.join("\n");
const blob = new Blob([`${header}\n${body}`], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `disbursement-${data.periodStart}-to-${data.periodEnd}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<Modal
opened={Boolean(runId)}
onClose={onClose}
size="xl"
title="Salary disbursement register"
>
{register.isLoading || !data ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack>
<Alert color="blue" variant="light">
<Text size="sm">
{data.periodStart} {data.periodEnd}
{data.paymentDate ? ` · paid ${data.paymentDate}` : ""} ·{" "}
<b>{formatMoney(data.total)}</b> across {data.rows.length} employee(s)
</Text>
</Alert>
<Group gap="sm">
{data.byMode.map((m) => (
<Badge key={m.salaryMode} size="lg" variant="light">
{m.salaryMode}: {m.count} · {formatMoney(m.total)}
</Badge>
))}
</Group>
<Table.ScrollContainer minWidth={700}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={140}>Number</Table.Th>
<Table.Th>Employee</Table.Th>
<Table.Th w={100}>Mode</Table.Th>
<Table.Th w={170}>Bank account</Table.Th>
<Table.Th w={150} ta="right">Net pay</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.rows.map((r) => (
<Table.Tr key={`${r.employeeNumber}-${r.employeeId}`}>
<Table.Td><Text ff="monospace" size="sm">{r.employeeNumber}</Text></Table.Td>
<Table.Td>{r.employeeName || "—"}</Table.Td>
<Table.Td>{r.salaryMode}</Table.Td>
<Table.Td>
<Text ff="monospace" size="sm">{r.bankAccount ?? "—"}</Text>
</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(r.netPay)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Close</Button>
<Button onClick={exportCsv}>Export CSV</Button>
</Group>
</Stack>
)}
</Modal>
);
}
function RemitModal({
target,
onClose,
onDone,
}: {
target: { code: string; owed: number } | null;
onClose: () => void;
onDone: () => void;
}) {
const [amount, setAmount] = useState<number | "">("");
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7));
const [paidDate, setPaidDate] = useState(today());
const [accountId, setAccountId] = useState<string | null>(null);
const [receiptNumber, setReceiptNumber] = useState("");
const cashAccounts = useQuery({
queryKey: ["accounts", "cash"],
queryFn: () => fetchAccounts({ isActive: true }),
select: (all) =>
all
.filter((a) => a.accountType === "ASSET" && !a.isGroup && a.code.startsWith("111"))
.map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` })),
});
const remit = useMutation({
mutationFn: () =>
recordRemittance({
statutoryType: STATUTORY_BY_CODE[target?.code ?? ""] ?? "INCOME_TAX",
period,
amount: Number(amount),
paidDate,
paidFromAccountId: accountId as string,
receiptNumber: receiptNumber.trim() || undefined,
}),
onSuccess: onDone,
});
return (
<Modal
opened={Boolean(target)}
onClose={onClose}
title={`Remit ${STATUTORY_BY_CODE[target?.code ?? ""] ?? ""}`}
>
<Stack>
<ApiErrorAlert error={remit.error} title="Remittance refused" />
<Text size="sm" c="dimmed">
The ledger shows {formatMoney(target?.owed ?? 0)} outstanding on{" "}
{target?.code}. Remitting more than that is refused it would mean the
liability was never posted, or the amount is wrong.
</Text>
<NumberInput
label="Amount"
min={0.01}
max={target?.owed}
decimalScale={2}
thousandSeparator=","
value={amount}
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Period (YYYY-MM)"
description="One remittance per type per period"
value={period}
onChange={(e) => setPeriod(e.currentTarget.value)}
/>
<TextInput
label="Paid date"
type="date"
value={paidDate}
onChange={(e) => setPaidDate(e.currentTarget.value)}
/>
<Select
label="Paid from"
data={cashAccounts.data ?? []}
value={accountId}
onChange={setAccountId}
searchable
/>
<TextInput
label="Receipt number"
description="The authority's own receipt — the evidence the obligation was met"
value={receiptNumber}
onChange={(e) => setReceiptNumber(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
loading={remit.isPending}
disabled={!accountId || !amount}
onClick={() => remit.mutate()}
>
Record remittance
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,264 @@
import { financeApi } from "@/auth/http";
import type { LocalizedName } from "@/shared/types";
export type Supplier = {
id: string;
code: string;
name: string;
tin: string | null;
phone: string | null;
email: string | null;
bankName: string | null;
bankAccount: string | null;
isActive: boolean;
};
export type BillStatus =
| "DRAFT"
| "APPROVED"
| "PARTIALLY_PAID"
| "PAID"
| "CANCELLED";
export type BillRow = {
id: string;
billNumber: string;
supplierInvoiceNumber: string | null;
billDate: string;
dueDate: string | null;
currency: string;
totalAmount: number | string;
paidAmount: number | string;
status: BillStatus;
description: string | null;
journalEntryId: string | null;
supplierId: string;
supplierCode: string;
supplierName: string;
};
export type BillLine = {
id: string;
lineNumber: number;
accountId: string;
accountCode: string;
accountName: LocalizedName;
description: string | null;
amount: number | string;
};
export type BillDetail = {
id: string;
billNumber: string;
supplierInvoiceNumber: string | null;
billDate: string;
dueDate: string | null;
status: BillStatus;
subtotal: number | string;
taxAmount: number | string;
withholdingAmount: number | string;
totalAmount: number | string;
paidAmount: number | string;
description: string | null;
journalEntryId: string | null;
lines: BillLine[];
payments: {
id: string;
paymentNumber: string;
paymentDate: string;
amount: number | string;
method: string;
reference: string | null;
journalEntryId: string | null;
}[];
supplier: Supplier | null;
};
export type AgingRow = {
bucket: string;
currency: string;
documentCount: number;
amount: number | string;
};
export type PayrollRun = {
id: string;
periodStart: string;
periodEnd: string;
paymentDate: string | null;
status: string;
employeeCount: number;
totalGross: number;
totalDeductions: number;
totalNet: number;
totalIncomeTax: number;
totalPensionEmployee: number;
totalPensionEmployer: number;
journalEntryId: string | null;
entryNumber: string | null;
};
export type DisbursementRegister = {
runId: string;
periodStart: string;
periodEnd: string;
paymentDate: string | null;
byMode: { salaryMode: string; count: number; total: number }[];
rows: {
employeeId: string;
employeeNumber: string;
employeeName: string;
salaryMode: string;
bankAccount: string | null;
netPay: number;
}[];
total: number;
};
export type StatutoryOutstanding = {
accountId: string;
accountCode: string;
accountName: LocalizedName;
outstanding: number | string;
};
export type Remittance = {
id: string;
statutoryType: string;
period: string;
amount: number | string;
paidDate: string;
receiptNumber: string | null;
reference: string | null;
journalEntryId: string | null;
};
export const BILL_STATUS_COLOR: Record<BillStatus, string> = {
DRAFT: "gray",
APPROVED: "blue",
PARTIALLY_PAID: "orange",
PAID: "green",
CANCELLED: "red",
};
export const fetchSuppliers = async (search?: string): Promise<Supplier[]> => {
const { data } = await financeApi.get<Supplier[]>("/payables/suppliers", {
params: search ? { search } : undefined,
});
return data;
};
export const createSupplier = async (payload: {
code: string;
name: string;
tin?: string;
phone?: string;
email?: string;
bankName?: string;
bankAccount?: string;
}): Promise<Supplier> => {
const { data } = await financeApi.post("/payables/suppliers", payload);
return data;
};
export const fetchBills = async (): Promise<BillRow[]> => {
const { data } = await financeApi.get<BillRow[]>("/payables/bills/detailed");
return data;
};
export const fetchBill = async (id: string): Promise<BillDetail> => {
const { data } = await financeApi.get<BillDetail>(`/payables/bills/${id}`);
return data;
};
export const createBill = async (payload: {
supplierId: string;
supplierInvoiceNumber?: string;
billDate: string;
dueDate?: string;
taxAmount?: number;
withholdingAmount?: number;
description?: string;
lines: { accountId: string; description?: string; amount: number }[];
}): Promise<BillDetail> => {
const { data } = await financeApi.post("/payables/bills", payload);
return data;
};
export const approveBill = async (id: string): Promise<BillDetail> => {
const { data } = await financeApi.post(`/payables/bills/${id}/approve`);
return data;
};
export const recordPayment = async (
billId: string,
payload: {
paymentDate: string;
amount: number;
method: string;
paidFromAccountId: string;
reference?: string;
},
) => {
const { data } = await financeApi.post(
`/payables/bills/${billId}/payments`,
payload,
);
return data;
};
export const fetchPayablesAging = async (asOf: string): Promise<AgingRow[]> => {
const { data } = await financeApi.get<AgingRow[]>("/payables/aging", {
params: { asOf },
});
return data;
};
export const fetchPayrollRuns = async (): Promise<PayrollRun[]> => {
const { data } = await financeApi.get<PayrollRun[]>("/payables/payroll/runs");
return data;
};
export const fetchDisbursement = async (
runId: string,
): Promise<DisbursementRegister> => {
const { data } = await financeApi.get<DisbursementRegister>(
`/payables/payroll/runs/${runId}/disbursement`,
);
return data;
};
export const postPayroll = async (runId: string) => {
const { data } = await financeApi.post(
`/payables/payroll/runs/${runId}/post`,
);
return data;
};
export const fetchStatutoryOutstanding = async (): Promise<
StatutoryOutstanding[]
> => {
const { data } = await financeApi.get("/payables/statutory/outstanding");
return data;
};
export const fetchRemittances = async (): Promise<Remittance[]> => {
const { data } = await financeApi.get("/payables/statutory/remittances");
return data;
};
export const recordRemittance = async (payload: {
statutoryType: string;
period: string;
amount: number;
paidDate: string;
paidFromAccountId: string;
receiptNumber?: string;
reference?: string;
}) => {
const { data } = await financeApi.post(
"/payables/statutory/remittances",
payload,
);
return data;
};

View File

@@ -0,0 +1,344 @@
import { useEffect, useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { 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 {
PERIOD_STATUS_COLOR,
closePeriod,
createFiscalYear,
fetchFiscalPeriods,
fetchFiscalYears,
reopenPeriod,
} from "./api";
export function FiscalPeriodsPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const canClose = can(FINANCE_PERMS.period.close);
const canManage = can(FINANCE_PERMS.period.manage);
const [yearId, setYearId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [actionError, setActionError] = useState<unknown>(null);
const years = useQuery({
queryKey: ["fiscal", "years"],
queryFn: fetchFiscalYears,
});
// Default to the most recent year once they load, so the page is never an
// empty table when data exists.
useEffect(() => {
if (!yearId && years.data?.length) setYearId(years.data[0].id);
}, [years.data, yearId]);
const periods = useQuery({
queryKey: ["fiscal", "periods", yearId],
queryFn: () => fetchFiscalPeriods(yearId ?? undefined),
enabled: Boolean(yearId),
});
const invalidate = () =>
void queryClient.invalidateQueries({ queryKey: ["fiscal"] });
const close = useMutation({
mutationFn: ({ id, status }: { id: string; status: "CLOSED" | "LOCKED" }) =>
closePeriod(id, status),
onMutate: () => setActionError(null),
onSuccess: invalidate,
onError: setActionError,
});
const reopen = useMutation({
mutationFn: (id: string) => reopenPeriod(id),
onMutate: () => setActionError(null),
onSuccess: invalidate,
onError: setActionError,
});
return (
<>
<PageHeader
title="Fiscal periods"
description="Closing a period is what makes a report over it final — nothing further can be posted into it."
actions={
canManage && (
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateOpen(true)}
>
New fiscal year
</Button>
)
}
/>
<ApiErrorAlert error={actionError} title="That change was refused" />
<ApiErrorAlert error={years.error} title="Could not load fiscal years" />
<Alert icon={<IconInfoCircle size={18} />} color="blue" variant="light" mb="md">
<Text size="sm">
<b>CLOSED</b> stops new postings but can be reopened. <b>LOCKED</b> is
permanent once a year has been reported or audited, a correction to
it must be posted as a dated entry in an open period instead.
</Text>
</Alert>
{years.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (years.data ?? []).length === 0 ? (
<Card withBorder padding="lg">
<Stack gap="xs">
<Text fw={600}>No fiscal year yet</Text>
<Text size="sm" c="dimmed">
Nothing can be posted until a year exists every entry date has
to resolve to a period. The Ethiopian fiscal year runs 8 July to 7
July, but the dates are yours to set.
</Text>
</Stack>
</Card>
) : (
<>
<Select
label="Fiscal year"
data={(years.data ?? []).map((year) => ({
value: year.id,
label: `${year.code} (${year.startDate}${year.endDate})`,
}))}
value={yearId}
onChange={setYearId}
allowDeselect={false}
w={360}
mb="md"
/>
{periods.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Table.ScrollContainer minWidth={780}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={60}>#</Table.Th>
<Table.Th>Period</Table.Th>
<Table.Th w={140}>From</Table.Th>
<Table.Th w={140}>To</Table.Th>
<Table.Th w={120}>Status</Table.Th>
<Table.Th w={230} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(periods.data ?? []).map((period) => (
<Table.Tr key={period.id}>
<Table.Td>{period.periodNumber}</Table.Td>
<Table.Td>{period.name.en}</Table.Td>
<Table.Td>{period.startDate}</Table.Td>
<Table.Td>{period.endDate}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={PERIOD_STATUS_COLOR[period.status]}
>
{period.status}
</Badge>
</Table.Td>
<Table.Td>
{canClose && (
<Group gap="xs" justify="flex-end">
{period.status === "OPEN" && (
<>
<Button
size="xs"
variant="light"
color="orange"
loading={
close.isPending &&
close.variables?.id === period.id
}
onClick={() =>
close.mutate({
id: period.id,
status: "CLOSED",
})
}
>
Close
</Button>
<Tooltip label="Permanent — a locked period is never reopened">
<Button
size="xs"
variant="light"
color="red"
onClick={() =>
close.mutate({
id: period.id,
status: "LOCKED",
})
}
>
Lock
</Button>
</Tooltip>
</>
)}
{period.status === "CLOSED" && (
<Button
size="xs"
variant="light"
loading={
reopen.isPending &&
reopen.variables === period.id
}
onClick={() => reopen.mutate(period.id)}
>
Reopen
</Button>
)}
{period.status === "LOCKED" && (
<Text size="xs" c="dimmed">
Sealed permanently
</Text>
)}
</Group>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</>
)}
<CreateYearModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(created) => {
setCreateOpen(false);
setYearId(created);
invalidate();
}}
/>
</>
);
}
function CreateYearModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: (yearId: string) => void;
}) {
const [code, setCode] = useState("");
const [en, setEn] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [periodCount, setPeriodCount] = useState<number | "">(12);
const create = useMutation({
mutationFn: () =>
createFiscalYear({
code,
name: { en, am: en },
startDate,
endDate,
periodCount: Number(periodCount) || 12,
}),
onSuccess: (result) => onCreated(result.year.id),
});
return (
<Modal opened={opened} onClose={onClose} title="New fiscal year" size="lg">
<Stack>
<ApiErrorAlert error={create.error} title="Could not create the year" />
<Text size="sm" c="dimmed">
The year and its periods are created together a year without periods
could not accept a posting.
</Text>
<TextInput
label="Code"
placeholder="2026-27"
required
value={code}
onChange={(event) => setCode(event.currentTarget.value)}
/>
<TextInput
label="Name"
placeholder="FY 2026/27"
required
value={en}
onChange={(event) => setEn(event.currentTarget.value)}
/>
<Group grow>
<TextInput
label="Start date"
type="date"
description="Ethiopian FY starts 8 July"
required
value={startDate}
onChange={(event) => setStartDate(event.currentTarget.value)}
/>
<TextInput
label="End date"
type="date"
required
value={endDate}
onChange={(event) => setEndDate(event.currentTarget.value)}
/>
</Group>
<NumberInput
label="Number of periods"
description="12 for calendar months; 13 to give Pagume its own period"
min={1}
max={13}
value={periodCount}
onChange={(value) => setPeriodCount(value === "" ? "" : Number(value))}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={create.isPending}
disabled={!code || !en || !startDate || !endDate}
onClick={() => create.mutate()}
>
Create
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,77 @@
import { financeApi } from "@/auth/http";
import type { LocalizedName } from "@/shared/types";
export type FiscalYearStatus = "OPEN" | "CLOSED";
export type FiscalPeriodStatus = "OPEN" | "CLOSED" | "LOCKED";
export type FiscalYear = {
id: string;
organizationId: string;
code: string;
name: LocalizedName;
startDate: string;
endDate: string;
status: FiscalYearStatus;
};
export type FiscalPeriod = {
id: string;
organizationId: string;
fiscalYearId: string;
periodNumber: number;
name: LocalizedName;
startDate: string;
endDate: string;
status: FiscalPeriodStatus;
closedAt: string | null;
closedBy: string | null;
};
export const PERIOD_STATUS_COLOR: Record<FiscalPeriodStatus, string> = {
OPEN: "green",
CLOSED: "orange",
LOCKED: "red",
};
export const fetchFiscalYears = async (): Promise<FiscalYear[]> => {
const { data } = await financeApi.get<FiscalYear[]>("/fiscal/years");
return data;
};
export const fetchFiscalPeriods = async (
fiscalYearId?: string,
): Promise<FiscalPeriod[]> => {
const { data } = await financeApi.get<FiscalPeriod[]>("/fiscal/periods", {
params: fiscalYearId ? { fiscalYearId } : undefined,
});
return data;
};
export const createFiscalYear = async (payload: {
code: string;
name: { en: string; am: string };
startDate: string;
endDate: string;
periodCount?: number;
}): Promise<{ year: FiscalYear; periods: FiscalPeriod[] }> => {
const { data } = await financeApi.post("/fiscal/years", payload);
return data;
};
export const closePeriod = async (
id: string,
status: "CLOSED" | "LOCKED",
): Promise<FiscalPeriod> => {
const { data } = await financeApi.post<FiscalPeriod>(
`/fiscal/periods/${id}/close`,
{ status },
);
return data;
};
export const reopenPeriod = async (id: string): Promise<FiscalPeriod> => {
const { data } = await financeApi.post<FiscalPeriod>(
`/fiscal/periods/${id}/reopen`,
);
return data;
};

View File

@@ -0,0 +1,390 @@
import { useState } from "react";
import {
Alert,
Button,
Card,
Group,
Loader,
Select,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconDownload } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { PageHeader } from "@/shared/components/PageHeader";
import { ApiErrorAlert } from "@/shared/components/ApiErrorAlert";
import { formatMoney } from "@/shared/lib/formatMoney";
import { fetchAccounts } from "../accounts/api";
import {
fetchBalanceSheet,
fetchCashMovement,
fetchGeneralLedger,
fetchProfitAndLoss,
fetchTrialBalance,
} from "../assets/api";
const today = () => new Date().toISOString().slice(0, 10);
const yearStart = () => `${new Date().getFullYear()}-01-01`;
/** Downloads any row set as CSV — the export the reports are actually used for. */
const exportCsv = (filename: string, header: string[], rows: (string | number)[][]) => {
const esc = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
const body = [header.map(esc).join(","), ...rows.map((r) => r.map(esc).join(","))].join("\n");
const url = URL.createObjectURL(new Blob([body], { type: "text/csv" }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
export function ReportsPage() {
const [dateFrom, setDateFrom] = useState(yearStart());
const [dateTo, setDateTo] = useState(today());
const [accountId, setAccountId] = useState<string | null>(null);
const tb = useQuery({
queryKey: ["reports", "tb", dateFrom, dateTo],
queryFn: () => fetchTrialBalance(dateFrom, dateTo),
});
const pl = useQuery({
queryKey: ["reports", "pl", dateFrom, dateTo],
queryFn: () => fetchProfitAndLoss(dateFrom, dateTo),
});
const bs = useQuery({
queryKey: ["reports", "bs", dateTo],
queryFn: () => fetchBalanceSheet(dateTo),
});
const cash = useQuery({
queryKey: ["reports", "cash", dateFrom, dateTo],
queryFn: () => fetchCashMovement(dateFrom, dateTo),
});
const accounts = useQuery({
queryKey: ["accounts", "postable"],
queryFn: () => fetchAccounts({}),
select: (all) =>
all.filter((a) => !a.isGroup).map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` })),
});
const gl = useQuery({
queryKey: ["reports", "gl", accountId, dateFrom, dateTo],
queryFn: () => fetchGeneralLedger(accountId as string, dateFrom, dateTo),
enabled: Boolean(accountId),
});
return (
<>
<PageHeader
title="Financial reports"
description="Read-only, over posted entries only. Drafts have not happened."
/>
<ApiErrorAlert error={tb.error} title="Could not build the report" />
<Group mb="lg" gap="sm" align="flex-end">
<TextInput label="From" type="date" value={dateFrom} onChange={(e) => setDateFrom(e.currentTarget.value)} />
<TextInput label="To" type="date" value={dateTo} onChange={(e) => setDateTo(e.currentTarget.value)} />
</Group>
<Tabs defaultValue="trial-balance">
<Tabs.List mb="md">
<Tabs.Tab value="trial-balance">Trial balance</Tabs.Tab>
<Tabs.Tab value="pl">Profit &amp; loss</Tabs.Tab>
<Tabs.Tab value="bs">Balance sheet</Tabs.Tab>
<Tabs.Tab value="cash">Cash</Tabs.Tab>
<Tabs.Tab value="gl">General ledger</Tabs.Tab>
</Tabs.List>
{/* ── trial balance ─────────────────────────────────────────────── */}
<Tabs.Panel value="trial-balance">
{tb.isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack>
{/* The check is stated, not implied — if the two columns differ,
something bypassed the journal service and the reader must be
told rather than left to spot it. */}
{tb.data?.balanced ? (
<Alert color="green" variant="light" icon={<IconCheck size={18} />}>
<Text size="sm">
Balanced debits and credits both {formatMoney(tb.data.totalDebit)}.
</Text>
</Alert>
) : (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title="Out of balance">
<Text size="sm">
Debits {formatMoney(tb.data?.totalDebit)} vs credits{" "}
{formatMoney(tb.data?.totalCredit)} a difference of{" "}
<b>{formatMoney(tb.data?.difference)}</b>. Every entry this
service posts is balanced, so a difference here means
something wrote to the ledger without going through it.
</Text>
</Alert>
)}
<Group justify="flex-end">
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={() =>
exportCsv(
`trial-balance-${dateFrom}-to-${dateTo}.csv`,
["Code", "Account", "Type", "Debit", "Credit"],
(tb.data?.rows ?? []).map((r) => [
r.accountCode, r.accountName?.en, r.accountType, r.debit, r.credit,
]),
)
}
>
Export CSV
</Button>
</Group>
<Table.ScrollContainer minWidth={760}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={100}>Code</Table.Th>
<Table.Th>Account</Table.Th>
<Table.Th w={130}>Type</Table.Th>
<Table.Th w={160} ta="right">Debit</Table.Th>
<Table.Th w={160} ta="right">Credit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(tb.data?.rows ?? []).map((r) => {
// The report lists any account with movement in the range,
// so an account whose debits and credits cancel — an entry
// and its reversal — arrives with BOTH sides zero. Blanking
// both cells makes a real row look like a broken one, so a
// net-zero account states its zero explicitly. Only the
// unused side of a one-sided row stays blank.
const netZero = !Number(r.debit) && !Number(r.credit);
return (
<Table.Tr key={r.accountCode}>
<Table.Td><Text ff="monospace" size="sm">{r.accountCode}</Text></Table.Td>
<Table.Td>{r.accountName?.en}</Table.Td>
<Table.Td><Text size="sm" c="dimmed">{r.accountType}</Text></Table.Td>
<Table.Td ta="right">
<Text ff="monospace">
{Number(r.debit) || netZero ? formatMoney(r.debit) : ""}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">
{Number(r.credit) || netZero ? formatMoney(r.credit) : ""}
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={3}><Text fw={700}>Total</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={700}>{formatMoney(tb.data?.totalDebit)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={700}>{formatMoney(tb.data?.totalCredit)}</Text></Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
</Table.ScrollContainer>
</Stack>
)}
</Tabs.Panel>
{/* ── profit & loss ─────────────────────────────────────────────── */}
<Tabs.Panel value="pl">
{pl.isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<Card withBorder padding="lg">
<Text size="xs" c="dimmed" tt="uppercase">Revenue</Text>
<Text size="xl" fw={700} ff="monospace">{formatMoney(pl.data?.totalRevenue)}</Text>
</Card>
<Card withBorder padding="lg">
<Text size="xs" c="dimmed" tt="uppercase">Expenses</Text>
<Text size="xl" fw={700} ff="monospace">{formatMoney(pl.data?.totalExpenses)}</Text>
</Card>
<Card withBorder padding="lg">
<Text size="xs" c="dimmed" tt="uppercase">Net result</Text>
<Text
size="xl" fw={700} ff="monospace"
c={Number(pl.data?.netResult) >= 0 ? "green" : "red"}
>
{formatMoney(pl.data?.netResult)}
</Text>
</Card>
</SimpleGrid>
<Title order={5} mt="md">Revenue</Title>
<ReportTable rows={pl.data?.revenue ?? []} valueKey="amount" />
<Title order={5} mt="md">Expenses</Title>
<ReportTable rows={pl.data?.expenses ?? []} valueKey="amount" />
</Stack>
)}
</Tabs.Panel>
{/* ── balance sheet ─────────────────────────────────────────────── */}
<Tabs.Panel value="bs">
{bs.isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack>
{bs.data?.balanced ? (
<Alert color="green" variant="light" icon={<IconCheck size={18} />}>
<Text size="sm">
Balanced as at {bs.data.asOf} assets {formatMoney(bs.data.totalAssets)} =
liabilities {formatMoney(bs.data.totalLiabilities)} + equity{" "}
{formatMoney(bs.data.equityWithResult)}.
</Text>
</Alert>
) : (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title="Does not balance">
<Text size="sm">
Out by <b>{formatMoney(bs.data?.difference)}</b>.
</Text>
</Alert>
)}
<Title order={5}>Assets</Title>
<ReportTable rows={bs.data?.assets ?? []} valueKey="balance" />
<Title order={5} mt="md">Liabilities</Title>
<ReportTable rows={bs.data?.liabilities ?? []} valueKey="balance" />
<Title order={5} mt="md">Equity</Title>
<ReportTable rows={bs.data?.equity ?? []} valueKey="balance" />
<Text size="sm" c="dimmed">
Plus the result earned but not yet closed into equity:{" "}
<b>{formatMoney(bs.data?.retainedResult)}</b>
</Text>
</Stack>
)}
</Tabs.Panel>
{/* ── cash ──────────────────────────────────────────────────────── */}
<Tabs.Panel value="cash">
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={100}>Code</Table.Th>
<Table.Th>Account</Table.Th>
<Table.Th w={150} ta="right">Opening</Table.Th>
<Table.Th w={150} ta="right">In</Table.Th>
<Table.Th w={150} ta="right">Out</Table.Th>
<Table.Th w={150} ta="right">Closing</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(cash.data?.accounts ?? []).map((a) => (
<Table.Tr key={a.accountCode}>
<Table.Td><Text ff="monospace" size="sm">{a.accountCode}</Text></Table.Td>
<Table.Td>{a.accountName?.en}</Table.Td>
<Table.Td ta="right"><Text ff="monospace">{formatMoney(a.opening)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" c="green">{formatMoney(a.cashIn)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" c="red">{formatMoney(a.cashOut)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={600}>{formatMoney(a.closing)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td colSpan={2}><Text fw={700}>Total</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={700}>{formatMoney(cash.data?.totalOpening)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={700}>{formatMoney(cash.data?.totalIn)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={700}>{formatMoney(cash.data?.totalOut)}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={700}>{formatMoney(cash.data?.totalClosing)}</Text></Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
{(cash.data?.accounts ?? []).length === 0 && <Text c="dimmed">No cash movement in this window.</Text>}
</Tabs.Panel>
{/* ── general ledger ────────────────────────────────────────────── */}
<Tabs.Panel value="gl">
<Select
label="Account"
placeholder="Choose an account"
searchable
data={accounts.data ?? []}
value={accountId}
onChange={setAccountId}
w={420}
mb="md"
/>
{!accountId ? (
<Text c="dimmed">Choose an account to see its movements.</Text>
) : gl.isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<>
<Text size="sm" c="dimmed" mb="xs">
Opening balance <b>{formatMoney(gl.data?.openingBalance)}</b> ·
closing <b>{formatMoney(gl.data?.closingBalance)}</b>
</Text>
<Table.ScrollContainer minWidth={900}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={110}>Date</Table.Th>
<Table.Th w={150}>Entry</Table.Th>
<Table.Th>Memo</Table.Th>
<Table.Th w={130} ta="right">Debit</Table.Th>
<Table.Th w={130} ta="right">Credit</Table.Th>
<Table.Th w={150} ta="right">Balance</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(gl.data?.lines ?? []).map((l, i) => (
<Table.Tr key={`${l.entryNumber}-${i}`}>
<Table.Td>{l.entryDate}</Table.Td>
<Table.Td><Text ff="monospace" size="sm">{l.entryNumber}</Text></Table.Td>
<Table.Td><Text size="sm" lineClamp={1}>{l.description ?? l.memo}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{l.debit ? formatMoney(l.debit) : ""}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace">{l.credit ? formatMoney(l.credit) : ""}</Text></Table.Td>
<Table.Td ta="right"><Text ff="monospace" fw={600}>{formatMoney(l.balance)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{(gl.data?.lines ?? []).length === 0 && (
<Text c="dimmed">No movements on this account in the window.</Text>
)}
</>
)}
</Tabs.Panel>
</Tabs>
</>
);
}
function ReportTable({
rows,
valueKey,
}: {
rows: { accountCode: string; accountName: { en: string }; [k: string]: unknown }[];
valueKey: string;
}) {
if (rows.length === 0) return <Text c="dimmed" size="sm">Nothing to show.</Text>;
return (
<Table striped withTableBorder>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.accountCode}>
<Table.Td w={110}><Text ff="monospace" size="sm">{r.accountCode}</Text></Table.Td>
<Table.Td>{r.accountName?.en}</Table.Td>
<Table.Td w={180} ta="right">
<Text ff="monospace">{formatMoney(r[valueKey] as number)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,502 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconInfoCircle,
IconPlugConnectedX,
} 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 {
fetchAging,
fetchRefundObligations,
fetchRevenue,
fetchRevenueByMonth,
fetchSources,
recognizeRevenue,
type RecognitionResult,
} from "./api";
const LEDGER_CURRENCY = "ETB";
const today = () => new Date().toISOString().slice(0, 10);
const monthStart = () => `${new Date().toISOString().slice(0, 7)}-01`;
/**
* Names the sources this deployment cannot read.
*
* Without this the screen would show an empty freight table and read as "no
* freight revenue", when the truth is "freight billing is not deployed against
* this database". Those are completely different facts.
*/
function SourceNotice({
sources,
}: {
sources: ReturnType<typeof useQuery<Awaited<ReturnType<typeof fetchSources>>>>;
}) {
const data = sources.data;
if (!data) return null;
const missing = [
!data.freightInvoices && "freight invoices",
!data.passengerBookings && "passenger bookings",
!data.paymentIntents && "payment intents",
].filter(Boolean) as string[];
if (missing.length === 0) return null;
return (
<Alert
icon={<IconPlugConnectedX size={18} />}
color="yellow"
variant="light"
mb="md"
title="Some sources are not present in this database"
>
<Text size="sm">
Not readable here: <b>{missing.join(", ")}</b>. Those figures are absent
rather than zero this is a deployment fact, not an accounting one.
</Text>
</Alert>
);
}
export function ReceivablesPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const [dateFrom, setDateFrom] = useState(monthStart());
const [dateTo, setDateTo] = useState(today());
const [recognizeOpen, setRecognizeOpen] = useState(false);
const sources = useQuery({ queryKey: ["revenue", "sources"], queryFn: fetchSources });
const revenue = useQuery({
queryKey: ["revenue", "buckets", dateFrom, dateTo],
queryFn: () => fetchRevenue(dateFrom, dateTo),
});
const months = useQuery({
queryKey: ["revenue", "months", dateFrom, dateTo],
queryFn: () => fetchRevenueByMonth(dateFrom, dateTo),
});
const aging = useQuery({
queryKey: ["revenue", "aging", dateTo],
queryFn: () => fetchAging(dateTo),
});
const refunds = useQuery({
queryKey: ["revenue", "refunds"],
queryFn: fetchRefundObligations,
});
const buckets = revenue.data ?? [];
const postable = buckets.filter((b) => b.postable);
const nonPostable = buckets.filter((b) => !b.postable);
const ledgerTotal = postable.reduce((sum, b) => sum + b.amount, 0);
return (
<>
<PageHeader
title="Receivables & revenue"
description="Revenue read from the source systems. Finance never writes them."
actions={
can(FINANCE_PERMS.receivable.recordReceipt) && (
<Button onClick={() => setRecognizeOpen(true)}>
Recognize a period
</Button>
)
}
/>
<SourceNotice sources={sources} />
<ApiErrorAlert error={revenue.error} title="Could not load revenue" />
<Group mb="md" gap="sm" align="flex-end">
<TextInput
label="From"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.currentTarget.value)}
/>
<TextInput
label="To"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.currentTarget.value)}
/>
</Group>
{/* The ledger total counts ONLY ledger-currency revenue. Anything else is
shown beside it, never added to it. */}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
<Card withBorder padding="lg">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Revenue in {LEDGER_CURRENCY} (postable)
</Text>
<Text size="xl" fw={700} ff="monospace">
{formatMoney(ledgerTotal)}
</Text>
</Stack>
</Card>
<Card withBorder padding="lg">
<Stack gap={2}>
<Text size="xs" c="dimmed" tt="uppercase">
Other currencies (not postable)
</Text>
{nonPostable.length === 0 ? (
<Text size="xl" fw={700} c="dimmed">
</Text>
) : (
<Group gap="md">
{nonPostable.map((b) => (
<Text key={`${b.revenueKey}-${b.currency}`} fw={700} ff="monospace">
{formatMoney(b.amount)}{" "}
<Text component="span" size="sm" c="dimmed">
{b.currency}
</Text>
</Text>
))}
</Group>
)}
</Stack>
</Card>
</SimpleGrid>
{nonPostable.length > 0 && (
<Alert
icon={<IconAlertTriangle size={18} />}
color="orange"
variant="light"
mb="md"
title="Revenue in other currencies is held back"
>
<Text size="sm">
The ledger carries {LEDGER_CURRENCY} only. These amounts are shown
separately and are never added to the {LEDGER_CURRENCY} total
summing them would overstate revenue by the full non-{LEDGER_CURRENCY}{" "}
figure. They post once someone supplies an explicit conversion rate.
</Text>
</Alert>
)}
<Tabs defaultValue="revenue">
<Tabs.List mb="md">
<Tabs.Tab value="revenue">By charge type</Tabs.Tab>
<Tabs.Tab value="months">By month</Tabs.Tab>
<Tabs.Tab value="aging">Aging</Tabs.Tab>
<Tabs.Tab value="refunds">Refund obligations</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="revenue">
{revenue.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : buckets.length === 0 ? (
<Text c="dimmed">No revenue in this window.</Text>
) : (
<Table.ScrollContainer minWidth={760}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={120}>Source</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th w={100}>Currency</Table.Th>
<Table.Th w={110} ta="right">
Documents
</Table.Th>
<Table.Th w={170} ta="right">
Amount
</Table.Th>
<Table.Th w={130} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{buckets.map((b) => (
<Table.Tr key={`${b.sourceModule}-${b.revenueKey}-${b.currency}`}>
<Table.Td>
<Text size="sm" c="dimmed">
{b.sourceModule}
</Text>
</Table.Td>
<Table.Td>
<Text ff="monospace" size="sm">
{b.revenueKey}
</Text>
</Table.Td>
<Table.Td>{b.currency}</Table.Td>
<Table.Td ta="right">{b.documentCount.toLocaleString()}</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">{formatMoney(b.amount)}</Text>
</Table.Td>
<Table.Td>
{b.postable ? (
<Badge size="sm" variant="light" color="green">
postable
</Badge>
) : (
<Tooltip label={`Needs a ${b.currency}${LEDGER_CURRENCY} rate`}>
<Badge size="sm" variant="light" color="orange">
needs rate
</Badge>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Tabs.Panel>
<Tabs.Panel value="months">
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={140}>Month</Table.Th>
<Table.Th w={110}>Currency</Table.Th>
<Table.Th w={130} ta="right">Bookings</Table.Th>
<Table.Th w={180} ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(months.data ?? []).map((row) => (
<Table.Tr key={`${row.period}-${row.currency}`}>
<Table.Td>{row.period}</Table.Td>
<Table.Td>{row.currency}</Table.Td>
<Table.Td ta="right">{row.documentCount.toLocaleString()}</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">{formatMoney(row.amount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{(months.data ?? []).length === 0 && (
<Text c="dimmed">Nothing in this window.</Text>
)}
</Tabs.Panel>
<Tabs.Panel value="aging">
{!sources.data?.freightInvoices ? (
<Alert color="yellow" variant="light" icon={<IconPlugConnectedX size={18} />}>
Aging reads freight invoices, which are not present in this
database. This is empty because the source is absent, not because
nothing is outstanding.
</Alert>
) : (aging.data ?? []).length === 0 ? (
<Text c="dimmed">Nothing outstanding.</Text>
) : (
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Bucket</Table.Th>
<Table.Th w={110}>Currency</Table.Th>
<Table.Th w={130} ta="right">Invoices</Table.Th>
<Table.Th w={180} ta="right">Balance</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(aging.data ?? []).map((row) => (
<Table.Tr key={`${row.bucket}-${row.currency}`}>
<Table.Td>{row.bucket}</Table.Td>
<Table.Td>{row.currency}</Table.Td>
<Table.Td ta="right">{row.documentCount}</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">{formatMoney(row.amount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Tabs.Panel>
<Tabs.Panel value="refunds">
<Alert
icon={<IconInfoCircle size={18} />}
color="blue"
variant="light"
mb="md"
>
<Text size="sm">
Passenger records a refund on cancellation but never settles it
no refund row is ever written and the status is never updated. So
these are <b>obligations</b>, not cash movements, and belong
against <b>2150 Refunds Payable</b>.
</Text>
</Alert>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Refund status</Table.Th>
<Table.Th w={150} ta="right">Cancellations</Table.Th>
<Table.Th w={180} ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(refunds.data ?? []).map((row) => (
<Table.Tr key={row.refundStatus}>
<Table.Td>{row.refundStatus}</Table.Td>
<Table.Td ta="right">{row.documentCount.toLocaleString()}</Table.Td>
<Table.Td ta="right">
<Text ff="monospace">{formatMoney(row.amount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Tabs.Panel>
</Tabs>
<RecognizeModal
opened={recognizeOpen}
onClose={() => setRecognizeOpen(false)}
onDone={() => {
void queryClient.invalidateQueries({ queryKey: ["journals"] });
void queryClient.invalidateQueries({ queryKey: ["revenue"] });
}}
/>
</>
);
}
function RecognizeModal({
opened,
onClose,
onDone,
}: {
opened: boolean;
onClose: () => void;
onDone: () => void;
}) {
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7));
const [sourceModule, setSourceModule] = useState("passenger");
const [result, setResult] = useState<RecognitionResult | null>(null);
const run = useMutation({
mutationFn: () => recognizeRevenue({ sourceModule, period }),
onSuccess: (data) => {
setResult(data);
onDone();
},
});
return (
<Modal
opened={opened}
onClose={() => {
setResult(null);
onClose();
}}
title="Recognize revenue for a period"
size="lg"
>
<Stack>
<Text size="sm" c="dimmed">
Posts ONE summarized journal entry for the period debiting the
receivable and crediting each mapped revenue account. Summarized
deliberately: a month can hold tens of thousands of bookings, and one
entry each would make the ledger unreadable.
</Text>
<ApiErrorAlert error={run.error} title="Recognition was refused" />
<Group grow>
<TextInput
label="Source"
value={sourceModule}
onChange={(e) => setSourceModule(e.currentTarget.value)}
/>
<TextInput
label="Period (YYYY-MM)"
value={period}
onChange={(e) => setPeriod(e.currentTarget.value)}
/>
</Group>
{result && (
<Stack gap="xs">
<Alert color="green" variant="light">
Posted {result.entryNumber} {formatMoney(result.total)} {LEDGER_CURRENCY}
</Alert>
<Title order={6}>Posted</Title>
<Table withTableBorder>
<Table.Tbody>
{result.lines.map((line) => (
<Table.Tr key={line.accountCode}>
<Table.Td w={90}>
<Text ff="monospace" size="sm">{line.accountCode}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{line.revenueKey}</Text>
</Table.Td>
<Table.Td ta="right" w={150}>
<Text ff="monospace">{formatMoney(line.amount)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{result.excluded.length > 0 && (
<>
<Title order={6}>Held back</Title>
<Table withTableBorder>
<Table.Tbody>
{result.excluded.map((row) => (
<Table.Tr key={`${row.revenueKey}-${row.currency}`}>
<Table.Td>
<Text size="sm">
{row.revenueKey} ({row.currency})
</Text>
</Table.Td>
<Table.Td ta="right" w={150}>
<Text ff="monospace">{formatMoney(row.amount)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{row.reason}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</>
)}
</Stack>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => { setResult(null); onClose(); }}>
Close
</Button>
<Button loading={run.isPending} onClick={() => run.mutate()}>
Recognize
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,373 @@
import { useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
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 { fetchAccounts } from "../accounts/api";
import {
EVENT_STATUS_COLOR,
createMapping,
deleteMapping,
fetchEventSummary,
fetchEvents,
fetchMappings,
} from "./api";
export function RevenueMappingsPage() {
const { can } = useAuth();
const queryClient = useQueryClient();
const canManage = can(FINANCE_PERMS.receivable.manageRevenueMapping);
const [createOpen, setCreateOpen] = useState(false);
const [actionError, setActionError] = useState<unknown>(null);
const [search, setSearch] = useState("");
const mappings = useQuery({
queryKey: ["revenue", "mappings"],
queryFn: fetchMappings,
});
const events = useQuery({ queryKey: ["revenue", "events"], queryFn: () => fetchEvents() });
const summary = useQuery({
queryKey: ["revenue", "events", "summary"],
queryFn: fetchEventSummary,
});
const remove = useMutation({
mutationFn: (id: string) => deleteMapping(id),
onMutate: () => setActionError(null),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["revenue"] }),
onError: setActionError,
});
const rows = (mappings.data ?? []).filter((m) =>
search.trim()
? `${m.matchValue} ${m.revenueCategory} ${m.accountCode}`
.toLowerCase()
.includes(search.trim().toLowerCase())
: true,
);
const failed = (summary.data ?? []).find((s) => s.status === "FAILED");
return (
<>
<PageHeader
title="Revenue mapping & ingest"
description="Which account each charge type earns into, and what the ledger did with each payment event."
actions={
canManage && (
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateOpen(true)}
>
New mapping
</Button>
)
}
/>
<ApiErrorAlert error={actionError} title="That change was refused" />
<Tabs defaultValue="mappings">
<Tabs.List mb="md">
<Tabs.Tab value="mappings">
Mappings ({(mappings.data ?? []).length})
</Tabs.Tab>
<Tabs.Tab value="events">
Payment events
{failed && failed.count > 0 ? ` (${failed.count} failed)` : ""}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="mappings">
<Alert
icon={<IconInfoCircle size={18} />}
color="blue"
variant="light"
mb="md"
>
<Text size="sm">
A charge type with no mapping is not an error it posts to{" "}
<b>4900 Unclassified Revenue</b>, a real account. A balance
appearing there is the signal that something upstream started
writing a new charge type and needs a row here.
</Text>
</Alert>
<TextInput
placeholder="Search charge type, category or account"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={340}
mb="md"
/>
{mappings.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Table.ScrollContainer minWidth={880}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={110}>Source</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th w={200}>Account</Table.Th>
<Table.Th w={200}>Category</Table.Th>
<Table.Th>Note</Table.Th>
<Table.Th w={60} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>
<Text size="sm" c="dimmed">{m.sourceModule}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Text ff="monospace" size="sm">{m.matchValue}</Text>
{!m.isActive && (
<Badge size="xs" color="red" variant="light">inactive</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">
<Text component="span" ff="monospace">{m.accountCode}</Text>{" "}
{m.accountName?.en}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{m.revenueCategory}</Text>
</Table.Td>
<Table.Td>
{m.notes && (
<Tooltip label={m.notes} multiline w={320}>
<Text size="xs" c="dimmed" lineClamp={1}>{m.notes}</Text>
</Tooltip>
)}
</Table.Td>
<Table.Td>
{canManage && (
<ActionIcon
variant="subtle"
color="red"
loading={remove.isPending && remove.variables === m.id}
onClick={() => remove.mutate(m.id)}
aria-label={`Retire ${m.matchValue}`}
>
<IconTrash size={16} />
</ActionIcon>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Tabs.Panel>
<Tabs.Panel value="events">
<Group mb="md" gap="sm">
{(summary.data ?? []).map((s) => (
<Badge
key={s.status}
size="lg"
variant="light"
color={EVENT_STATUS_COLOR[s.status] ?? "gray"}
>
{s.status}: {s.count}
</Badge>
))}
{(summary.data ?? []).length === 0 && (
<Text c="dimmed" size="sm">
No payment events received yet.
</Text>
)}
</Group>
<Alert
icon={<IconInfoCircle size={18} />}
color="blue"
variant="light"
mb="md"
>
<Text size="sm">
Delivery is at-least-once, so the same event can arrive twice
the second is recognised and ignored. An event that could not be
posted is kept as <b>FAILED</b> with its full payload rather than
dropped, so it can be replayed once the cause is fixed.
</Text>
</Alert>
<Table.ScrollContainer minWidth={900}>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th w={170}>Received</Table.Th>
<Table.Th w={210}>Routing key</Table.Th>
<Table.Th w={110}>Status</Table.Th>
<Table.Th>Reference / reason</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(events.data ?? []).map((e) => (
<Table.Tr key={e.id}>
<Table.Td>
<Text size="xs">
{new Date(e.receivedAt).toLocaleString()}
</Text>
</Table.Td>
<Table.Td>
<Text ff="monospace" size="xs">{e.routingKey}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={EVENT_STATUS_COLOR[e.status] ?? "gray"}
>
{e.status}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c={e.status === "FAILED" ? "red" : "dimmed"}>
{e.error ?? e.sourceId ?? "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{(events.data ?? []).length === 0 && (
<Text c="dimmed">
Nothing received. Live ingest needs PAYMENT_RABBITMQ_URL set on the
API without it every other screen still works.
</Text>
)}
</Tabs.Panel>
</Tabs>
<CreateMappingModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={() => {
setCreateOpen(false);
void queryClient.invalidateQueries({ queryKey: ["revenue"] });
}}
/>
</>
);
}
function CreateMappingModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [sourceModule, setSourceModule] = useState("freight");
const [matchValue, setMatchValue] = useState("");
const [accountId, setAccountId] = useState<string | null>(null);
const [revenueCategory, setRevenueCategory] = useState("");
// Only postable REVENUE accounts — the server rejects anything else, so
// offering them would be a guaranteed 400.
const accounts = useQuery({
queryKey: ["accounts", "revenue-postable"],
queryFn: () => fetchAccounts({ isActive: true }),
select: (all) =>
all
.filter((a) => a.accountType === "REVENUE" && !a.isGroup && a.isActive)
.map((a) => ({ value: a.id, label: `${a.code}${a.name.en}` })),
});
const create = useMutation({
mutationFn: () =>
createMapping({
sourceModule,
matchValue: matchValue.trim(),
accountId: accountId as string,
revenueCategory: revenueCategory.trim(),
}),
onSuccess: () => {
setMatchValue("");
setRevenueCategory("");
setAccountId(null);
onCreated();
},
});
return (
<Modal opened={opened} onClose={onClose} title="New revenue mapping" size="lg">
<Stack>
<ApiErrorAlert error={create.error} title="Could not create the mapping" />
<Select
label="Source"
data={["freight", "passenger", "payment"]}
value={sourceModule}
onChange={(v) => setSourceModule(v ?? "freight")}
allowDeselect={false}
/>
<TextInput
label="Charge type"
description="Matched exactly against the source's charge type"
required
value={matchValue}
onChange={(e) => setMatchValue(e.currentTarget.value)}
/>
<Select
label="Revenue account"
description="Must be a postable REVENUE account"
searchable
required
data={accounts.data ?? []}
value={accountId}
onChange={setAccountId}
/>
<TextInput
label="Reporting category"
required
value={revenueCategory}
onChange={(e) => setRevenueCategory(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
loading={create.isPending}
disabled={!matchValue.trim() || !accountId || !revenueCategory.trim()}
onClick={() => create.mutate()}
>
Create
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,164 @@
import { financeApi } from "@/auth/http";
import type { LocalizedName } from "@/shared/types";
export type SourceAvailability = {
freightInvoices: boolean;
freightInvoiceLines: boolean;
passengerBookings: boolean;
passengerExcessBaggage: boolean;
paymentIntents: boolean;
};
export type RevenueBucket = {
sourceModule: string;
revenueKey: string;
currency: string;
documentCount: number;
amount: number;
/** False when the currency is not the ledger's — cannot post without a rate. */
postable: boolean;
};
export type RevenuePeriodRow = {
period: string;
currency: string;
documentCount: number;
amount: number;
};
export type AgingRow = {
bucket: string;
currency: string;
documentCount: number;
amount: number;
};
export type RefundObligation = {
refundStatus: string;
documentCount: number;
amount: number;
};
export type RevenueMapping = {
id: string;
sourceModule: "freight" | "passenger" | "payment";
matchValue: string;
revenueCategory: string;
accountId: string;
accountCode: string;
accountName: LocalizedName;
isActive: boolean;
notes: string | null;
};
export type InboundEvent = {
id: string;
eventId: string;
routingKey: string;
sourceModule: string | null;
sourceId: string | null;
status: "RECEIVED" | "POSTED" | "SKIPPED" | "FAILED";
journalEntryId: string | null;
error: string | null;
receivedAt: string;
processedAt: string | null;
};
export type RecognitionResult = {
posted: boolean;
entryNumber?: string;
journalEntryId?: string;
excluded: { revenueKey: string; currency: string; amount: number; reason: string }[];
lines: { accountCode: string; revenueKey: string; amount: number }[];
total: number;
};
export const fetchSources = async (): Promise<SourceAvailability> => {
const { data } = await financeApi.get<SourceAvailability>("/revenue/sources");
return data;
};
export const fetchRevenue = async (
dateFrom: string,
dateTo: string,
): Promise<RevenueBucket[]> => {
const { data } = await financeApi.get<RevenueBucket[]>("/revenue", {
params: { dateFrom, dateTo },
});
return data;
};
export const fetchRevenueByMonth = async (
dateFrom: string,
dateTo: string,
): Promise<RevenuePeriodRow[]> => {
const { data } = await financeApi.get<RevenuePeriodRow[]>("/revenue/by-month", {
params: { dateFrom, dateTo },
});
return data;
};
export const fetchAging = async (asOf: string): Promise<AgingRow[]> => {
const { data } = await financeApi.get<AgingRow[]>("/revenue/aging", {
params: { asOf },
});
return data;
};
export const fetchRefundObligations = async (): Promise<RefundObligation[]> => {
const { data } = await financeApi.get<RefundObligation[]>(
"/revenue/refund-obligations",
);
return data;
};
export const fetchMappings = async (): Promise<RevenueMapping[]> => {
const { data } = await financeApi.get<RevenueMapping[]>(
"/revenue/mappings/detailed",
);
return data;
};
export const createMapping = async (payload: {
sourceModule: string;
matchValue: string;
accountId: string;
revenueCategory: string;
notes?: string;
}): Promise<RevenueMapping> => {
const { data } = await financeApi.post("/revenue/mappings", payload);
return data;
};
export const deleteMapping = async (id: string): Promise<void> => {
await financeApi.delete(`/revenue/mappings/${id}`);
};
export const fetchEvents = async (status?: string): Promise<InboundEvent[]> => {
const { data } = await financeApi.get<InboundEvent[]>("/revenue/events", {
params: status ? { status } : undefined,
});
return data;
};
export const fetchEventSummary = async (): Promise<
{ status: string; count: number }[]
> => {
const { data } = await financeApi.get("/revenue/events/summary");
return data;
};
export const recognizeRevenue = async (payload: {
sourceModule: string;
period: string;
}): Promise<RecognitionResult> => {
const { data } = await financeApi.post("/revenue/recognize", payload);
return data;
};
export const EVENT_STATUS_COLOR: Record<string, string> = {
RECEIVED: "gray",
POSTED: "green",
SKIPPED: "blue",
FAILED: "red",
};

View File

@@ -0,0 +1,24 @@
{
"app": {
"title": "የኢዲአር ፋይናንስ",
"overview": "አጠቃላይ እይታ"
},
"nav": {
"accounts": "የሒሳብ መዝገብ",
"journals": "የመዝገብ ግቤቶች",
"periods": "የሒሳብ ዘመናት",
"reports": "ሪፖርቶች"
},
"auth": {
"signIn": "ግባ",
"email": "ኢሜይል ወይም የተጠቃሚ ስም",
"password": "የይለፍ ቃል"
},
"common": {
"debit": "ዴቢት",
"credit": "ክሬዲት",
"total": "ጠቅላላ",
"balance": "ቀሪ ሒሳብ",
"currency": "ብር"
}
}

View File

@@ -0,0 +1,24 @@
{
"app": {
"title": "EDR Finance",
"overview": "Overview"
},
"nav": {
"accounts": "Chart of accounts",
"journals": "Journals",
"periods": "Fiscal periods",
"reports": "Reports"
},
"auth": {
"signIn": "Sign in",
"email": "Email or username",
"password": "Password"
},
"common": {
"debit": "Debit",
"credit": "Credit",
"total": "Total",
"balance": "Balance",
"currency": "ETB"
}
}

View File

@@ -0,0 +1,24 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./en.json";
import am from "./am.json";
/**
* The UI ships English and Amharic. Most user-visible DATA is already bilingual
* `{am, en}` and rendered through `localized()`, which reads the active language
* — so switching here changes account names and titles as well as chrome.
*/
void i18n.use(initReactI18next).init({
resources: { en: { translation: en }, am: { translation: am } },
lng: localStorage.getItem("finance-lang") ?? "en",
fallbackLng: "en",
interpolation: { escapeValue: false },
});
export const setLanguage = (language: "en" | "am") => {
localStorage.setItem("finance-lang", language);
void i18n.changeLanguage(language);
};
export default i18n;

View File

@@ -0,0 +1,21 @@
@import "tailwindcss";
/* @edr/ui-common's components are Tailwind-styled, so its source has to be
scanned for classes — without this its markup renders unstyled. */
@source "../node_modules/@edr/ui-common/src/**/*.{ts,tsx}";
/* The shell toggles a `dark` CLASS on <html>, but Tailwind v4 resolves `dark:`
against the OS preference unless told otherwise. `@edr/ui-common` declares
this same variant in its own stylesheet — but this app runs a SECOND
Tailwind build over ui-common's source via the `@source` above, and this
sheet loads later, so its plain `text-slate-800` wins over ui-common's
class-scoped `dark:text-slate-100`. Without this line every `dark:` utility
in the shell is silently dead (the wordmark measured 1.23:1 against its own
background). Any new Mantine + Tailwind app in this repo needs it. */
@custom-variant dark (&:where(.dark, .dark *));
html,
body,
#root {
height: 100%;
}

View File

@@ -0,0 +1,51 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/spotlight/styles.css";
// The package maps these; the raw dist path is not an exported specifier.
import "@edr/ui-common/styles.css";
import "@edr/ui-common/theme.css";
import "./index.css";
import "./i18n";
import { App } from "./App";
import { ColorSchemeSync } from "@/shared/components/ColorSchemeSync";
import { AuthProvider } from "@/auth/AuthContext";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Ledger data changes rarely within a session; refetching on every window
// focus is noise, not freshness.
refetchOnWindowFocus: false,
staleTime: 30_000,
// A 401 is handled by the http interceptor's refresh, and a 403 will not
// become a 200 on retry — only retry once, for transient failures.
retry: 1,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
{/* "light" rather than "auto": the shell's `dark` class is the single
source of truth and ColorSchemeSync follows it. Leaving this on "auto"
lets Mantine pick from the OS while the shell has been toggled the other
way, which is the same mismatch by a different route. */}
<MantineProvider defaultColorScheme="light">
<ColorSchemeSync />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,31 @@
import { Alert } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { apiErrorMessage } from "@/auth/http";
/**
* Surfaces the SERVER's message rather than "Request failed with status code
* NNN" — the API's validation and conflict errors say exactly what is wrong
* ("Entry does not balance: debits 1,200.00, credits 1,000.00"), and that is
* what the user needs to see.
*/
export function ApiErrorAlert({
error,
title = "Something went wrong",
}: {
error: unknown;
title?: string;
}) {
if (!error) return null;
return (
<Alert
color="red"
icon={<IconAlertTriangle size={18} />}
title={title}
mb="md"
styles={{ message: { whiteSpace: "pre-line" } }}
>
{apiErrorMessage(error)}
</Alert>
);
}

View File

@@ -0,0 +1,97 @@
import { DashboardLayout } from "@edr/ui-common";
import { Box } from "@mantine/core";
import { useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
import { useAuth } from "@/auth/AuthContext";
import { setLanguage } from "@/i18n";
import { FINANCE_NAV, findActive, visibleNav } from "@/shared/nav/nav-model";
import { localized } from "@/shared/lib/localizedName";
import {
CommandPalette,
openCommandPalette,
} from "@/shared/components/CommandPalette";
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "am", label: "አማርኛ" },
];
/** The shortcut is only discoverable if something on screen names it. */
function SearchPill() {
return (
<button
type="button"
onClick={openCommandPalette}
data-testid="command-palette-trigger"
className="ml-3 hidden shrink-0 items-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground transition hover:border-[#10B981]/30 hover:text-foreground sm:inline-flex"
>
<span>Search</span>
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 text-xs">
K
</kbd>
</button>
);
}
export function AppShell({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const location = useLocation();
const { user, logout, can } = useAuth();
const { i18n } = useTranslation();
const items = visibleNav(FINANCE_NAV, can);
const { group, entry } = findActive(FINANCE_NAV, location.pathname);
const name = localized(user?.name, "en") || user?.username || "";
const breadcrumb = entry ? (
<span data-testid="breadcrumb" className="truncate">
{group ? (
<>
<span className="text-muted-foreground">{group.label}</span>
<span className="mx-1.5 text-muted-foreground">/</span>
</>
) : null}
<span className="font-semibold">{entry.label}</span>
</span>
) : (
<span data-testid="breadcrumb">EDR Finance</span>
);
return (
<>
<CommandPalette />
<DashboardLayout
title="EDR Finance"
sidebarItems={items}
activeHref={location.pathname}
onNavigate={(href) => navigate(href)}
userName={name}
userEmail={user?.email}
userInitials={name.slice(0, 2).toUpperCase()}
onLogout={logout}
enableThemeToggle
breadcrumb={breadcrumb}
headerLeft={<SearchPill />}
languages={LANGUAGES}
language={i18n.language}
onLanguageChange={(code) => setLanguage(code as "en" | "am")}
// No notification source exists in this app yet.
showNotifications={false}
// finance-web has no profile screen — an entry that 404s is worse than
// no entry.
showProfileLink={false}
responsiveSidebar
sidebarPersistKey="finance-nav-expanded"
>
{/* `main` in ui-common has zero padding by design — its other ~94
consumers pad themselves — so the gutter belongs here. */}
<Box px="xl" py="lg">
{children}
</Box>
</DashboardLayout>
</>
);
}

View File

@@ -0,0 +1,35 @@
import { useEffect } from "react";
import { useMantineColorScheme } from "@mantine/core";
/**
* Keeps Mantine's colour scheme in step with the shell's.
*
* `@edr/ui-common`'s DashboardLayout owns the theme toggle, and all it does is
* add or remove the Tailwind `dark` class on <html> — it predates Mantine and
* knows nothing about it. Every page in this app is built from Mantine
* components, which read `data-mantine-color-scheme` instead. Without this the
* two halves disagree: the shell turns dark while Mantine stays light, so
* titles, tabs, table headers and rows render black-on-black and the cards stay
* white. Only the browser shows it — it type-checks and builds perfectly.
*
* The class is the single source of truth and this follows it, so the toggle
* keeps working exactly as it does in the other apps. Fixing it here rather
* than in ui-common is deliberate: that package has ~94 consumers, most of them
* not Mantine apps.
*/
export function ColorSchemeSync() {
const { setColorScheme } = useMantineColorScheme();
useEffect(() => {
const root = document.documentElement;
const sync = () =>
setColorScheme(root.classList.contains("dark") ? "dark" : "light");
sync();
const observer = new MutationObserver(sync);
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, [setColorScheme]);
return null;
}

View File

@@ -0,0 +1,77 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Spotlight, spotlight, type SpotlightActionData } from "@mantine/spotlight";
import { IconLogout, IconSearch } from "@tabler/icons-react";
import { useAuth } from "@/auth/AuthContext";
import { FINANCE_NAV, visibleEntries } from "@/shared/nav/nav-model";
/**
* Cmd/Ctrl-K navigation.
*
* Built from `visibleEntries`, the same gated source the sidebar renders, so
* the palette can never offer a screen the sidebar hides — one gate, two
* surfaces.
*
* Group names are deliberately NOT in `keywords`. They read like useful
* synonyms, but they make every sibling match a search for one of them.
*/
export function CommandPalette() {
const navigate = useNavigate();
const { can, logout } = useAuth();
const actions = useMemo<SpotlightActionData[]>(() => {
const screens: SpotlightActionData[] = visibleEntries(FINANCE_NAV, can).map(
({ group, entry }) => ({
id: entry.id,
label: entry.label,
// The group is shown as context, but is not searchable — see above.
description: group?.label,
onClick: () => navigate(entry.href),
}),
);
return [
...screens,
{
id: "action-logout",
label: "Log out",
description: "Account",
leftSection: <IconLogout size={18} stroke={1.7} />,
onClick: () => logout(),
},
];
}, [can, navigate, logout]);
return (
<Spotlight
actions={actions}
nothingFound="No screen matches that."
shortcut={["mod + K", "mod + P"]}
highlightQuery
// Match on the screen NAME only. Mantine's default filter also searches
// `description`, which holds the group — so "lea" ("Time & leave") also
// returned *Attendance register*, pushing the screen actually wanted
// down the list. The group stays visible as context, but is not searched.
filter={(query, actions) => {
const q = query.trim().toLowerCase();
if (!q) return actions;
return actions.filter((action) =>
"label" in action
? action.label?.toLowerCase().includes(q)
: true,
);
}}
searchProps={{
// `SpotlightSearchProps` has no `data-testid`; tests address this input
// by its placeholder.
leftSection: <IconSearch size={18} stroke={1.7} />,
placeholder: "Search screens…",
}}
/>
);
}
/** Opens the palette. Exported so a visible control can trigger it — nobody
* discovers a keyboard shortcut that is never shown. */
export const openCommandPalette = () => spotlight.open();

View File

@@ -0,0 +1,26 @@
import { Group, Stack, Text, Title } from "@mantine/core";
import type { ReactNode } from "react";
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<Group justify="space-between" align="flex-start" mb="lg" wrap="wrap">
<Stack gap={2}>
<Title order={2}>{title}</Title>
{description && (
<Text size="sm" c="dimmed">
{description}
</Text>
)}
</Stack>
{actions && <Group gap="sm">{actions}</Group>}
</Group>
);
}

View File

@@ -0,0 +1,41 @@
/**
* Money formatting for the Finance UI.
*
* The API sends amounts as JSON numbers already normalized to major units
* (see apps/finance-api/src/common/money.ts). This never converts — it only
* displays — so a value that looks wrong on screen is wrong in the ledger, not
* wrong here.
*/
const FORMATTER = new Intl.NumberFormat("en-ET", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export const formatMoney = (
amount: number | string | null | undefined,
): string => {
if (amount === null || amount === undefined || amount === "") return "—";
const value = typeof amount === "string" ? Number(amount) : amount;
if (Number.isNaN(value)) return "—";
return FORMATTER.format(value);
};
/** With the currency suffix, for totals and single-value displays. */
export const formatMoneyWithCurrency = (
amount: number | string | null | undefined,
currency = "ETB",
): string => {
const formatted = formatMoney(amount);
return formatted === "—" ? formatted : `${formatted} ${currency}`;
};
/**
* Debit/credit presentation: a ledger shows the amount in one of two columns
* and never as a negative number, so callers ask for the side they are
* rendering and get a blank cell for the other.
*/
export const formatLedgerSide = (
amount: number | null | undefined,
side: "debit" | "credit",
entrySide: "debit" | "credit",
): string => (side === entrySide ? formatMoney(amount) : "");

View File

@@ -0,0 +1,15 @@
import type { LocalizedName } from "../types";
/**
* Render an `{am, en}` value in the active language, falling back to the other
* locale rather than showing an empty cell — a name recorded in only one
* language is common in this data.
*/
export const localized = (
value: Partial<LocalizedName> | null | undefined,
language: string,
): string => {
if (!value) return "";
const preferred = language.startsWith("am") ? value.am : value.en;
return preferred || value.en || value.am || "";
};

View File

@@ -0,0 +1,307 @@
import { createElement, type ReactNode } from "react";
import type { SidebarItem } from "@edr/ui-common";
import {
IconBuildingBank,
IconCashBanknote,
IconChartBar,
IconCoin,
IconLayoutDashboard,
IconReceipt2,
IconRocket,
IconSettings,
type Icon as TablerIcon,
} from "@tabler/icons-react";
import { FINANCE_PERMS } from "@/auth/permissions";
/**
* The navigation tree, as data.
*
* Grouped by the ledger's own subjects: what is owed to EDR, what EDR owes,
* what it plans to spend, and what it owns. "Ledger" holds the double-entry
* core that everything else posts into.
*
* `gate` mirrors the server's permission check, so a reader never sees a screen
* whose every request would 403 — and a group with no permitted child is not
* rendered at all, rather than opening onto nothing.
*
* edr-hr-web carries a deliberate twin of this file. The two apps are separate
* deployables and `@edr/ui-common` has ~94 consumers, most of them not Mantine
* apps, so a shared abstraction would cost more than the duplication does. Keep
* them in step — same as `ColorSchemeSync.tsx`.
*/
/** A Tabler icon component. Stored as the component, not an element, so this
* file stays plain TypeScript and the icon is only constructed when rendered.
* Tabler's own `Icon` type — a hand-written shape does not match it, because
* its `stroke` accepts a string as well as a number. */
export type NavIcon = TablerIcon;
export interface NavEntry {
id: string;
label: string;
href: string;
/**
* Permission key(s) required to see this entry. Omitted = always visible.
* An array is "any of".
*/
gate?: string | string[];
icon?: NavIcon;
}
export interface NavGroup {
id: string;
label: string;
children: NavEntry[];
icon?: NavIcon;
}
export type NavNode = NavEntry | NavGroup;
const isGroup = (node: NavNode): node is NavGroup => "children" in node;
export const FINANCE_NAV: NavNode[] = [
{
id: "overview",
label: "Overview",
href: "/",
icon: IconLayoutDashboard,
},
{
id: "ledger",
label: "Ledger",
icon: IconBuildingBank,
children: [
{
id: "accounts",
label: "Chart of accounts",
href: "/accounts",
gate: FINANCE_PERMS.account.view,
},
{
id: "journals",
label: "Journals",
href: "/journals",
gate: FINANCE_PERMS.journal.view,
},
{
id: "periods",
label: "Fiscal periods",
href: "/periods",
gate: FINANCE_PERMS.period.view,
},
],
},
{
id: "revenue",
label: "Revenue",
icon: IconCoin,
children: [
{
id: "receivables",
label: "Receivables",
href: "/receivables",
gate: FINANCE_PERMS.receivable.view,
},
{
id: "revenue-mappings",
label: "Revenue mapping",
href: "/revenue-mappings",
gate: FINANCE_PERMS.receivable.view,
},
],
},
{
id: "spend",
label: "Spend",
icon: IconReceipt2,
children: [
{
id: "payables",
label: "Payables",
href: "/payables",
gate: FINANCE_PERMS.payable.view,
},
{
id: "payroll",
label: "Payroll & statutory",
href: "/payroll",
gate: FINANCE_PERMS.payable.view,
},
],
},
{
id: "planning",
label: "Planning",
icon: IconChartBar,
children: [
{
id: "budgets",
label: "Budgets",
href: "/budgets",
gate: FINANCE_PERMS.budget.view,
},
{
id: "cost-centers",
label: "Cost centers",
href: "/cost-centers",
gate: FINANCE_PERMS.budget.view,
},
],
},
{
id: "assets",
label: "Fixed assets",
href: "/assets",
icon: IconCashBanknote,
gate: FINANCE_PERMS.asset.view,
},
{
id: "reports",
label: "Reports",
href: "/reports",
icon: IconChartBar,
gate: FINANCE_PERMS.report.view,
},
{
// Last on purpose: going live is done once, and it should not sit above the
// screens used every day.
id: "setup",
label: "Setup",
icon: IconSettings,
children: [
{
id: "cutover",
label: "Cutover",
href: "/cutover",
gate: FINANCE_PERMS.period.view,
icon: IconRocket,
},
],
},
];
const renderIcon = (icon?: NavIcon) =>
icon ? createElement(icon, { size: 18, stroke: 1.7 }) : undefined;
/**
* How well `href` matches `path`, or -1. Mirrors the sidebar's own rule, so the
* breadcrumb and the highlighted row can never disagree: longest match wins,
* boundaries are whole segments, and "/" matches only itself.
*/
const matchLength = (href: string, path: string) => {
const target = href.toLowerCase().replace(/\/+$/, "");
const current = path.toLowerCase().replace(/\/+$/, "") || "/";
if (target === "") return current === "/" ? 1 : -1;
if (current === target) return target.length;
if (current.startsWith(`${target}/`)) return target.length;
return -1;
};
/**
* Every entry the user may open, flattened, each with the group it sits in.
* The command palette is built from this, so it can never offer a screen the
* sidebar hides — one gate, two surfaces.
*/
export const visibleEntries = (
nav: NavNode[],
can: (permission: string | string[]) => boolean,
): Array<{ group?: NavGroup; entry: NavEntry }> =>
nav.flatMap((node) => {
if (!isGroup(node)) {
return node.gate && !can(node.gate) ? [] : [{ entry: node }];
}
return node.children
.filter((child) => !child.gate || can(child.gate))
.map((entry) => ({ group: node, entry }));
});
/**
* The nav entry the given path belongs to, and the group holding it — the
* source for the breadcrumb. A detail route resolves to its list entry
* (`/journals/abc` → Journals), because that is the section the reader is in.
*/
export const findActive = (
nav: NavNode[],
path: string,
): { group?: NavGroup; entry?: NavEntry } => {
let best: { group?: NavGroup; entry?: NavEntry } = {};
let bestScore = 0;
const consider = (entry: NavEntry, group?: NavGroup) => {
const score = matchLength(entry.href, path);
if (score > bestScore) {
bestScore = score;
best = { group, entry };
}
};
for (const node of nav) {
if (isGroup(node)) node.children.forEach((child) => consider(child, node));
else consider(node);
}
return best;
};
/**
* The tree the signed-in user may actually see, as `SidebarItem`s.
*
* A group is dropped entirely when none of its children are permitted, so a
* header can never open onto an empty list. Group headers carry no `href` —
* they toggle, and cannot navigate anywhere.
*/
export const visibleNav = (
nav: NavNode[],
can: (permission: string | string[]) => boolean,
badges: Record<string, ReactNode> = {},
): SidebarItem[] =>
nav.flatMap((node): SidebarItem[] => {
if (!isGroup(node)) {
if (node.gate && !can(node.gate)) return [];
return [
{
id: node.id,
label: node.label,
href: node.href,
icon: renderIcon(node.icon),
badge: badges[node.id],
testId: `nav-item-${node.id}`,
},
];
}
const children = node.children
.filter((child) => !child.gate || can(child.gate))
.map((child) => ({
id: child.id,
label: child.label,
href: child.href,
badge: badges[child.id],
testId: `nav-item-${child.id}`,
}));
if (children.length === 0) return [];
// A group holding exactly one permitted screen is a click that only ever
// reveals one thing, so it is rendered as that screen instead — keeping the
// group's icon, since the icon is what the section is recognised by.
if (children.length === 1) {
const only = children[0];
return [{ ...only, icon: renderIcon(node.icon) }];
}
return [
{
id: node.id,
label: node.label,
icon: renderIcon(node.icon),
badge: badges[node.id],
testId: `nav-group-${node.id}`,
children,
},
];
});

View File

@@ -0,0 +1,27 @@
/** Mirrors the API's Paginated<T> envelope (common/pagination.dto.ts). */
export type Paginated<T> = {
items: T[];
total: number;
page: number;
limit: number;
pageCount: number;
};
export type LocalizedName = { am: string; en: string };
/**
* Column descriptor for shared tables.
*
* Declared here rather than imported from `@edr/ui-common` because that
* package's barrel is inconsistent: it exports the VALUE `Table` from
* `components/table.tsx` (shadcn HTML primitives) but the TYPE `TableColumn`
* from `components/Table/Table.tsx` — a different component with a different
* API. Using both together does not type-check. hr-web hit this first and made
* the same local declaration.
*/
export type TableColumn<T> = {
key: string;
header: string;
width?: string;
render: (row: T) => import("react").ReactNode;
};

12
apps/finance-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_FINANCE_API_URL: string;
readonly VITE_AUTH_API_URL: string;
readonly VITE_CLIENT_APP: string;
readonly VITE_AUTH_BASE_PATH: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,29 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "");
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
// Force a single copy of these singletons. @edr/ui-common ships its own
// node_modules copy of Mantine; without dedupe two @mantine/core
// instances get bundled and MantineProvider's context lookup fails at
// runtime inside ui-common's components. Copied from edr-hr-web, where
// the same bug was already paid for twice.
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
server: {
port: Number(env.PORT) || 5186,
host: "0.0.0.0",
},
};
});