mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 20:10:56 +00:00
Muluhabt ERP modules
This commit is contained in:
12
apps/finance-web/index.html
Normal file
12
apps/finance-web/index.html
Normal 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>
|
||||
46
apps/finance-web/package.json
Normal file
46
apps/finance-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
124
apps/finance-web/src/App.tsx
Normal file
124
apps/finance-web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
apps/finance-web/src/auth/AuthContext.tsx
Normal file
103
apps/finance-web/src/auth/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
26
apps/finance-web/src/auth/Can.tsx
Normal file
26
apps/finance-web/src/auth/Can.tsx
Normal 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}</>;
|
||||
}
|
||||
39
apps/finance-web/src/auth/RequireAuth.tsx
Normal file
39
apps/finance-web/src/auth/RequireAuth.tsx
Normal 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}</>;
|
||||
}
|
||||
26
apps/finance-web/src/auth/api.ts
Normal file
26
apps/finance-web/src/auth/api.ts
Normal 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;
|
||||
};
|
||||
37
apps/finance-web/src/auth/cookies.ts
Normal file
37
apps/finance-web/src/auth/cookies.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
148
apps/finance-web/src/auth/http.ts
Normal file
148
apps/finance-web/src/auth/http.ts
Normal 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";
|
||||
};
|
||||
116
apps/finance-web/src/auth/permissions.ts
Normal file
116
apps/finance-web/src/auth/permissions.ts
Normal 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;
|
||||
};
|
||||
42
apps/finance-web/src/auth/types.ts
Normal file
42
apps/finance-web/src/auth/types.ts
Normal 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 };
|
||||
41
apps/finance-web/src/config/env.ts
Normal file
41
apps/finance-web/src/config/env.ts
Normal 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();
|
||||
494
apps/finance-web/src/features/accounts/ChartOfAccountsPage.tsx
Normal file
494
apps/finance-web/src/features/accounts/ChartOfAccountsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/finance-web/src/features/accounts/api.ts
Normal file
56
apps/finance-web/src/features/accounts/api.ts
Normal 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}`);
|
||||
};
|
||||
38
apps/finance-web/src/features/accounts/types.ts
Normal file
38
apps/finance-web/src/features/accounts/types.ts
Normal 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",
|
||||
};
|
||||
651
apps/finance-web/src/features/assets/AssetsPage.tsx
Normal file
651
apps/finance-web/src/features/assets/AssetsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
apps/finance-web/src/features/assets/api.ts
Normal file
279
apps/finance-web/src/features/assets/api.ts
Normal 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;
|
||||
};
|
||||
92
apps/finance-web/src/features/auth/LoginPage.tsx
Normal file
92
apps/finance-web/src/features/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
541
apps/finance-web/src/features/budgeting/BudgetsPage.tsx
Normal file
541
apps/finance-web/src/features/budgeting/BudgetsPage.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Progress,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPlus } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/AuthContext";
|
||||
import { FINANCE_PERMS } from "@/auth/permissions";
|
||||
import { PageHeader } from "@/shared/components/PageHeader";
|
||||
import { ApiErrorAlert } from "@/shared/components/ApiErrorAlert";
|
||||
import { formatMoney } from "@/shared/lib/formatMoney";
|
||||
import { fetchAccounts } from "../accounts/api";
|
||||
import { fetchFiscalPeriods, fetchFiscalYears } from "../periods/api";
|
||||
import {
|
||||
BUDGET_STATUS_COLOR,
|
||||
approveBudget,
|
||||
createBudget,
|
||||
fetchBudget,
|
||||
fetchBudgets,
|
||||
fetchCostCenters,
|
||||
fetchVariance,
|
||||
spreadBudget,
|
||||
} from "./api";
|
||||
|
||||
export function BudgetsPage() {
|
||||
const { can } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [periodId, setPeriodId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [spreadOpen, setSpreadOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
|
||||
const budgets = useQuery({ queryKey: ["budgeting", "budgets"], queryFn: fetchBudgets });
|
||||
|
||||
// Shares its cache key with the create modal's query, so the empty state can
|
||||
// tell "no fiscal year exists" apart from "a year exists but no budget uses
|
||||
// it yet" — two situations with different next steps.
|
||||
const fiscalYears = useQuery({ queryKey: ["fiscal", "years"], queryFn: fetchFiscalYears });
|
||||
|
||||
// Default to the most recent budget so the page is never an empty shell
|
||||
// when data exists.
|
||||
useEffect(() => {
|
||||
if (!selectedId && budgets.data?.length) setSelectedId(budgets.data[0].id);
|
||||
}, [budgets.data, selectedId]);
|
||||
|
||||
const budget = useQuery({
|
||||
queryKey: ["budgeting", "budget", selectedId],
|
||||
queryFn: () => fetchBudget(selectedId as string),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
|
||||
const periods = useQuery({
|
||||
queryKey: ["fiscal", "periods", budget.data?.fiscalYearId],
|
||||
queryFn: () => fetchFiscalPeriods(budget.data?.fiscalYearId),
|
||||
enabled: Boolean(budget.data?.fiscalYearId),
|
||||
});
|
||||
|
||||
const variance = useQuery({
|
||||
queryKey: ["budgeting", "variance", selectedId, periodId],
|
||||
queryFn: () => fetchVariance(selectedId as string, periodId ?? undefined),
|
||||
enabled: Boolean(selectedId),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
void queryClient.invalidateQueries({ queryKey: ["budgeting"] });
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => approveBudget(selectedId as string),
|
||||
onMutate: () => setActionError(null),
|
||||
onSuccess: invalidate,
|
||||
onError: setActionError,
|
||||
});
|
||||
|
||||
const isDraft = budget.data?.status === "DRAFT";
|
||||
const rows = variance.data ?? [];
|
||||
const overBudget = rows.filter((r) => r.isOverBudget);
|
||||
const unbudgeted = rows.filter((r) => r.isUnbudgeted);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Budgets"
|
||||
description="What was approved, what has been spent, and what is already promised."
|
||||
actions={
|
||||
<Group gap="sm">
|
||||
{isDraft && can(FINANCE_PERMS.budget.manage) && (
|
||||
<Button variant="default" onClick={() => setSpreadOpen(true)}>
|
||||
Set figures
|
||||
</Button>
|
||||
)}
|
||||
{isDraft &&
|
||||
(can(FINANCE_PERMS.budget.approve) ? (
|
||||
<Button loading={approve.isPending} onClick={() => approve.mutate()}>
|
||||
Approve
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip label="Approving a budget is a separate authorisation from preparing it.">
|
||||
<Button disabled>Approve</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
{can(FINANCE_PERMS.budget.manage) && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New budget
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<ApiErrorAlert error={actionError} title="That action was refused" />
|
||||
|
||||
{budgets.isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (budgets.data ?? []).length === 0 ? (
|
||||
<Text c="dimmed">
|
||||
{(fiscalYears.data ?? []).length === 0
|
||||
? "No budgets yet. A budget belongs to a fiscal year, and none exists — create one under Fiscal periods first."
|
||||
: "No budgets yet. Create one against a fiscal year to start planning."}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Group mb="md" gap="sm" align="flex-end">
|
||||
<Select
|
||||
label="Budget"
|
||||
data={(budgets.data ?? []).map((b) => ({
|
||||
value: b.id,
|
||||
label: `${b.name} (${b.status})`,
|
||||
}))}
|
||||
value={selectedId}
|
||||
onChange={(v) => { setSelectedId(v); setPeriodId(null); }}
|
||||
allowDeselect={false}
|
||||
w={340}
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
placeholder="Whole fiscal year"
|
||||
clearable
|
||||
data={(periods.data ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name.en,
|
||||
}))}
|
||||
value={periodId}
|
||||
onChange={setPeriodId}
|
||||
w={220}
|
||||
/>
|
||||
{budget.data && (
|
||||
<Badge size="lg" variant="light" color={BUDGET_STATUS_COLOR[budget.data.status]}>
|
||||
{budget.data.status}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isDraft && (
|
||||
<Alert color="gray" variant="light" mb="md">
|
||||
This budget is a draft — variance is shown against it, but it is
|
||||
not yet the approved plan for the year.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overBudget.length > 0 && (
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
mb="md"
|
||||
title={`${overBudget.length} line(s) over budget`}
|
||||
>
|
||||
<Text size="sm">
|
||||
Counting what is already committed, not only what has been
|
||||
spent — a line at 50% spent and 95% committed has no room left.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unbudgeted.length > 0 && (
|
||||
<Alert icon={<IconInfoCircle size={18} />} color="orange" variant="light" mb="md">
|
||||
<Text size="sm">
|
||||
{unbudgeted.length} line(s) carry spend with <b>nothing budgeted</b>.
|
||||
They appear here on purpose — a report that only listed budgeted
|
||||
lines would hide exactly the spend worth seeing.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="variance">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="variance">Budget vs actual</Tabs.Tab>
|
||||
<Tabs.Tab value="lines">
|
||||
Figures ({(budget.data?.lines ?? []).length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="variance">
|
||||
{variance.isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Text c="dimmed">Nothing budgeted or spent in this window.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1050}>
|
||||
<Table striped withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={90}>Account</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th w={160}>Cost center</Table.Th>
|
||||
<Table.Th w={130} ta="right">Budget</Table.Th>
|
||||
<Table.Th w={130} ta="right">Actual</Table.Th>
|
||||
<Table.Th w={130} ta="right">Committed</Table.Th>
|
||||
<Table.Th w={130} ta="right">Remaining</Table.Th>
|
||||
<Table.Th w={150}>Used</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={`${r.accountId}-${r.costCenterId ?? "none"}`}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm">{r.accountCode}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text size="sm">{r.accountName?.en}</Text>
|
||||
{r.isUnbudgeted && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
unbudgeted
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{r.costCenterCode
|
||||
? `${r.costCenterCode} ${r.costCenterName?.en ?? ""}`
|
||||
: "unallocated"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace">{formatMoney(r.budget)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace">{formatMoney(r.actual)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace" c={r.committed > 0 ? "orange" : "dimmed"}>
|
||||
{formatMoney(r.committed)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace" c={r.remaining < 0 ? "red" : undefined}>
|
||||
{formatMoney(r.remaining)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.percentUsed === null ? (
|
||||
<Text size="xs" c="dimmed">—</Text>
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
<Progress
|
||||
value={Math.min(r.percentUsed, 100)}
|
||||
color={r.isOverBudget ? "red" : r.percentUsed > 85 ? "orange" : "green"}
|
||||
size="sm"
|
||||
/>
|
||||
<Text size="xs" c={r.isOverBudget ? "red" : "dimmed"}>
|
||||
{r.percentUsed}%
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="lines">
|
||||
<Table.ScrollContainer minWidth={800}>
|
||||
<Table striped withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={90}>Account</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th w={160}>Cost center</Table.Th>
|
||||
<Table.Th w={170}>Period</Table.Th>
|
||||
<Table.Th w={140} ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(budget.data?.lines ?? []).map((l) => (
|
||||
<Table.Tr key={l.id}>
|
||||
<Table.Td><Text ff="monospace" size="sm">{l.accountCode}</Text></Table.Td>
|
||||
<Table.Td>{l.accountName?.en}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{l.costCenterCode ?? "unallocated"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{l.periodName?.en}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text ff="monospace">{formatMoney(l.amount)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
{(budget.data?.lines ?? []).length === 0 && (
|
||||
<Text c="dimmed">
|
||||
No figures yet — use “Set figures” to enter annual amounts.
|
||||
</Text>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
<NewBudgetModal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={(id) => { setCreateOpen(false); setSelectedId(id); invalidate(); }}
|
||||
/>
|
||||
<SpreadModal
|
||||
budgetId={selectedId}
|
||||
opened={spreadOpen}
|
||||
onClose={() => setSpreadOpen(false)}
|
||||
onDone={() => { setSpreadOpen(false); invalidate(); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewBudgetModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (id: string) => void;
|
||||
}) {
|
||||
const [fiscalYearId, setFiscalYearId] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const years = useQuery({ queryKey: ["fiscal", "years"], queryFn: fetchFiscalYears });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createBudget({ fiscalYearId: fiscalYearId as string, name: name.trim() }),
|
||||
onSuccess: (b) => { setName(""); onCreated(b.id); },
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="New budget">
|
||||
<Stack>
|
||||
<ApiErrorAlert error={create.error} title="Could not create the budget" />
|
||||
<Select
|
||||
label="Fiscal year"
|
||||
required
|
||||
placeholder={years.isLoading ? "Loading…" : "Select a fiscal year"}
|
||||
data={(years.data ?? []).map((y) => ({ value: y.id, label: y.code }))}
|
||||
value={fiscalYearId}
|
||||
onChange={setFiscalYearId}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="FY 2026/27 operating budget"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
loading={create.isPending}
|
||||
disabled={!fiscalYearId || !name.trim()}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SpreadModal({
|
||||
budgetId,
|
||||
opened,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
budgetId: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<
|
||||
{ key: string; accountId: string | null; costCenterId: string | null; annual: number | "" }[]
|
||||
>([{ key: "1", accountId: null, costCenterId: null, annual: "" }]);
|
||||
|
||||
const accounts = useQuery({
|
||||
queryKey: ["accounts", "budgetable"],
|
||||
queryFn: () => fetchAccounts({ isActive: true }),
|
||||
select: (all) =>
|
||||
all
|
||||
.filter((a) => !a.isGroup && a.isActive && ["EXPENSE", "REVENUE"].includes(a.accountType))
|
||||
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name.en}` })),
|
||||
});
|
||||
|
||||
const costCenters = useQuery({
|
||||
queryKey: ["budgeting", "cost-centers"],
|
||||
queryFn: fetchCostCenters,
|
||||
select: (all) =>
|
||||
all
|
||||
.filter((c) => !c.isGroup && c.isActive)
|
||||
.map((c) => ({ value: c.id, label: `${c.code} — ${c.name.en}` })),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
spreadBudget(
|
||||
budgetId as string,
|
||||
rows
|
||||
.filter((r) => r.accountId && Number(r.annual) > 0)
|
||||
.map((r) => ({
|
||||
accountId: r.accountId as string,
|
||||
costCenterId: r.costCenterId ?? undefined,
|
||||
annualAmount: Number(r.annual),
|
||||
})),
|
||||
),
|
||||
onSuccess: onDone,
|
||||
});
|
||||
|
||||
const usable = rows.filter((r) => r.accountId && Number(r.annual) > 0).length;
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Set annual figures" size="xl">
|
||||
<Stack>
|
||||
<ApiErrorAlert error={save.error} title="Could not save the figures" />
|
||||
<Alert color="blue" variant="light" icon={<IconInfoCircle size={18} />}>
|
||||
<Text size="sm">
|
||||
Each annual amount is spread evenly across the year's periods, with
|
||||
the remainder on the last one so the periods add back to exactly
|
||||
what you entered. A figure already set for the same account, cost
|
||||
center and period is replaced, not added to.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Table withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Account</Table.Th>
|
||||
<Table.Th w={240}>Cost center</Table.Th>
|
||||
<Table.Th w={180}>Annual amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.key}>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Account"
|
||||
searchable
|
||||
data={accounts.data ?? []}
|
||||
value={r.accountId}
|
||||
onChange={(v) =>
|
||||
setRows((c) => c.map((x) => (x.key === r.key ? { ...x, accountId: v } : x)))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Unallocated"
|
||||
clearable
|
||||
searchable
|
||||
data={costCenters.data ?? []}
|
||||
value={r.costCenterId}
|
||||
onChange={(v) =>
|
||||
setRows((c) => c.map((x) => (x.key === r.key ? { ...x, costCenterId: v } : x)))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<NumberInput
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
value={r.annual}
|
||||
onChange={(v) =>
|
||||
setRows((c) =>
|
||||
c.map((x) => (x.key === r.key ? { ...x, annual: v === "" ? "" : Number(v) } : x)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() =>
|
||||
setRows((c) => [
|
||||
...c,
|
||||
{ key: Math.random().toString(36).slice(2), accountId: null, costCenterId: null, annual: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add row
|
||||
</Button>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button loading={save.isPending} disabled={usable === 0} onClick={() => save.mutate()}>
|
||||
Save figures
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
268
apps/finance-web/src/features/budgeting/CostCentersPage.tsx
Normal file
268
apps/finance-web/src/features/budgeting/CostCentersPage.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/AuthContext";
|
||||
import { FINANCE_PERMS } from "@/auth/permissions";
|
||||
import { PageHeader } from "@/shared/components/PageHeader";
|
||||
import { ApiErrorAlert } from "@/shared/components/ApiErrorAlert";
|
||||
import {
|
||||
createCostCenter,
|
||||
deleteCostCenter,
|
||||
fetchCostCenters,
|
||||
fetchUnlinkedUnits,
|
||||
} from "./api";
|
||||
|
||||
export function CostCentersPage() {
|
||||
const { can } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = can(FINANCE_PERMS.budget.manageCostCenter);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
|
||||
const centers = useQuery({
|
||||
queryKey: ["budgeting", "cost-centers"],
|
||||
queryFn: fetchCostCenters,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => deleteCostCenter(id),
|
||||
onMutate: () => setActionError(null),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["budgeting"] }),
|
||||
onError: setActionError,
|
||||
});
|
||||
|
||||
const rows = (centers.data ?? []).filter((c) =>
|
||||
search.trim()
|
||||
? `${c.code} ${c.name?.en ?? ""} ${c.name?.am ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
: true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cost centers"
|
||||
description="The dimension every cost can be attributed to. A center may stand for an IAM unit without Finance owning the org chart."
|
||||
actions={
|
||||
canManage && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreateOpen(true)}>
|
||||
New cost center
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ApiErrorAlert error={actionError} title="That change was refused" />
|
||||
|
||||
<Alert icon={<IconInfoCircle size={18} />} color="blue" variant="light" mb="md">
|
||||
<Text size="sm">
|
||||
Linking a center to a unit is optional and one-to-one — two centers on
|
||||
one unit would make “what did this department spend?” ambiguous. The
|
||||
unit's name is read from IAM at query time; Finance stores no copy.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search code or name"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={320}
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
{centers.isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Text c="dimmed">No cost centers yet.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={880}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={140}>Code</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th w={220}>Linked unit</Table.Th>
|
||||
<Table.Th w={180}>Manager</Table.Th>
|
||||
<Table.Th w={140}>Posting</Table.Th>
|
||||
<Table.Th w={60} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((c) => (
|
||||
<Table.Tr key={c.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" fw={c.isGroup ? 700 : 400}>{c.code}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text fw={c.isGroup ? 600 : 400}>{c.name?.en}</Text>
|
||||
{!c.isActive && (
|
||||
<Badge size="xs" color="red" variant="light">inactive</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{c.unitName?.en ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{c.managerName ?? "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{c.isGroup ? (
|
||||
<Text size="sm" c="dimmed">Group — totals children</Text>
|
||||
) : (
|
||||
<Text size="sm">Postable</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{canManage && (
|
||||
<Tooltip label="Refused once anything is posted to it, or if it has children">
|
||||
{/* Wrapped so the tooltip still fires — a silently dead
|
||||
control is worse than a disabled one. */}
|
||||
<Box>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={remove.isPending && remove.variables === c.id}
|
||||
onClick={() => remove.mutate(c.id)}
|
||||
aria-label={`Delete ${c.code}`}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<NewCostCenterModal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["budgeting"] });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NewCostCenterModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState("");
|
||||
const [en, setEn] = useState("");
|
||||
const [am, setAm] = useState("");
|
||||
const [isGroup, setIsGroup] = useState(false);
|
||||
const [parentId, setParentId] = useState<string | null>(null);
|
||||
const [unitId, setUnitId] = useState<string | null>(null);
|
||||
|
||||
// Only GROUP centers can take children — offering a leaf would be a
|
||||
// guaranteed rejection.
|
||||
const groups = useQuery({
|
||||
queryKey: ["budgeting", "cost-centers"],
|
||||
queryFn: fetchCostCenters,
|
||||
select: (all) =>
|
||||
all.filter((c) => c.isGroup).map((c) => ({ value: c.id, label: `${c.code} — ${c.name.en}` })),
|
||||
});
|
||||
|
||||
// Only units without a center yet — the server enforces one-to-one.
|
||||
const units = useQuery({
|
||||
queryKey: ["budgeting", "unlinked-units"],
|
||||
queryFn: fetchUnlinkedUnits,
|
||||
select: (all) => all.map((u) => ({ value: u.id, label: u.name?.en ?? u.id })),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createCostCenter({
|
||||
code: code.trim(),
|
||||
name: { en: en.trim(), am: am.trim() || en.trim() },
|
||||
parentId: parentId ?? undefined,
|
||||
unitId: unitId ?? undefined,
|
||||
isGroup,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCode(""); setEn(""); setAm(""); setParentId(null); setUnitId(null); setIsGroup(false);
|
||||
onCreated();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="New cost center" size="lg">
|
||||
<Stack>
|
||||
<ApiErrorAlert error={create.error} title="Could not create the cost center" />
|
||||
<TextInput label="Code" required value={code} onChange={(e) => setCode(e.currentTarget.value)} />
|
||||
<TextInput label="Name (English)" required value={en} onChange={(e) => setEn(e.currentTarget.value)} />
|
||||
<TextInput label="Name (Amharic)" value={am} onChange={(e) => setAm(e.currentTarget.value)} />
|
||||
<Select
|
||||
label="Parent (group centers only)"
|
||||
placeholder={groups.data?.length ? "None — a new root" : "No group centers exist yet"}
|
||||
clearable
|
||||
disabled={!groups.data?.length}
|
||||
data={groups.data ?? []}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
/>
|
||||
<Select
|
||||
label="Linked IAM unit"
|
||||
description="Only units without a cost center are listed"
|
||||
placeholder="None"
|
||||
clearable
|
||||
searchable
|
||||
data={units.data ?? []}
|
||||
value={unitId}
|
||||
onChange={setUnitId}
|
||||
/>
|
||||
<Switch
|
||||
label="Group cost center"
|
||||
description="Totals its children. Nothing can be posted or budgeted to it."
|
||||
checked={isGroup}
|
||||
onChange={(e) => setIsGroup(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
loading={create.isPending}
|
||||
disabled={!code.trim() || !en.trim()}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
161
apps/finance-web/src/features/budgeting/api.ts
Normal file
161
apps/finance-web/src/features/budgeting/api.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { financeApi } from "@/auth/http";
|
||||
import type { LocalizedName } from "@/shared/types";
|
||||
|
||||
export type CostCenter = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: LocalizedName;
|
||||
parentId: string | null;
|
||||
unitId: string | null;
|
||||
unitName: LocalizedName | null;
|
||||
managerEmployeeId: string | null;
|
||||
managerName: string | null;
|
||||
isGroup: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type CostCenterNode = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: LocalizedName;
|
||||
parentId: string | null;
|
||||
isGroup: boolean;
|
||||
isActive: boolean;
|
||||
children: CostCenterNode[];
|
||||
};
|
||||
|
||||
export type Budget = {
|
||||
id: string;
|
||||
fiscalYearId: string;
|
||||
name: string;
|
||||
status: "DRAFT" | "APPROVED" | "CLOSED";
|
||||
description: string | null;
|
||||
approvedBy: string | null;
|
||||
approvedAt: string | null;
|
||||
};
|
||||
|
||||
export type BudgetLine = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
accountCode: string;
|
||||
accountName: LocalizedName;
|
||||
costCenterId: string | null;
|
||||
costCenterCode: string | null;
|
||||
costCenterName: LocalizedName | null;
|
||||
fiscalPeriodId: string;
|
||||
periodNumber: number;
|
||||
periodName: LocalizedName;
|
||||
amount: number | string;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
export type BudgetDetail = Budget & { lines: BudgetLine[] };
|
||||
|
||||
export type VarianceRow = {
|
||||
accountId: string;
|
||||
accountCode: string;
|
||||
accountName: LocalizedName;
|
||||
accountType: string;
|
||||
costCenterId: string | null;
|
||||
costCenterCode: string | null;
|
||||
costCenterName: LocalizedName | null;
|
||||
budget: number;
|
||||
actual: number;
|
||||
committed: number;
|
||||
variance: number;
|
||||
remaining: number;
|
||||
percentUsed: number | null;
|
||||
isOverBudget: boolean;
|
||||
isUnbudgeted: boolean;
|
||||
};
|
||||
|
||||
export const BUDGET_STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: "gray",
|
||||
APPROVED: "green",
|
||||
CLOSED: "blue",
|
||||
};
|
||||
|
||||
export const fetchCostCenters = async (): Promise<CostCenter[]> => {
|
||||
const { data } = await financeApi.get("/budgeting/cost-centers");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchCostCenterTree = async (
|
||||
search?: string,
|
||||
): Promise<CostCenterNode[]> => {
|
||||
const { data } = await financeApi.get("/budgeting/cost-centers/tree", {
|
||||
params: search ? { search } : undefined,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchUnlinkedUnits = async (): Promise<
|
||||
{ id: string; name: LocalizedName }[]
|
||||
> => {
|
||||
const { data } = await financeApi.get("/budgeting/cost-centers/unlinked-units");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createCostCenter = async (payload: {
|
||||
code: string;
|
||||
name: { en: string; am: string };
|
||||
parentId?: string;
|
||||
unitId?: string;
|
||||
isGroup?: boolean;
|
||||
}): Promise<CostCenter> => {
|
||||
const { data } = await financeApi.post("/budgeting/cost-centers", payload);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteCostCenter = async (id: string): Promise<void> => {
|
||||
await financeApi.delete(`/budgeting/cost-centers/${id}`);
|
||||
};
|
||||
|
||||
export const fetchBudgets = async (): Promise<Budget[]> => {
|
||||
const { data } = await financeApi.get("/budgeting/budgets");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchBudget = async (id: string): Promise<BudgetDetail> => {
|
||||
const { data } = await financeApi.get(`/budgeting/budgets/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createBudget = async (payload: {
|
||||
fiscalYearId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<Budget> => {
|
||||
const { data } = await financeApi.post("/budgeting/budgets", payload);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const spreadBudget = async (
|
||||
id: string,
|
||||
entries: {
|
||||
accountId: string;
|
||||
costCenterId?: string;
|
||||
annualAmount: number;
|
||||
note?: string;
|
||||
}[],
|
||||
): Promise<BudgetDetail> => {
|
||||
const { data } = await financeApi.post(`/budgeting/budgets/${id}/spread`, {
|
||||
entries,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const approveBudget = async (id: string): Promise<BudgetDetail> => {
|
||||
const { data } = await financeApi.post(`/budgeting/budgets/${id}/approve`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchVariance = async (
|
||||
id: string,
|
||||
fiscalPeriodId?: string,
|
||||
): Promise<VarianceRow[]> => {
|
||||
const { data } = await financeApi.get(`/budgeting/budgets/${id}/variance`, {
|
||||
params: fiscalPeriodId ? { fiscalPeriodId } : undefined,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
469
apps/finance-web/src/features/cutover/CutoverPage.tsx
Normal file
469
apps/finance-web/src/features/cutover/CutoverPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
apps/finance-web/src/features/cutover/api.ts
Normal file
173
apps/finance-web/src/features/cutover/api.ts
Normal 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;
|
||||
}
|
||||
259
apps/finance-web/src/features/dashboard/DashboardPage.tsx
Normal file
259
apps/finance-web/src/features/dashboard/DashboardPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
335
apps/finance-web/src/features/journals/JournalDetailPage.tsx
Normal file
335
apps/finance-web/src/features/journals/JournalDetailPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
184
apps/finance-web/src/features/journals/JournalsPage.tsx
Normal file
184
apps/finance-web/src/features/journals/JournalsPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
308
apps/finance-web/src/features/journals/NewJournalPage.tsx
Normal file
308
apps/finance-web/src/features/journals/NewJournalPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
apps/finance-web/src/features/journals/api.ts
Normal file
80
apps/finance-web/src/features/journals/api.ts
Normal 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}`);
|
||||
};
|
||||
63
apps/finance-web/src/features/journals/types.ts
Normal file
63
apps/finance-web/src/features/journals/types.ts
Normal 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",
|
||||
};
|
||||
774
apps/finance-web/src/features/payables/PayablesPage.tsx
Normal file
774
apps/finance-web/src/features/payables/PayablesPage.tsx
Normal 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 & post
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip label="Approving is a separate authorisation from entering the bill. Ask someone who holds it.">
|
||||
<Button disabled>Approve & 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>
|
||||
);
|
||||
}
|
||||
449
apps/finance-web/src/features/payables/PayrollPage.tsx
Normal file
449
apps/finance-web/src/features/payables/PayrollPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
apps/finance-web/src/features/payables/api.ts
Normal file
264
apps/finance-web/src/features/payables/api.ts
Normal 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;
|
||||
};
|
||||
344
apps/finance-web/src/features/periods/FiscalPeriodsPage.tsx
Normal file
344
apps/finance-web/src/features/periods/FiscalPeriodsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/finance-web/src/features/periods/api.ts
Normal file
77
apps/finance-web/src/features/periods/api.ts
Normal 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;
|
||||
};
|
||||
390
apps/finance-web/src/features/reports/ReportsPage.tsx
Normal file
390
apps/finance-web/src/features/reports/ReportsPage.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
502
apps/finance-web/src/features/revenue/ReceivablesPage.tsx
Normal file
502
apps/finance-web/src/features/revenue/ReceivablesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
373
apps/finance-web/src/features/revenue/RevenueMappingsPage.tsx
Normal file
373
apps/finance-web/src/features/revenue/RevenueMappingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
apps/finance-web/src/features/revenue/api.ts
Normal file
164
apps/finance-web/src/features/revenue/api.ts
Normal 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",
|
||||
};
|
||||
24
apps/finance-web/src/i18n/am.json
Normal file
24
apps/finance-web/src/i18n/am.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "የኢዲአር ፋይናንስ",
|
||||
"overview": "አጠቃላይ እይታ"
|
||||
},
|
||||
"nav": {
|
||||
"accounts": "የሒሳብ መዝገብ",
|
||||
"journals": "የመዝገብ ግቤቶች",
|
||||
"periods": "የሒሳብ ዘመናት",
|
||||
"reports": "ሪፖርቶች"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "ግባ",
|
||||
"email": "ኢሜይል ወይም የተጠቃሚ ስም",
|
||||
"password": "የይለፍ ቃል"
|
||||
},
|
||||
"common": {
|
||||
"debit": "ዴቢት",
|
||||
"credit": "ክሬዲት",
|
||||
"total": "ጠቅላላ",
|
||||
"balance": "ቀሪ ሒሳብ",
|
||||
"currency": "ብር"
|
||||
}
|
||||
}
|
||||
24
apps/finance-web/src/i18n/en.json
Normal file
24
apps/finance-web/src/i18n/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
24
apps/finance-web/src/i18n/index.ts
Normal file
24
apps/finance-web/src/i18n/index.ts
Normal 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;
|
||||
21
apps/finance-web/src/index.css
Normal file
21
apps/finance-web/src/index.css
Normal 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%;
|
||||
}
|
||||
51
apps/finance-web/src/main.tsx
Normal file
51
apps/finance-web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
31
apps/finance-web/src/shared/components/ApiErrorAlert.tsx
Normal file
31
apps/finance-web/src/shared/components/ApiErrorAlert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
apps/finance-web/src/shared/components/AppShell.tsx
Normal file
97
apps/finance-web/src/shared/components/AppShell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
apps/finance-web/src/shared/components/ColorSchemeSync.tsx
Normal file
35
apps/finance-web/src/shared/components/ColorSchemeSync.tsx
Normal 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;
|
||||
}
|
||||
77
apps/finance-web/src/shared/components/CommandPalette.tsx
Normal file
77
apps/finance-web/src/shared/components/CommandPalette.tsx
Normal 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();
|
||||
26
apps/finance-web/src/shared/components/PageHeader.tsx
Normal file
26
apps/finance-web/src/shared/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/finance-web/src/shared/lib/formatMoney.ts
Normal file
41
apps/finance-web/src/shared/lib/formatMoney.ts
Normal 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) : "");
|
||||
15
apps/finance-web/src/shared/lib/localizedName.ts
Normal file
15
apps/finance-web/src/shared/lib/localizedName.ts
Normal 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 || "";
|
||||
};
|
||||
307
apps/finance-web/src/shared/nav/nav-model.ts
Normal file
307
apps/finance-web/src/shared/nav/nav-model.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
27
apps/finance-web/src/shared/types.ts
Normal file
27
apps/finance-web/src/shared/types.ts
Normal 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
12
apps/finance-web/src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
24
apps/finance-web/tsconfig.app.json
Normal file
24
apps/finance-web/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
apps/finance-web/tsconfig.json
Normal file
7
apps/finance-web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
16
apps/finance-web/tsconfig.node.json
Normal file
16
apps/finance-web/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
29
apps/finance-web/vite.config.ts
Normal file
29
apps/finance-web/vite.config.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user