Initial commit: multi-company financial management system

Root company app (src/, client/) — GL foundation, AR, and AP:
- Segmented Chart of Accounts (Sage 300-style), double-entry PostingService with
  three-layer balance enforcement (app validation, CHECK constraints, deferred
  DB trigger), gap-free document numbering, RBAC with branch restrictions.
- Accounts Receivable: Customers, Invoices, Credit Notes, Receipts with
  allocation, all posting through PostingService.
- Accounts Payable: Suppliers, Supplier Invoices, Debit Notes, Payments with
  allocation, mirroring AR's posting pattern.
- Tax Codes shared between AR and AP.
- Company-app login is a token handoff from the Account Manager (RS256,
  single-use, company-scoped) rather than a local password.
- Frontend reskinned to the approved mockup: navy sidebar, dashboard, tree-view
  Chart of Accounts, Customer/Supplier 360 pages.

Account Manager (manager/, manager/client/) — central login gateway:
- Password + TOTP 2FA + Google/Microsoft OAuth, company picker, and the RS256
  handoff token issuance that the company app verifies.

Covered by unit + integration tests against a real Postgres database (GL
balance invariants, AR/AP posting correctness and allocation limits, the full
handoff exchange including replay/cross-company/expiry/forgery rejection).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tq2DbEvzHzs13jDFhLaX44
This commit is contained in:
Joshua Oosthuizen
2026-09-03 13:27:19 +02:00
co-authored by Claude Sonnet 5
commit 4acbc03b35
279 changed files with 38537 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/cfm?schema=public"
JWT_ACCESS_SECRET="change-me-access-secret"
JWT_REFRESH_SECRET="change-me-refresh-secret"
JWT_ACCESS_TTL="15m"
JWT_REFRESH_TTL="7d"
PORT="4000"
COMPANY_SLUG="demo"
# One or more (comma-separated) paths to public keys copied from the Account Manager's
# manager/keys/handoff-public.pem, used to verify incoming handoff tokens.
HANDOFF_PUBLIC_KEY_PATHS="./keys/handoff-public.pem"
+46
View File
@@ -0,0 +1,46 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "import"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"env": {
"node": true,
"es2022": true,
"jest": true
},
"rules": {
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.property.name='journalEntry'][callee.property.name=/^(create|createMany|createManyAndReturn|update|updateMany|upsert|delete|deleteMany)$/]",
"message": "Only src/modules/gl/posting.service.ts may write JournalEntry rows. Build a JournalEntryDraft and call PostingService.postEntry()/reverseEntry() instead."
},
{
"selector": "CallExpression[callee.object.property.name='journalEntryLine'][callee.property.name=/^(create|createMany|createManyAndReturn|update|updateMany|upsert|delete|deleteMany)$/]",
"message": "Only src/modules/gl/posting.service.ts may write JournalEntryLine rows. Build a JournalEntryDraft and call PostingService.postEntry()/reverseEntry() instead."
}
]
},
"overrides": [
{
"files": ["src/modules/gl/posting.service.ts"],
"rules": {
"no-restricted-syntax": "off"
}
},
{
"files": ["tests/integration/posting.balance.spec.ts"],
"rules": {
"no-restricted-syntax": "off"
}
}
]
}
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.env
*.log
coverage/
client/node_modules/
client/dist/
.idea/
keys/
+153
View File
@@ -0,0 +1,153 @@
# Phase 1.5 — Visual Redesign: Manual Test Checklist
Reskinned the company-app frontend to match `temp/mockups/Cross Financial Manage Mockup.png`
(navy sidebar, blue accent, card-based content) and added three new screens (Dashboard,
Chart of Accounts tree view, Reports Centre shell, GL account drill-down). No backend or
schema changes in this phase — everything below should work against data you already have.
Tick each box as you go. If something fails, note what you saw under it — that's what I'll fix.
## 0. Start everything
```
# Terminal 1 — company API
cd D:\Webstorm\cross-financial-management
npm run dev # http://localhost:5000
# Terminal 2 — company frontend
cd D:\Webstorm\cross-financial-management\client
npm run dev # http://localhost:5173
# Terminal 3 — Manager API
cd D:\Webstorm\cross-financial-management\manager
npm run dev # http://localhost:5100
# Terminal 4 — Manager frontend
cd D:\Webstorm\cross-financial-management\manager\client
npm run dev # http://localhost:5174
```
Manager login: `joosth2002@icloud.com` / `ChangeMe123!` (change this password once you're
through testing — see `manager/SETUP.md`).
- [ ] All four `npm run dev` terminals started with no errors
## 1. Sign-in / handoff flow (regression — not new, but the entry point to everything below)
- [ ] Open `http://localhost:5174`, sign in with the Manager credentials above
- [ ] Company picker shows "Demo Company"
- [ ] Clicking it redirects to `http://localhost:5173` and you land **already signed in** —
no login form appears in the company app
- [ ] Refreshing the company app keeps you signed in (session persists)
- [ ] Signing out (avatar menu, top-right, in the company app) redirects you back to the
Manager rather than a dead `/login` page
## 2. Sidebar & top bar (new look)
- [ ] Left sidebar renders navy with the "Cross Financial / MANAGER" logo mark at the top
- [ ] Nav items are grouped under **General Ledger**, **Reports**, **Administration** with a
standalone **Dashboard** link above them
- [ ] Each nav item has an icon; the currently active page is highlighted (lighter navy pill,
blue icon, white text)
- [ ] Clicking every visible nav item navigates correctly and updates the highlight
- [ ] Top bar (white, right-aligned) shows the Branch switcher and your avatar with initials
- [ ] Avatar menu shows your name + email and a working **Sign out**
- [ ] Nav items you don't have permission for are hidden (test with a restricted user if you
have one, or trust that `hasPermission` filtering is unchanged from before)
## 3. Dashboard (new — this is now the `/` landing page)
- [ ] Landing on `http://localhost:5173/` after sign-in shows the new Dashboard, not the
journal entry list
- [ ] Four KPI tiles render: **Posted Entries**, **Draft Entries**, **Total Posted Movement**,
**Total Entries** — numbers should match what you'd expect from your seeded/test data
- [ ] "Posted Movement by Account Type" bar list appears once you have at least one posted
entry, with one bar per account type that has activity (ASSET/LIABILITY/etc.), and the
relative bar widths look proportionate to the amounts shown
- [ ] With **zero** posted entries (fresh DB), the panel shows "No posted entries yet." instead
of breaking
- [ ] "Quick Actions" only shows actions you have permission for, and each one navigates to the
right page (New Manual Entry / Chart of Accounts / Journals / Users & Roles)
- [ ] "Recent Activity" lists your most recent journal entries (any status) with date, entry
number, description, amount, and a status pill; clicking a row opens that entry's detail
page
- [ ] Switching the Branch switcher between "Main" and a specific branch updates the dashboard
figures to that branch's data
## 4. Chart of Accounts (rebuilt as a grouped/collapsible tree)
- [ ] Accounts are grouped under collapsible headers: ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
- [ ] Clicking a group header collapses/expands its accounts, and the chevron icon flips
accordingly
- [ ] Each group header shows an account count chip (e.g. "12 accounts")
- [ ] Each account row shows: account code (monospace), account name, normal balance
(DEBIT/CREDIT), a control-account pill when applicable, and an Active/Closed status pill
- [ ] The search box filters accounts by code or name across all groups as you type
- [ ] "New Account" still opens the existing account-creation dialog and newly created accounts
appear in the correct group after saving
- [ ] Groups with zero matching accounts (e.g. after a narrow search) don't render or show
cleanly rather than looking broken
## 5. General Ledger — "All Entries" tab (regression)
- [ ] `Journal Entries` in the sidebar opens a page titled **General Ledger** with two tabs:
"All Entries" and "By Account"
- [ ] "All Entries" behaves exactly as before: same grid, same columns, sortable by date,
clicking a row opens the entry detail page
- [ ] "New Manual Entry" button still only shows if you have `gl.journal.post`
## 6. General Ledger — "By Account" tab (new — account-centric drill-down)
- [ ] Selecting an account from the dropdown loads its posted transaction history
- [ ] A running balance column updates correctly line by line (increases/decreases match the
account's normal balance direction — e.g. a debit-normal account's balance goes up on
debits, down on credits)
- [ ] Restricting the From/To date fields narrows the rows and the running balance still looks
correct for the restricted range
- [ ] An account with no posted activity shows "No posted transactions for this account in the
selected range." instead of an empty broken table
- [ ] Before selecting an account, you see a prompt to pick one rather than an empty table
- [ ] Switching the Branch switcher changes which entries are considered
## 7. Journal Entry form (visual alignment + new static footer)
- [ ] Form still works exactly as before: adding/removing lines, debit/credit mutual exclusion
per line, the live "Balanced ✓ / Not balanced" indicator, and Post Entry / Cancel
- [ ] Two new footer cards appear below the line table: **Attachments** ("+ Add Attachment") and
**Notes** ("+ Add Note") — these are intentionally disabled/inert placeholders for now,
just confirm they render without errors and don't block submission
- [ ] Posting a valid balanced entry still redirects to its detail page as before
## 8. Reports Centre (new shell — no live reports yet)
- [ ] `Reports Centre` in the sidebar opens a page with four categories: Financial,
Receivables, Payables, Management
- [ ] Each category shows report cards (e.g. Trial Balance, Profit & Loss, Aged Receivables)
with a name, short description, and a "Coming soon" chip
- [ ] Cards are visually disabled (dimmed, non-interactive) — this is expected; no report is
wired up to real data yet
## 9. Other admin screens — style regression only (Segments, Journals, Fiscal Years, Users)
These pages weren't rebuilt, but they now render on the new theme (cards instead of flat
panels, rounded corners, updated table header style). Confirm nothing looks broken:
- [ ] Segments & Branches — page renders, "New Segment"/"Add Value" dialogs still work
- [ ] Journals — grid renders, "New Journal" dialog still works
- [ ] Fiscal Years — year buttons + period grid still work, "Create Year" still works
- [ ] Users & Roles — both tabs (Users / Role Permissions) still work, toggling a user's Active
switch still works
## 10. General polish
- [ ] Page titles, card borders, and table headers look consistent across every screen you
visited (uppercase gray table headers, light card borders, no harsh drop-shadows)
- [ ] No console errors in the browser dev tools on any page you visited
- [ ] Resizing the browser window narrower doesn't visibly break the layout (some horizontal
scroll on wide tables/grids is fine — full mobile responsiveness isn't a goal yet)
---
Once everything above is ticked (or you've told me what's broken), the plan's next phase is
Accounts Receivable — customers, invoices, credit notes, receipts — built directly in this
visual language.
+113
View File
@@ -0,0 +1,113 @@
# Phase 2 — Accounts Receivable: Manual Test Checklist
Adds Customers, Tax Codes, AR Invoices, Credit Notes, and Receipts with allocation — all
posting through the existing `PostingService`, so every one of these creates a real,
balanced GL entry you can trace from the Journal Entries screen. Backend correctness
(balanced postings, tax calc, allocation limits) is already covered by
`tests/integration/ar.posting.spec.ts` (7 tests, passing) and was additionally verified by
hand over the real HTTP API end-to-end. This checklist is for the parts only a human can
judge: does the UI make sense, does the data look right, does it match how the business
actually invoices customers.
Start the same four dev servers as Phase 1.5 (see `PHASE_1.5_TEST_PLAN.md` §0) and sign in
through the Manager.
## 0. New nav items
- [ ] Sidebar shows a new **Sales** section above General Ledger: Customers, Invoices, Receipts
- [ ] Administration section has a new **Tax Codes** item at the bottom
- [ ] Two tax codes are pre-seeded: STD (15%) and ZERO (0%) — visible at `/admin/tax-codes`
## 1. Tax Codes
- [ ] "New Tax Code" creates a code (e.g. `EXPORT`, `Zero-Rated Export`, `0`) and it appears in the grid
- [ ] Toggling a tax code's Active switch off, then back on, works
- [ ] A tax code is usable immediately on new invoice/credit note lines without a page refresh
## 2. Customers
- [ ] "New Customer" requires a branch selection; creating one without a branch shows an error
- [ ] A created customer appears in the Customers grid with the right code/name/credit limit/terms
- [ ] Switching the top-right Branch switcher to a single branch filters the Customers grid to
that branch only; switching back to Main shows all
- [ ] Clicking a customer row opens their Customer 360 page
## 3. Customer 360
- [ ] Header shows the customer's name and an Active/Closed status pill
- [ ] Four summary tiles: Outstanding Balance, Overdue (30+ days), Credit Limit, Payment Terms
- [ ] A brand-new customer with no invoices shows R0.00 everywhere, not blank or broken
- [ ] Contact Details card shows code/email/phone/tax number (or "—" for anything empty)
- [ ] Three tabs — Invoices / Payments / Credit Notes — each show "No … yet." until you create one
- [ ] "New Invoice" and "Receive Payment" buttons only appear if you have the AR permissions
(test with a restricted role, or trust the code path — both gate on `ar.invoice.manage` /
`ar.receipt.manage`)
## 4. Creating and posting an invoice
- [ ] From Customer 360, "New Invoice" opens the invoice form with that customer pre-selected
- [ ] Due date auto-fills from invoice date + the customer's payment terms and is read-only
- [ ] Adding a line, picking a revenue account (only INCOME-type accounts for that customer's
branch are offered) and a tax code updates that line's Amount live
- [ ] Applying the STD (15%) tax code to a R1,000 line shows R150 tax and R1,150 line total
- [ ] The footer Subtotal / Tax / Total roll up correctly across multiple lines with different
tax codes (including a line with no tax code — contributes 0 to tax)
- [ ] "Save Draft" creates the invoice with status DRAFT and does **not** create a GL entry —
confirm by checking General Ledger's entry count is unchanged
- [ ] From the draft's detail page, "Post Invoice" posts it — status flips to POSTED, and a new
entry appears in General Ledger (journal `SALES`) that is balanced (Debits = Credits)
- [ ] Opening that GL entry shows: one debit line to the AR Control account for the invoice
total, one or more credit lines to the revenue account(s) for the subtotal(s), and (if
tax was applied) a credit line to Output Tax Payable for the tax total
- [ ] "Post Invoice" directly from the form (skipping the separate detail-page click) also works
and lands you on the now-posted invoice
- [ ] A draft invoice can be **Voided** (status → VOID, no GL entry ever created); a posted
invoice has no Void option — only a credit note can reverse it
- [ ] The invoice now appears in the customer's Invoices tab and in the global Invoices list,
with the correct balance and status pill
## 5. Receiving a payment
- [ ] From Customer 360, "Receive Payment" opens the receipt form with the customer pre-selected
- [ ] "Deposited to" only offers accounts marked as a Bank control account
- [ ] Saving and posting a receipt for the invoice's exact total creates a balanced GL entry:
debit to Bank, credit to AR Control, journal `CASH_RECEIPT`
- [ ] The posted receipt's detail page shows an "Allocate Against Invoices" panel listing the
customer's open (posted, balance > 0) invoices
- [ ] Clicking "Full" next to an invoice fills in its exact remaining balance (capped at what's
still unallocated on the receipt)
- [ ] Applying the allocation reduces both the invoice's balance and the receipt's unallocated
amount by the applied amount — reload both pages and confirm the numbers persist
- [ ] Fully allocating a receipt makes the "Allocate" panel show "Fully allocated." instead of
the invoice table
- [ ] Attempting to allocate more than a receipt's unallocated amount, or more than an invoice's
balance, is rejected with a clear error and changes nothing
- [ ] A part-paid invoice shows the correct partial balance on its detail page and in the
Customer 360 Invoices tab
## 6. Credit notes
- [ ] Create a credit note for the same customer, one line mirroring a posted invoice line
(same account, same tax code, same amount)
- [ ] Posting it creates a GL entry that is the mirror image of the invoice's: debit to the
revenue account (+ debit to Output Tax if taxed), credit to AR Control — and is balanced
- [ ] The credit note's detail page shows the same "Allocate Against Invoices" panel; allocating
it against the open invoice reduces both the invoice's balance and the credit note's
remaining balance
- [ ] A draft credit note can be voided; a posted one cannot
## 7. Cross-cutting checks
- [ ] Every invoice/receipt/credit note number follows its own series (`ARINV-2026-######`,
`ARRCT-2026-######`, `ARCN-2026-######`) and never repeats or skips oddly between runs
- [ ] The Dashboard's "Posted Movement by Account Type" bars now reflect AR activity (ASSET bar
grows from AR Control + Bank movement, INCOME bar grows from Sales)
- [ ] Switching to a specific branch (not Main) on any AR screen shows only that branch's
customers/invoices/receipts
- [ ] No console errors in the browser dev tools while working through this checklist
---
Once this is ticked (or you've flagged issues), the plan's next step is Accounts Payable —
Suppliers, Supplier Invoices, Debit Notes, Payments — built the same way, followed by Tax
reporting and then Stock Control.
+90
View File
@@ -0,0 +1,90 @@
# Phase 3 — Accounts Payable: Manual Test Checklist
Mirrors Accounts Receivable exactly, but for money owed to suppliers instead of money owed by
customers: Suppliers, Supplier Invoices (Bills), Debit Notes, and Payments with allocation —
same Draft → Post workflow, same shared `PostingService`, same Tax Codes. Backend correctness
is already covered by `tests/integration/ap.posting.spec.ts` (7 tests, passing alongside AR's
7 and GL's 9) and was additionally verified over the real HTTP API by hand (create supplier →
create/post invoice → create/post payment → allocate → invoice balance hit zero). This
checklist is for the parts only a human can judge.
Start the same four dev servers as before and sign in through the Manager.
## 0. New nav items
- [ ] Sidebar shows a new **Purchasing** section (between Sales and General Ledger): Suppliers,
Bills, Payments
## 1. Suppliers
- [ ] "New Supplier" requires a branch selection; creating one without a branch shows an error
- [ ] A created supplier appears in the Suppliers grid
- [ ] Branch switcher filters the Suppliers grid the same way it does Customers
- [ ] Clicking a supplier opens their detail page (the AP equivalent of Customer 360)
## 2. Supplier detail page
- [ ] Header shows the supplier's name and an Active/Closed status pill
- [ ] Three summary tiles: Outstanding Balance, Overdue (30+ days), Payment Terms
- [ ] A brand-new supplier with no invoices shows R0.00 everywhere
- [ ] Contact Details card shows code/email/phone/tax number (or "—" for anything empty)
- [ ] Three tabs — Invoices / Payments / Debit Notes — each show "No … yet." until you create one
- [ ] "New Invoice" and "Make Payment" buttons only appear if you have the AP permissions
## 3. Creating and posting a supplier invoice (Bill)
- [ ] From the supplier's page, "New Invoice" opens the form with that supplier pre-selected
- [ ] Due date auto-fills from invoice date + the supplier's payment terms and is read-only
- [ ] Adding a line, picking an expense account (only EXPENSE-type accounts for that supplier's
branch are offered) and a tax code updates that line's Amount live
- [ ] Applying the STD (15%) tax code to a R1,000 line shows R150 tax and R1,150 line total
- [ ] "Save Draft" creates the invoice with status DRAFT and does **not** create a GL entry
- [ ] "Post Invoice" posts it — status flips to POSTED, and a new balanced entry appears in
General Ledger under journal `PURCHASES`
- [ ] Opening that entry shows: debit line(s) to the expense account(s) for the subtotal, a
debit line to Input Tax Receivable for the tax (if any), and a credit line to Accounts
Payable Control for the total
- [ ] A draft invoice can be **Voided**; a posted invoice has no Void option — only a debit note
can reverse it
- [ ] The invoice appears in the supplier's Invoices tab and in the global Bills list
## 4. Making a payment
- [ ] From the supplier's page, "Make Payment" opens the form with the supplier pre-selected
- [ ] "Paid from" only offers accounts marked as a Bank control account
- [ ] Posting a payment for the invoice's exact total creates a balanced GL entry: debit to
Accounts Payable Control, credit to Bank, journal `CASH_PAYMENT`
- [ ] The posted payment's detail page shows an "Allocate Against Invoices" panel listing the
supplier's open (posted, balance > 0) invoices
- [ ] "Full" fills in the invoice's exact remaining balance, capped at what's still unallocated
on the payment
- [ ] Applying the allocation reduces both the invoice's balance and the payment's unallocated
amount — reload both pages and confirm the numbers persist
- [ ] Over-allocating (more than the payment's unallocated amount, or more than an invoice's
balance) is rejected with a clear error and changes nothing
- [ ] A part-paid invoice shows the correct partial balance on its detail page and on the
supplier's Invoices tab
## 5. Debit notes
- [ ] Create a debit note for the same supplier, one line mirroring a posted invoice line
- [ ] Posting it creates the mirror-image GL entry: debit to Accounts Payable Control, credit
to the expense account (+ credit to Input Tax if taxed) — and is balanced
- [ ] The debit note's detail page shows the same allocation panel; allocating it against the
open invoice reduces both the invoice's balance and the debit note's remaining balance
- [ ] A draft debit note can be voided; a posted one cannot
## 6. Cross-cutting checks
- [ ] Every invoice/payment/debit-note number follows its own series (`APINV-2026-######`,
`APPMT-2026-######`, `APDN-2026-######`) and never repeats or skips oddly
- [ ] The Dashboard's "Posted Movement by Account Type" bars now also reflect AP activity
(LIABILITY bar grows from AP Control movement, EXPENSE bar grows from bills)
- [ ] Switching to a specific branch shows only that branch's suppliers/invoices/payments
- [ ] No console errors in the browser dev tools while working through this checklist
---
Once this is ticked, the plan's next step is Tax reporting (VAT/output-input reconciliation),
then Stock Control + Procurement Matching (GRV/PPV, incorporating the Posting Ledger reference
already reviewed).
+46
View File
@@ -0,0 +1,46 @@
# Setup — Phase 1 (Foundation)
## Prerequisites
- Node.js 18+ (tested on v22)
- A reachable PostgreSQL 15+ database (this project does not use Docker)
## Login
This app has no local login screen — see `../manager/`. Sign-in happens once at the central
Account Manager (2FA, Google, Microsoft), which hands this app a short-lived signed token
(`POST /api/auth/handoff`) instead of a password. `../manager/keys/handoff-public.pem` must be
copied into `keys/handoff-public.pem` here (see step 4) before that will work.
## First-time setup
1. Copy `.env.example` to `.env` and fill in a real `DATABASE_URL`, two random JWT secrets
(e.g. `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`), and
`COMPANY_SLUG` (must match the `slug` of this company's `Company` row in the Manager). `.env`
is gitignored — never put real secrets in `.env.example`.
2. `npm install`
3. `npm run prisma:migrate` — applies all migrations, including the hand-written
`prisma/migrations/*_add_journal_balance_trigger/migration.sql` (the double-entry DB backstop;
source copy also kept at `prisma/manual-sql/add_journal_balance_trigger.sql` for reference).
4. `mkdir keys && cp ../manager/keys/handoff-public.pem keys/` (after running
`npm run generate-handoff-keys` in `../manager/` at least once).
5. `npm run prisma:seed` — creates the default 4-segment account structure, default Chart of
Accounts across two example branches (HQ, AMA), the GJ general journal, the 2026 fiscal year,
default roles/permissions, and an admin user (`joosth2002@icloud.com`, no local password —
this email must match the Manager `Person` who signs in).
6. `npm run dev` — starts the API on `http://localhost:4000` (see `PORT` in `.env`).
## Verifying it works
- `npm test` — unit tests (no DB required; validates PostingService's pre-write balance checks).
- `npm run test:integration` — integration tests against the real DB: posts balanced/unbalanced
entries through the full stack (including a direct raw-SQL attempt that proves the DB trigger
backstop works even if `PostingService` is bypassed), and exchanges real handoff tokens (minted
with the Manager's real private key) to prove sign-in, replay protection, and cross-company
rejection all work end-to-end.
- `npm run smoke` — seeds two sample balanced entries (HQ and AMA), confirms an unbalanced one is
rejected, and prints a Main-level trial balance check.
- `GET /health` — basic liveness check.
## Adding a new migration
Prisma's schema DSL can't express multi-row CHECK constraints or triggers, so any future raw-SQL
addition (e.g. more constraints) follows the same pattern used for the balance trigger:
1. `npx prisma migrate dev --create-only --name <description>`
2. Hand-edit the generated `migration.sql`
3. `npx prisma migrate dev` to apply it
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL="http://localhost:5000/api"
VITE_MANAGER_URL="http://localhost:5174"
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<title>Cross Financial Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2964
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@hookform/resolvers": "^5.9.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@mui/x-data-grid": "^9.12.0",
"@tanstack/react-query": "^5.102.8",
"axios": "^1.20.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.87.0",
"react-router-dom": "^7.18.3",
"zod": "^3.25.76",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+15
View File
@@ -0,0 +1,15 @@
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider, CssBaseline } from '@mui/material';
import { theme } from './theme';
import { AppRouter } from './routes/AppRouter';
export default function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</ThemeProvider>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { apiClient } from './client';
import type { APAllocation, APDebitNote } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchDebitNotes(branchScope: BranchSelection, supplierId?: number): Promise<APDebitNote[]> {
const response = await apiClient.get<APDebitNote[]>('/ap/debit-notes', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope), supplierId },
});
return response.data;
}
export async function fetchDebitNote(id: number): Promise<APDebitNote> {
const response = await apiClient.get<APDebitNote>(`/ap/debit-notes/${id}`);
return response.data;
}
export interface DebitNoteLineInput {
description: string;
quantity: number;
unitPrice: number;
expenseGLAccountId: number;
taxCodeId?: number;
}
export interface CreateDebitNoteInput {
supplierId: number;
debitNoteDate: string;
reference?: string;
reason?: string;
lines: DebitNoteLineInput[];
}
export async function createDebitNote(input: CreateDebitNoteInput): Promise<APDebitNote> {
const response = await apiClient.post<APDebitNote>('/ap/debit-notes', input);
return response.data;
}
export async function postDebitNote(id: number): Promise<APDebitNote> {
const response = await apiClient.post<APDebitNote>(`/ap/debit-notes/${id}/post`);
return response.data;
}
export async function voidDebitNote(id: number): Promise<APDebitNote> {
const response = await apiClient.post<APDebitNote>(`/ap/debit-notes/${id}/void`);
return response.data;
}
export async function allocateDebitNote(id: number, allocations: { invoiceId: number; amount: number }[]): Promise<APAllocation[]> {
const response = await apiClient.post<APAllocation[]>(`/ap/debit-notes/${id}/allocations`, { allocations });
return response.data;
}
+46
View File
@@ -0,0 +1,46 @@
import { apiClient } from './client';
import type { APInvoice } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchInvoices(branchScope: BranchSelection, supplierId?: number): Promise<APInvoice[]> {
const response = await apiClient.get<APInvoice[]>('/ap/invoices', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope), supplierId },
});
return response.data;
}
export async function fetchInvoice(id: number): Promise<APInvoice> {
const response = await apiClient.get<APInvoice>(`/ap/invoices/${id}`);
return response.data;
}
export interface InvoiceLineInput {
description: string;
quantity: number;
unitPrice: number;
expenseGLAccountId: number;
taxCodeId?: number;
}
export interface CreateInvoiceInput {
supplierId: number;
invoiceDate: string;
dueDate: string;
supplierReference?: string;
lines: InvoiceLineInput[];
}
export async function createInvoice(input: CreateInvoiceInput): Promise<APInvoice> {
const response = await apiClient.post<APInvoice>('/ap/invoices', input);
return response.data;
}
export async function postInvoice(id: number): Promise<APInvoice> {
const response = await apiClient.post<APInvoice>(`/ap/invoices/${id}/post`);
return response.data;
}
export async function voidInvoice(id: number): Promise<APInvoice> {
const response = await apiClient.post<APInvoice>(`/ap/invoices/${id}/void`);
return response.data;
}
+43
View File
@@ -0,0 +1,43 @@
import { apiClient } from './client';
import type { APAllocation, APPayment } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchPayments(branchScope: BranchSelection, supplierId?: number): Promise<APPayment[]> {
const response = await apiClient.get<APPayment[]>('/ap/payments', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope), supplierId },
});
return response.data;
}
export async function fetchPayment(id: number): Promise<APPayment> {
const response = await apiClient.get<APPayment>(`/ap/payments/${id}`);
return response.data;
}
export interface CreatePaymentInput {
supplierId: number;
paymentDate: string;
amount: number;
bankGLAccountId: number;
reference?: string;
}
export async function createPayment(input: CreatePaymentInput): Promise<APPayment> {
const response = await apiClient.post<APPayment>('/ap/payments', input);
return response.data;
}
export async function postPayment(id: number): Promise<APPayment> {
const response = await apiClient.post<APPayment>(`/ap/payments/${id}/post`);
return response.data;
}
export async function voidPayment(id: number): Promise<APPayment> {
const response = await apiClient.post<APPayment>(`/ap/payments/${id}/void`);
return response.data;
}
export async function allocatePayment(id: number, allocations: { invoiceId: number; amount: number }[]): Promise<APAllocation[]> {
const response = await apiClient.post<APAllocation[]>(`/ap/payments/${id}/allocations`, { allocations });
return response.data;
}
+51
View File
@@ -0,0 +1,51 @@
import { apiClient } from './client';
import type { ARAllocation, ARCreditNote } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchCreditNotes(branchScope: BranchSelection, customerId?: number): Promise<ARCreditNote[]> {
const response = await apiClient.get<ARCreditNote[]>('/ar/credit-notes', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope), customerId },
});
return response.data;
}
export async function fetchCreditNote(id: number): Promise<ARCreditNote> {
const response = await apiClient.get<ARCreditNote>(`/ar/credit-notes/${id}`);
return response.data;
}
export interface CreditNoteLineInput {
description: string;
quantity: number;
unitPrice: number;
revenueGLAccountId: number;
taxCodeId?: number;
}
export interface CreateCreditNoteInput {
customerId: number;
creditNoteDate: string;
reference?: string;
reason?: string;
lines: CreditNoteLineInput[];
}
export async function createCreditNote(input: CreateCreditNoteInput): Promise<ARCreditNote> {
const response = await apiClient.post<ARCreditNote>('/ar/credit-notes', input);
return response.data;
}
export async function postCreditNote(id: number): Promise<ARCreditNote> {
const response = await apiClient.post<ARCreditNote>(`/ar/credit-notes/${id}/post`);
return response.data;
}
export async function voidCreditNote(id: number): Promise<ARCreditNote> {
const response = await apiClient.post<ARCreditNote>(`/ar/credit-notes/${id}/void`);
return response.data;
}
export async function allocateCreditNote(id: number, allocations: { invoiceId: number; amount: number }[]): Promise<ARAllocation[]> {
const response = await apiClient.post<ARAllocation[]>(`/ar/credit-notes/${id}/allocations`, { allocations });
return response.data;
}
+46
View File
@@ -0,0 +1,46 @@
import { apiClient } from './client';
import type { ARInvoice } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchInvoices(branchScope: BranchSelection, customerId?: number): Promise<ARInvoice[]> {
const response = await apiClient.get<ARInvoice[]>('/ar/invoices', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope), customerId },
});
return response.data;
}
export async function fetchInvoice(id: number): Promise<ARInvoice> {
const response = await apiClient.get<ARInvoice>(`/ar/invoices/${id}`);
return response.data;
}
export interface InvoiceLineInput {
description: string;
quantity: number;
unitPrice: number;
revenueGLAccountId: number;
taxCodeId?: number;
}
export interface CreateInvoiceInput {
customerId: number;
invoiceDate: string;
dueDate: string;
reference?: string;
lines: InvoiceLineInput[];
}
export async function createInvoice(input: CreateInvoiceInput): Promise<ARInvoice> {
const response = await apiClient.post<ARInvoice>('/ar/invoices', input);
return response.data;
}
export async function postInvoice(id: number): Promise<ARInvoice> {
const response = await apiClient.post<ARInvoice>(`/ar/invoices/${id}/post`);
return response.data;
}
export async function voidInvoice(id: number): Promise<ARInvoice> {
const response = await apiClient.post<ARInvoice>(`/ar/invoices/${id}/void`);
return response.data;
}
+43
View File
@@ -0,0 +1,43 @@
import { apiClient } from './client';
import type { ARAllocation, ARReceipt } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchReceipts(branchScope: BranchSelection, customerId?: number): Promise<ARReceipt[]> {
const response = await apiClient.get<ARReceipt[]>('/ar/receipts', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope), customerId },
});
return response.data;
}
export async function fetchReceipt(id: number): Promise<ARReceipt> {
const response = await apiClient.get<ARReceipt>(`/ar/receipts/${id}`);
return response.data;
}
export interface CreateReceiptInput {
customerId: number;
receiptDate: string;
amount: number;
bankGLAccountId: number;
reference?: string;
}
export async function createReceipt(input: CreateReceiptInput): Promise<ARReceipt> {
const response = await apiClient.post<ARReceipt>('/ar/receipts', input);
return response.data;
}
export async function postReceipt(id: number): Promise<ARReceipt> {
const response = await apiClient.post<ARReceipt>(`/ar/receipts/${id}/post`);
return response.data;
}
export async function voidReceipt(id: number): Promise<ARReceipt> {
const response = await apiClient.post<ARReceipt>(`/ar/receipts/${id}/void`);
return response.data;
}
export async function allocateReceipt(id: number, allocations: { invoiceId: number; amount: number }[]): Promise<ARAllocation[]> {
const response = await apiClient.post<ARAllocation[]>(`/ar/receipts/${id}/allocations`, { allocations });
return response.data;
}
+18
View File
@@ -0,0 +1,18 @@
import { apiClient } from './client';
import type { AuthUser } from '../store/authStore';
export interface HandoffResponse {
accessToken: string;
refreshToken: string;
user: AuthUser;
}
export async function handoff(token: string): Promise<HandoffResponse> {
const response = await apiClient.post<HandoffResponse>('/auth/handoff', { token });
return response.data;
}
export async function fetchMe(): Promise<AuthUser> {
const response = await apiClient.get<{ user: AuthUser }>('/auth/me');
return response.data.user;
}
+53
View File
@@ -0,0 +1,53 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '../store/authStore';
const baseURL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
export const apiClient = axios.create({ baseURL });
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const { accessToken } = useAuthStore.getState();
if (accessToken) {
config.headers.set('Authorization', `Bearer ${accessToken}`);
}
return config;
});
let refreshInFlight: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
const { refreshToken, setAccessToken, clearSession } = useAuthStore.getState();
if (!refreshToken) {
clearSession();
throw new Error('No refresh token available');
}
try {
const response = await axios.post<{ accessToken: string }>(`${baseURL}/auth/refresh`, { refreshToken });
setAccessToken(response.data.accessToken);
return response.data.accessToken;
} catch (error) {
clearSession();
throw error;
}
}
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
if (error.response?.status === 401 && originalRequest && !originalRequest._retried) {
originalRequest._retried = true;
try {
refreshInFlight ??= refreshAccessToken().finally(() => {
refreshInFlight = null;
});
const accessToken = await refreshInFlight;
originalRequest.headers.set('Authorization', `Bearer ${accessToken}`);
return apiClient(originalRequest);
} catch {
return Promise.reject(error);
}
}
return Promise.reject(error);
},
);
+37
View File
@@ -0,0 +1,37 @@
import { apiClient } from './client';
import type { Customer, CustomerDetail } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchCustomers(branchScope: BranchSelection): Promise<Customer[]> {
const response = await apiClient.get<Customer[]>('/ar/customers', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope) },
});
return response.data;
}
export async function fetchCustomer(id: number): Promise<CustomerDetail> {
const response = await apiClient.get<CustomerDetail>(`/ar/customers/${id}`);
return response.data;
}
export interface CustomerInput {
code: string;
name: string;
email?: string;
phone?: string;
address?: string;
taxNumber?: string;
branchSegmentValueId: number;
creditLimit?: number;
paymentTermsDays?: number;
}
export async function createCustomer(input: CustomerInput): Promise<Customer> {
const response = await apiClient.post<Customer>('/ar/customers', input);
return response.data;
}
export async function updateCustomer(id: number, input: Partial<CustomerInput> & { isActive?: boolean }): Promise<Customer> {
const response = await apiClient.put<Customer>(`/ar/customers/${id}`, input);
return response.data;
}
+12
View File
@@ -0,0 +1,12 @@
import { apiClient } from './client';
import type { FiscalYear } from '../types/api';
export async function fetchFiscalYears(): Promise<FiscalYear[]> {
const response = await apiClient.get<FiscalYear[]>('/company/fiscal-years');
return response.data;
}
export async function createFiscalYear(year: number): Promise<FiscalYear> {
const response = await apiClient.post<FiscalYear>('/company/fiscal-years', { year });
return response.data;
}
+24
View File
@@ -0,0 +1,24 @@
import { apiClient } from './client';
import type { GLAccount, GLAccountType, DebitCredit, ControlAccountType } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchGLAccounts(branchScope: BranchSelection, accountType?: GLAccountType): Promise<GLAccount[]> {
const response = await apiClient.get<GLAccount[]>('/gl/accounts', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope), accountType },
});
return response.data;
}
export interface CreateGLAccountInput {
accountName: string;
accountType: GLAccountType;
normalBalance: DebitCredit;
isControlAccount?: boolean;
controlType?: ControlAccountType;
segmentSelections: { segmentDefId: number; segmentValueId: number }[];
}
export async function createGLAccount(input: CreateGLAccountInput): Promise<GLAccount> {
const response = await apiClient.post<GLAccount>('/gl/accounts', input);
return response.data;
}
+40
View File
@@ -0,0 +1,40 @@
import { apiClient } from './client';
import type { JournalEntry } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchJournalEntries(branchScope: BranchSelection): Promise<JournalEntry[]> {
const response = await apiClient.get<JournalEntry[]>('/gl/journal-entries', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope) },
});
return response.data;
}
export async function fetchJournalEntry(id: number): Promise<JournalEntry> {
const response = await apiClient.get<JournalEntry>(`/gl/journal-entries/${id}`);
return response.data;
}
export interface CreateManualEntryLineInput {
glAccountId: number;
debit: number;
credit: number;
description?: string;
branchSegmentValueId?: number;
}
export interface CreateManualEntryInput {
journalCode: string;
entryDate: string;
description: string;
lines: CreateManualEntryLineInput[];
}
export async function createManualEntry(input: CreateManualEntryInput): Promise<JournalEntry> {
const response = await apiClient.post<JournalEntry>('/gl/journal-entries', input);
return response.data;
}
export async function reverseJournalEntry(id: number, reason?: string): Promise<JournalEntry> {
const response = await apiClient.post<JournalEntry>(`/gl/journal-entries/${id}/reverse`, { reason });
return response.data;
}
+19
View File
@@ -0,0 +1,19 @@
import { apiClient } from './client';
import type { Journal, JournalType } from '../types/api';
export async function fetchJournals(): Promise<Journal[]> {
const response = await apiClient.get<Journal[]>('/gl/journals');
return response.data;
}
export interface CreateJournalInput {
code: string;
name: string;
journalType: JournalType;
defaultBranchSegmentValueId?: number;
}
export async function createJournal(input: CreateJournalInput): Promise<Journal> {
const response = await apiClient.post<Journal>('/gl/journals', input);
return response.data;
}
+17
View File
@@ -0,0 +1,17 @@
import { apiClient } from './client';
import type { Permission, Role } from '../types/api';
export async function fetchRoles(): Promise<Role[]> {
const response = await apiClient.get<Role[]>('/roles');
return response.data;
}
export async function fetchPermissions(): Promise<Permission[]> {
const response = await apiClient.get<Permission[]>('/roles/permissions');
return response.data;
}
export async function setRolePermissions(roleId: number, permissionIds: number[]): Promise<Role> {
const response = await apiClient.put<Role>(`/roles/${roleId}/permissions`, { permissionIds });
return response.data;
}
+37
View File
@@ -0,0 +1,37 @@
import { apiClient } from './client';
import type { AccountSegmentDefinition, AccountSegmentValue, SegmentFormat } from '../types/api';
export async function fetchSegmentDefinitions(): Promise<AccountSegmentDefinition[]> {
const response = await apiClient.get<AccountSegmentDefinition[]>('/company/segment-definitions');
return response.data;
}
export interface CreateSegmentDefinitionInput {
segmentKey: string;
label: string;
order: number;
length: number;
format: SegmentFormat;
isBranchSegment: boolean;
}
export async function createSegmentDefinition(input: CreateSegmentDefinitionInput): Promise<AccountSegmentDefinition> {
const response = await apiClient.post<AccountSegmentDefinition>('/company/segment-definitions', input);
return response.data;
}
export interface CreateSegmentValueInput {
segmentDefId: number;
code: string;
description: string;
}
export async function createSegmentValue(input: CreateSegmentValueInput): Promise<AccountSegmentValue> {
const response = await apiClient.post<AccountSegmentValue>('/company/segment-values', input);
return response.data;
}
export async function fetchBranchValues(): Promise<AccountSegmentValue[]> {
const response = await apiClient.get<AccountSegmentValue[]>('/company/branches');
return response.data;
}
+36
View File
@@ -0,0 +1,36 @@
import { apiClient } from './client';
import type { Supplier, SupplierDetail } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
export async function fetchSuppliers(branchScope: BranchSelection): Promise<Supplier[]> {
const response = await apiClient.get<Supplier[]>('/ap/suppliers', {
params: { branchScope: branchScope === 'MAIN' ? 'MAIN' : String(branchScope) },
});
return response.data;
}
export async function fetchSupplier(id: number): Promise<SupplierDetail> {
const response = await apiClient.get<SupplierDetail>(`/ap/suppliers/${id}`);
return response.data;
}
export interface SupplierInput {
code: string;
name: string;
email?: string;
phone?: string;
address?: string;
taxNumber?: string;
branchSegmentValueId: number;
paymentTermsDays?: number;
}
export async function createSupplier(input: SupplierInput): Promise<Supplier> {
const response = await apiClient.post<Supplier>('/ap/suppliers', input);
return response.data;
}
export async function updateSupplier(id: number, input: Partial<SupplierInput> & { isActive?: boolean }): Promise<Supplier> {
const response = await apiClient.put<Supplier>(`/ap/suppliers/${id}`, input);
return response.data;
}
+24
View File
@@ -0,0 +1,24 @@
import { apiClient } from './client';
import type { TaxCode } from '../types/api';
export async function fetchTaxCodes(): Promise<TaxCode[]> {
const response = await apiClient.get<TaxCode[]>('/tax/codes');
return response.data;
}
export interface TaxCodeInput {
code: string;
name: string;
ratePercent: number;
isActive?: boolean;
}
export async function createTaxCode(input: TaxCodeInput): Promise<TaxCode> {
const response = await apiClient.post<TaxCode>('/tax/codes', input);
return response.data;
}
export async function updateTaxCode(id: number, input: Partial<TaxCodeInput>): Promise<TaxCode> {
const response = await apiClient.put<TaxCode>(`/tax/codes/${id}`, input);
return response.data;
}
+24
View File
@@ -0,0 +1,24 @@
import { apiClient } from './client';
import type { AppUser } from '../types/api';
export async function fetchUsers(): Promise<AppUser[]> {
const response = await apiClient.get<AppUser[]>('/users');
return response.data;
}
export interface CreateUserInput {
email: string;
fullName: string;
roleIds: number[];
branchSegmentValueIds?: number[];
}
export async function createUser(input: CreateUserInput): Promise<AppUser> {
const response = await apiClient.post<AppUser>('/users', input);
return response.data;
}
export async function setUserActive(id: number, isActive: boolean): Promise<AppUser> {
const response = await apiClient.patch<AppUser>(`/users/${id}/active`, { isActive });
return response.data;
}
@@ -0,0 +1,29 @@
import { FormControl, MenuItem, Select, type SelectChangeEvent } from '@mui/material';
import { useBranchScope } from '../../hooks/useBranchScope';
export function BranchSwitcher() {
const { selected, setSelected, branches, canViewMain } = useBranchScope();
const handleChange = (event: SelectChangeEvent<string>) => {
const value = event.target.value;
setSelected(value === 'MAIN' ? 'MAIN' : Number(value));
};
return (
<FormControl size="small" sx={{ minWidth: 200 }}>
<Select
value={String(selected)}
onChange={handleChange}
displayEmpty
sx={{ bgcolor: '#f7f9fd', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#e6e9f2' } }}
>
{canViewMain && <MenuItem value="MAIN">Main (All Branches)</MenuItem>}
{branches.map((branch) => (
<MenuItem key={branch.id} value={String(branch.id)}>
{branch.code} — {branch.description}
</MenuItem>
))}
</Select>
</FormControl>
);
}
@@ -0,0 +1,30 @@
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material';
interface ConfirmDialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
}
export function ConfirmDialog({ open, title, description, confirmLabel = 'Confirm', onConfirm, onCancel, loading }: ConfirmDialogProps) {
return (
<Dialog open={open} onClose={onCancel}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{description}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button onClick={onConfirm} color="error" variant="contained" disabled={loading}>
{confirmLabel}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,10 @@
interface MoneyCellProps {
value: string | number | null | undefined;
}
/** Display-only formatting — never round-trips this formatted value back to the server. */
export function MoneyCell({ value }: MoneyCellProps) {
const amount = Number(value ?? 0);
if (amount === 0) return <span style={{ color: 'var(--mui-palette-text-disabled, #999)' }}>—</span>;
return <span>{amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>;
}
@@ -0,0 +1,15 @@
import { Chip } from '@mui/material';
const COLOR_BY_STATUS: Record<string, 'default' | 'success' | 'warning' | 'error'> = {
POSTED: 'success',
DRAFT: 'warning',
REVERSED: 'error',
OPEN: 'success',
ACTIVE: 'success',
CLOSED: 'default',
LOCKED: 'warning',
};
export function StatusChip({ status }: { status: string }) {
return <Chip label={status} size="small" color={COLOR_BY_STATUS[status] ?? 'default'} />;
}
@@ -0,0 +1,157 @@
import { useMemo, useState } from 'react';
import {
Box,
Button,
Card,
Chip,
Collapse,
InputAdornment,
Stack,
TextField,
Typography,
} from '@mui/material';
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
import ExpandMoreOutlinedIcon from '@mui/icons-material/ExpandMoreOutlined';
import ChevronRightOutlinedIcon from '@mui/icons-material/ChevronRightOutlined';
import { useGLAccounts } from '../../../hooks/useGLAccounts';
import { useBranchStore } from '../../../store/branchStore';
import { StatusChip } from '../../../components/common/StatusChip';
import { GLAccountDialog } from './GLAccountDialog';
import type { GLAccount, GLAccountType } from '../../../types/api';
const TYPE_ORDER: GLAccountType[] = ['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE'];
export function AdminChartOfAccountsPage() {
const selected = useBranchStore((state) => state.selected);
const { data: accounts, isLoading } = useGLAccounts(selected);
const [dialogOpen, setDialogOpen] = useState(false);
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState<Set<GLAccountType>>(new Set());
const grouped = useMemo(() => {
const term = search.trim().toLowerCase();
const filtered = (accounts ?? []).filter(
(a) => !term || a.accountCode.toLowerCase().includes(term) || a.accountName.toLowerCase().includes(term),
);
const map = new Map<GLAccountType, GLAccount[]>();
for (const type of TYPE_ORDER) map.set(type, []);
for (const account of filtered) {
if (!map.has(account.accountType)) map.set(account.accountType, []);
map.get(account.accountType)!.push(account);
}
for (const list of map.values()) list.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
return map;
}, [accounts, search]);
const toggle = (type: GLAccountType) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Chart of Accounts</Typography>
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New Account
</Button>
</Stack>
<TextField
placeholder="Search accounts…"
size="small"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ mb: 2, width: 320 }}
slotProps={{
input: { startAdornment: <InputAdornment position="start"><SearchOutlinedIcon fontSize="small" /></InputAdornment> },
}}
/>
{isLoading && (
<Typography variant="body2" color="text.secondary">
Loading…
</Typography>
)}
<Card>
{Array.from(grouped.entries())
.filter(([, list]) => list.length > 0)
.map(([type, list]) => {
const isCollapsed = collapsed.has(type);
const totalBalance = list.length;
return (
<Box key={type}>
<Stack
direction="row"
spacing={1}
onClick={() => toggle(type)}
sx={{
alignItems: 'center',
px: 2,
py: 1.25,
cursor: 'pointer',
bgcolor: '#f7f9fd',
borderBottom: '1px solid',
borderColor: 'divider',
'&:hover': { bgcolor: '#eef2fa' },
}}
>
{isCollapsed ? <ChevronRightOutlinedIcon fontSize="small" /> : <ExpandMoreOutlinedIcon fontSize="small" />}
<Typography variant="subtitle1" sx={{ flex: 1 }}>
{type}
</Typography>
<Chip label={`${totalBalance} account${totalBalance === 1 ? '' : 's'}`} size="small" variant="outlined" />
</Stack>
<Collapse in={!isCollapsed}>
{list.map((account) => (
<Stack
key={account.id}
direction="row"
spacing={2}
sx={{
alignItems: 'center',
pl: 5,
pr: 2,
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-of-type': { borderBottom: 'none' },
'&:hover': { bgcolor: '#fafbfe' },
}}
>
<Typography variant="body2" sx={{ width: 200, fontFamily: 'monospace' }}>
{account.accountCode}
</Typography>
<Typography variant="body2" sx={{ flex: 1 }}>
{account.accountName}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ width: 110 }}>
{account.normalBalance}
</Typography>
<Box sx={{ width: 110 }}>
{account.isControlAccount && <StatusChip status={account.controlType ?? 'CONTROL'} />}
</Box>
<Box sx={{ width: 90 }}>
<StatusChip status={account.isActive ? 'ACTIVE' : 'CLOSED'} />
</Box>
</Stack>
))}
{list.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ pl: 5, py: 1 }}>
No accounts in this category.
</Typography>
)}
</Collapse>
</Box>
);
})}
</Card>
<GLAccountDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
);
}
@@ -0,0 +1,138 @@
import { useState } from 'react';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Checkbox,
MenuItem,
Stack,
TextField,
} from '@mui/material';
import { useSegmentDefinitions } from '../../../hooks/useSegments';
import { useCreateGLAccount } from '../../../hooks/useGLAccounts';
import type { GLAccountType, DebitCredit, ControlAccountType } from '../../../types/api';
const ACCOUNT_TYPES: GLAccountType[] = ['ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE'];
const DEBIT_CREDIT: DebitCredit[] = ['DEBIT', 'CREDIT'];
const CONTROL_TYPES: ControlAccountType[] = ['AR', 'AP', 'STOCK', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS'];
export function GLAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { data: definitions } = useSegmentDefinitions();
const createAccount = useCreateGLAccount();
const [accountName, setAccountName] = useState('');
const [accountType, setAccountType] = useState<GLAccountType>('ASSET');
const [normalBalance, setNormalBalance] = useState<DebitCredit>('DEBIT');
const [isControlAccount, setIsControlAccount] = useState(false);
const [controlType, setControlType] = useState<ControlAccountType | ''>('');
const [segmentValueBySegment, setSegmentValueBySegment] = useState<Record<number, number>>({});
const [error, setError] = useState<string | null>(null);
const activeDefs = (definitions ?? []).filter((def) => def.isActive).sort((a, b) => a.order - b.order);
const handleSubmit = async () => {
setError(null);
const segmentSelections = activeDefs.map((def) => ({ segmentDefId: def.id, segmentValueId: segmentValueBySegment[def.id] }));
if (segmentSelections.some((s) => !s.segmentValueId)) {
setError('Select a value for every segment');
return;
}
try {
await createAccount.mutateAsync({
accountName,
accountType,
normalBalance,
isControlAccount,
controlType: controlType || undefined,
segmentSelections,
});
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create account');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>New GL Account</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Account name" value={accountName} onChange={(e) => setAccountName(e.target.value)} fullWidth />
<Stack direction="row" spacing={2}>
<TextField
select
label="Account type"
value={accountType}
onChange={(e) => setAccountType(e.target.value as GLAccountType)}
fullWidth
>
{ACCOUNT_TYPES.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</TextField>
<TextField
select
label="Normal balance"
value={normalBalance}
onChange={(e) => setNormalBalance(e.target.value as DebitCredit)}
fullWidth
>
{DEBIT_CREDIT.map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
</Stack>
<FormControlLabel
control={<Checkbox checked={isControlAccount} onChange={(e) => setIsControlAccount(e.target.checked)} />}
label="Control account"
/>
{isControlAccount && (
<TextField
select
label="Control type"
value={controlType}
onChange={(e) => setControlType(e.target.value as ControlAccountType)}
fullWidth
>
{CONTROL_TYPES.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</TextField>
)}
{activeDefs.map((def) => (
<TextField
key={def.id}
select
label={def.label}
value={segmentValueBySegment[def.id] ?? ''}
onChange={(e) => setSegmentValueBySegment((prev) => ({ ...prev, [def.id]: Number(e.target.value) }))}
fullWidth
>
{def.values.map((value) => (
<MenuItem key={value.id} value={value.id}>
{value.code} — {value.description}
</MenuItem>
))}
</TextField>
))}
{error && <span style={{ color: 'red' }}>{error}</span>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={createAccount.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,70 @@
import { useState } from 'react';
import { Box, Button, Paper, Stack, TextField, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useCreateFiscalYear, useFiscalYears } from '../../../hooks/useFiscalYears';
import { StatusChip } from '../../../components/common/StatusChip';
import type { FiscalPeriod } from '../../../types/api';
export function AdminFiscalYearsPage() {
const { data: fiscalYears, isLoading } = useFiscalYears();
const createFiscalYear = useCreateFiscalYear();
const [newYear, setNewYear] = useState('');
const [selectedYearId, setSelectedYearId] = useState<number | null>(null);
const selectedYear = (fiscalYears ?? []).find((year) => year.id === selectedYearId) ?? fiscalYears?.[0];
const periodColumns: GridColDef<FiscalPeriod>[] = [
{ field: 'periodNumber', headerName: 'Period', width: 100 },
{ field: 'startDate', headerName: 'Start', width: 140, valueFormatter: (value: string) => value?.slice(0, 10) },
{ field: 'endDate', headerName: 'End', width: 140, valueFormatter: (value: string) => value?.slice(0, 10) },
{ field: 'status', headerName: 'Status', width: 120, renderCell: (params) => <StatusChip status={params.row.status} /> },
];
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Fiscal Years
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 3 }}>
<TextField label="New fiscal year" type="number" size="small" value={newYear} onChange={(e) => setNewYear(e.target.value)} />
<Button
variant="contained"
disabled={!newYear || createFiscalYear.isPending}
onClick={async () => {
await createFiscalYear.mutateAsync(Number(newYear));
setNewYear('');
}}
>
Create Year (12 periods)
</Button>
</Stack>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
{(fiscalYears ?? []).map((year) => (
<Button
key={year.id}
size="small"
variant={year.id === selectedYear?.id ? 'contained' : 'outlined'}
onClick={() => setSelectedYearId(year.id)}
>
{year.year}
</Button>
))}
</Stack>
{selectedYear && (
<Paper>
<Box sx={{ height: 500 }}>
<DataGrid
rows={selectedYear.periods}
columns={periodColumns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
/>
</Box>
</Paper>
)}
</Box>
);
}
@@ -0,0 +1,33 @@
import { useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useJournals } from '../../../hooks/useJournals';
import type { Journal } from '../../../types/api';
import { JournalDialog } from './JournalDialog';
const columns: GridColDef<Journal>[] = [
{ field: 'code', headerName: 'Code', width: 140 },
{ field: 'name', headerName: 'Name', flex: 1 },
{ field: 'journalType', headerName: 'Type', width: 160 },
{ field: 'isActive', headerName: 'Active', width: 100, type: 'boolean' },
];
export function AdminJournalsPage() {
const { data: journals, isLoading } = useJournals();
const [dialogOpen, setDialogOpen] = useState(false);
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Journals</Typography>
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New Journal
</Button>
</Stack>
<Box sx={{ height: 500, bgcolor: 'background.paper' }}>
<DataGrid rows={journals ?? []} columns={columns} loading={isLoading} density="compact" disableRowSelectionOnClick />
</Box>
<JournalDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
);
}
@@ -0,0 +1,71 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material';
import { useCreateJournal } from '../../../hooks/useJournals';
import { useSegmentDefinitions } from '../../../hooks/useSegments';
const JOURNAL_TYPES = ['GENERAL', 'SALES', 'PURCHASES', 'CASH_RECEIPT', 'CASH_PAYMENT', 'STOCK', 'BANK', 'CUSTOM'] as const;
const schema = z.object({
code: z.string().min(1).max(20),
name: z.string().min(1),
journalType: z.enum(JOURNAL_TYPES),
defaultBranchSegmentValueId: z.coerce.number().int().optional(),
});
type FormValues = z.infer<typeof schema>;
export function JournalDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const createJournal = useCreateJournal();
const { data: definitions } = useSegmentDefinitions();
const branchDef = (definitions ?? []).find((def) => def.isBranchSegment);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { code: '', name: '', journalType: 'CUSTOM' } });
const onSubmit = handleSubmit(async (values) => {
await createJournal.mutateAsync(values);
reset();
onClose();
});
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Journal</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }} component="form" id="journal-form" onSubmit={onSubmit}>
<TextField label="Code" {...register('code')} error={Boolean(errors.code)} helperText={errors.code?.message} />
<TextField label="Name" {...register('name')} error={Boolean(errors.name)} helperText={errors.name?.message} />
<TextField label="Type" select defaultValue="CUSTOM" {...register('journalType')}>
{JOURNAL_TYPES.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</TextField>
{branchDef && (
<TextField label="Default branch (optional)" select defaultValue="" {...register('defaultBranchSegmentValueId')}>
<MenuItem value="">None</MenuItem>
{branchDef.values.map((value) => (
<MenuItem key={value.id} value={value.id}>
{value.code} — {value.description}
</MenuItem>
))}
</TextField>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button type="submit" form="journal-form" variant="contained" disabled={createJournal.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,77 @@
import { useState } from 'react';
import {
Box,
Button,
Chip,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useSegmentDefinitions } from '../../../hooks/useSegments';
import { SegmentDefinitionDialog } from './SegmentDefinitionDialog';
import { SegmentValueDialog } from './SegmentValueDialog';
export function AdminSegmentsPage() {
const { data: definitions, isLoading } = useSegmentDefinitions();
const [defDialogOpen, setDefDialogOpen] = useState(false);
const [valueDialogFor, setValueDialogFor] = useState<number | null>(null);
if (isLoading) return <CircularProgress />;
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Account Segments & Branches</Typography>
<Button variant="contained" onClick={() => setDefDialogOpen(true)}>
New Segment
</Button>
</Stack>
<Stack spacing={3}>
{(definitions ?? [])
.sort((a, b) => a.order - b.order)
.map((def) => (
<Paper key={def.id} sx={{ p: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="h6">
{def.order}. {def.label} ({def.segmentKey})
</Typography>
{def.isBranchSegment && <Chip label="Branch segment" color="primary" size="small" />}
<Chip label={`${def.format} · length ${def.length}`} size="small" variant="outlined" />
</Stack>
<Button size="small" onClick={() => setValueDialogFor(def.id)}>
Add Value
</Button>
</Stack>
<Table size="small" sx={{ mt: 1 }}>
<TableHead>
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{def.values.map((value) => (
<TableRow key={value.id}>
<TableCell>{value.code}</TableCell>
<TableCell>{value.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
))}
</Stack>
<SegmentDefinitionDialog open={defDialogOpen} onClose={() => setDefDialogOpen(false)} />
<SegmentValueDialog open={valueDialogFor !== null} segmentDefId={valueDialogFor} onClose={() => setValueDialogFor(null)} />
</Box>
);
}
@@ -0,0 +1,83 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
MenuItem,
Stack,
TextField,
} from '@mui/material';
import { useCreateSegmentDefinition } from '../../../hooks/useSegments';
const schema = z.object({
segmentKey: z.string().min(1).max(30),
label: z.string().min(1),
order: z.coerce.number().int().min(0),
length: z.coerce.number().int().min(1).max(20),
format: z.enum(['NUMERIC', 'ALPHA', 'ALPHANUMERIC']),
isBranchSegment: z.boolean(),
});
type FormValues = z.infer<typeof schema>;
export function SegmentDefinitionDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const createDefinition = useCreateSegmentDefinition();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { segmentKey: '', label: '', order: 0, length: 3, format: 'NUMERIC', isBranchSegment: false },
});
const onSubmit = handleSubmit(async (values) => {
await createDefinition.mutateAsync(values);
reset();
onClose();
});
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Account Segment</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }} component="form" id="segment-def-form" onSubmit={onSubmit}>
<TextField
label="Segment key"
{...register('segmentKey')}
error={Boolean(errors.segmentKey)}
helperText={errors.segmentKey?.message ?? 'e.g. MASTER, SUB, BRANCH'}
/>
<TextField label="Label" {...register('label')} error={Boolean(errors.label)} helperText={errors.label?.message} />
<TextField label="Order" type="number" {...register('order')} error={Boolean(errors.order)} />
<TextField
label="Length"
type="number"
{...register('length')}
error={Boolean(errors.length)}
helperText="Fixed character width of this segment"
/>
<TextField label="Format" select defaultValue="NUMERIC" {...register('format')}>
<MenuItem value="NUMERIC">Numeric</MenuItem>
<MenuItem value="ALPHA">Alpha</MenuItem>
<MenuItem value="ALPHANUMERIC">Alphanumeric</MenuItem>
</TextField>
<FormControlLabel control={<Checkbox {...register('isBranchSegment')} />} label="This is the branch segment" />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button type="submit" form="segment-def-form" variant="contained" disabled={createDefinition.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,58 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material';
import { useCreateSegmentValue } from '../../../hooks/useSegments';
const schema = z.object({
code: z.string().min(1),
description: z.string().min(1),
});
type FormValues = z.infer<typeof schema>;
interface SegmentValueDialogProps {
open: boolean;
segmentDefId: number | null;
onClose: () => void;
}
export function SegmentValueDialog({ open, segmentDefId, onClose }: SegmentValueDialogProps) {
const createValue = useCreateSegmentValue();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { code: '', description: '' } });
const onSubmit = handleSubmit(async (values) => {
if (segmentDefId === null) return;
await createValue.mutateAsync({ segmentDefId, ...values });
reset();
onClose();
});
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Segment Value</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }} component="form" id="segment-value-form" onSubmit={onSubmit}>
<TextField label="Code" {...register('code')} error={Boolean(errors.code)} helperText={errors.code?.message} />
<TextField
label="Description"
{...register('description')}
error={Boolean(errors.description)}
helperText={errors.description?.message}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button type="submit" form="segment-value-form" variant="contained" disabled={createValue.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,49 @@
import { useState } from 'react';
import { Box, Button, Card, Stack, Switch, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useTaxCodes, useUpdateTaxCode } from '../../../hooks/useTaxCodes';
import { useAuth } from '../../../hooks/useAuth';
import type { TaxCode } from '../../../types/api';
import { TaxCodeDialog } from './TaxCodeDialog';
export function AdminTaxCodesPage() {
const { data: taxCodes, isLoading } = useTaxCodes();
const updateTaxCode = useUpdateTaxCode();
const { hasPermission } = useAuth();
const [dialogOpen, setDialogOpen] = useState(false);
const columns: GridColDef<TaxCode>[] = [
{ field: 'code', headerName: 'Code', width: 140 },
{ field: 'name', headerName: 'Name', flex: 1 },
{ field: 'ratePercent', headerName: 'Rate %', width: 120, align: 'right', headerAlign: 'right' },
{
field: 'isActive',
headerName: 'Active',
width: 100,
renderCell: (params) => (
<Switch
checked={params.row.isActive}
size="small"
onChange={(e) => updateTaxCode.mutate({ id: params.row.id, input: { isActive: e.target.checked } })}
/>
),
},
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Tax Codes</Typography>
{hasPermission('admin.tax.manage') && (
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New Tax Code
</Button>
)}
</Stack>
<Card sx={{ height: 500 }}>
<DataGrid rows={taxCodes ?? []} columns={columns} loading={isLoading} density="compact" disableRowSelectionOnClick sx={{ border: 'none' }} />
</Card>
<TaxCodeDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
);
}
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from '@mui/material';
import { useCreateTaxCode } from '../../../hooks/useTaxCodes';
export function TaxCodeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const createTaxCode = useCreateTaxCode();
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [ratePercent, setRatePercent] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
try {
await createTaxCode.mutateAsync({ code, name, ratePercent: Number(ratePercent) });
onClose();
setCode('');
setName('');
setRatePercent('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create tax code');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Tax Code</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Code" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} helperText="e.g. STD, ZERO" />
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<TextField
label="Rate %"
type="number"
value={ratePercent}
onChange={(e) => setRatePercent(e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 100, step: '0.001' } }}
/>
{error && <span style={{ color: 'red' }}>{error}</span>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={createTaxCode.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Box, Button, Stack, Switch, Tab, Tabs, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useSetUserActive, useUsers } from '../../../hooks/useUsers';
import type { AppUser } from '../../../types/api';
import { UserDialog } from './UserDialog';
import { RolePermissionMatrix } from './RolePermissionMatrix';
export function AdminUsersPage() {
const { data: users, isLoading } = useUsers();
const setActive = useSetUserActive();
const [dialogOpen, setDialogOpen] = useState(false);
const [tab, setTab] = useState(0);
const columns: GridColDef<AppUser>[] = [
{ field: 'fullName', headerName: 'Name', flex: 1 },
{ field: 'email', headerName: 'Email', flex: 1 },
{
field: 'roles',
headerName: 'Roles',
flex: 1,
valueGetter: (_value, row) => row.roles.map((r) => r.role.name).join(', '),
},
{
field: 'isActive',
headerName: 'Active',
width: 100,
renderCell: (params) => (
<Switch
checked={params.row.isActive}
size="small"
onChange={(e) => setActive.mutate({ id: params.row.id, isActive: e.target.checked })}
/>
),
},
];
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Users & Roles
</Typography>
<Tabs value={tab} onChange={(_e, value) => setTab(value)} sx={{ mb: 2 }}>
<Tab label="Users" />
<Tab label="Role Permissions" />
</Tabs>
{tab === 0 && (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'flex-end' }}>
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New User
</Button>
</Stack>
<Box sx={{ height: 500, bgcolor: 'background.paper' }}>
<DataGrid rows={users ?? []} columns={columns} loading={isLoading} density="compact" disableRowSelectionOnClick />
</Box>
<UserDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
)}
{tab === 1 && <RolePermissionMatrix />}
</Box>
);
}
@@ -0,0 +1,53 @@
import { Checkbox, CircularProgress, Paper, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
import { useRoles, usePermissions, useSetRolePermissions } from '../../../hooks/useRoles';
export function RolePermissionMatrix() {
const { data: roles, isLoading: rolesLoading } = useRoles();
const { data: permissions, isLoading: permissionsLoading } = usePermissions();
const setRolePermissions = useSetRolePermissions();
if (rolesLoading || permissionsLoading) return <CircularProgress />;
const handleToggle = (roleId: number, currentPermissionIds: number[], permissionId: number) => {
const next = currentPermissionIds.includes(permissionId)
? currentPermissionIds.filter((id) => id !== permissionId)
: [...currentPermissionIds, permissionId];
setRolePermissions.mutate({ roleId, permissionIds: next });
};
return (
<Paper sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Permission</TableCell>
{(roles ?? []).map((role) => (
<TableCell key={role.id} align="center">
{role.name}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{(permissions ?? []).map((permission) => (
<TableRow key={permission.id}>
<TableCell>{permission.key}</TableCell>
{(roles ?? []).map((role) => {
const currentPermissionIds = role.permissions.map((rp) => rp.permission.id);
return (
<TableCell key={role.id} align="center">
<Checkbox
size="small"
checked={currentPermissionIds.includes(permission.id)}
onChange={() => handleToggle(role.id, currentPermissionIds, permission.id)}
/>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
}
@@ -0,0 +1,100 @@
import { useState } from 'react';
import {
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
FormGroup,
Stack,
TextField,
Typography,
} from '@mui/material';
import { useCreateUser } from '../../../hooks/useUsers';
import { useRoles } from '../../../hooks/useRoles';
import { useSegmentDefinitions } from '../../../hooks/useSegments';
export function UserDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { data: roles } = useRoles();
const { data: definitions } = useSegmentDefinitions();
const branchDef = (definitions ?? []).find((def) => def.isBranchSegment);
const createUser = useCreateUser();
const [email, setEmail] = useState('');
const [fullName, setFullName] = useState('');
const [roleIds, setRoleIds] = useState<number[]>([]);
const [branchIds, setBranchIds] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const toggle = (list: number[], setList: (ids: number[]) => void, id: number) =>
setList(list.includes(id) ? list.filter((existing) => existing !== id) : [...list, id]);
const handleSubmit = async () => {
setError(null);
if (roleIds.length === 0) {
setError('Select at least one role');
return;
}
try {
await createUser.mutateAsync({ email, fullName, roleIds, branchSegmentValueIds: branchIds });
onClose();
setEmail('');
setFullName('');
setRoleIds([]);
setBranchIds([]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New User</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Full name" value={fullName} onChange={(e) => setFullName(e.target.value)} />
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
helperText="Must match the email this person signs in with at the Account Manager"
/>
<Typography variant="subtitle2">Roles</Typography>
<FormGroup row>
{(roles ?? []).map((role) => (
<FormControlLabel
key={role.id}
control={<Checkbox checked={roleIds.includes(role.id)} onChange={() => toggle(roleIds, setRoleIds, role.id)} />}
label={role.name}
/>
))}
</FormGroup>
{branchDef && (
<>
<Typography variant="subtitle2">Branch restrictions (leave empty for unrestricted)</Typography>
<FormGroup row>
{branchDef.values.map((value) => (
<FormControlLabel
key={value.id}
control={<Checkbox checked={branchIds.includes(value.id)} onChange={() => toggle(branchIds, setBranchIds, value.id)} />}
label={value.code}
/>
))}
</FormGroup>
</>
)}
{error && <span style={{ color: 'red' }}>{error}</span>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={createUser.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useAllocateDebitNote, useDebitNote, usePostDebitNote, useVoidDebitNote } from '../../../hooks/useAPDebitNotes';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { APAllocationPanel } from '../shared/APAllocationPanel';
export function DebitNoteDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const debitNoteId = id ? Number(id) : undefined;
const { data: debitNote, isLoading } = useDebitNote(debitNoteId);
const postDebitNote = usePostDebitNote();
const voidDebitNote = useVoidDebitNote();
const allocateDebitNote = useAllocateDebitNote();
const { hasPermission } = useAuth();
const [error, setError] = useState<string | null>(null);
const autoPosted = useRef(false);
useEffect(() => {
if (searchParams.get('post') === '1' && debitNote?.status === 'DRAFT' && !autoPosted.current) {
autoPosted.current = true;
postDebitNote.mutateAsync(debitNote.id).catch((err) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post debit note');
});
setSearchParams({}, { replace: true });
}
}, [debitNote, postDebitNote, searchParams, setSearchParams]);
if (isLoading || !debitNote) return <CircularProgress />;
const handlePost = async () => {
setError(null);
try {
await postDebitNote.mutateAsync(debitNote.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post debit note');
}
};
const handleVoid = async () => {
setError(null);
try {
await voidDebitNote.mutateAsync(debitNote.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to void debit note');
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{debitNote.debitNoteNumber}</Typography>
<StatusChip status={debitNote.status} />
</Stack>
{debitNote.status === 'DRAFT' && hasPermission('ap.debitnote.manage') && (
<Stack direction="row" spacing={1.5}>
<Button variant="outlined" color="error" onClick={handleVoid} disabled={voidDebitNote.isPending}>
Void
</Button>
<Button variant="contained" onClick={handlePost} disabled={postDebitNote.isPending}>
Post Debit Note
</Button>
</Stack>
)}
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Supplier
</Typography>
<Typography
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ap/suppliers/${debitNote.supplierId}`)}
>
{debitNote.supplier?.name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Date
</Typography>
<Typography>{debitNote.debitNoteDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Reason
</Typography>
<Typography>{debitNote.reason || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Unallocated Balance
</Typography>
<Typography>
<MoneyCell value={debitNote.balance} />
</Typography>
</Box>
</Stack>
</Card>
<Card>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Description</TableCell>
<TableCell align="right">Qty</TableCell>
<TableCell align="right">Unit Price</TableCell>
<TableCell>Expense Account</TableCell>
<TableCell>Tax</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{debitNote.lines.map((line) => (
<TableRow key={line.id}>
<TableCell>{line.description}</TableCell>
<TableCell align="right">{line.quantity}</TableCell>
<TableCell align="right">
<MoneyCell value={line.unitPrice} />
</TableCell>
<TableCell>{line.expenseGLAccount?.accountName}</TableCell>
<TableCell>{line.taxCode?.code ?? '—'}</TableCell>
<TableCell align="right">
<MoneyCell value={line.lineTotal} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={5} align="right">
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={debitNote.total} />
</strong>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
{debitNote.status === 'POSTED' && (
<Card sx={{ p: 2, mt: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Allocate Against Invoices
</Typography>
<APAllocationPanel
supplierId={debitNote.supplierId}
unallocatedAmount={Number(debitNote.balance)}
isSubmitting={allocateDebitNote.isPending}
onAllocate={(allocations) => allocateDebitNote.mutateAsync({ id: debitNote.id, allocations })}
/>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,153 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, MenuItem, Stack, TextField, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useSuppliers } from '../../../hooks/useSuppliers';
import { useGLAccounts } from '../../../hooks/useGLAccounts';
import { useTaxCodes } from '../../../hooks/useTaxCodes';
import { useCreateDebitNote } from '../../../hooks/useAPDebitNotes';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { APDocumentLineEditor, blankAPDocumentLine, apLineTotals, type APDocumentLineDraft } from '../shared/APDocumentLineEditor';
export function DebitNoteFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedSupplierId = searchParams.get('supplierId');
const { data: suppliers } = useSuppliers('MAIN');
const { data: taxCodes } = useTaxCodes();
const createDebitNote = useCreateDebitNote();
const [supplierId, setSupplierId] = useState<number | ''>(preselectedSupplierId ? Number(preselectedSupplierId) : '');
const [debitNoteDate, setDebitNoteDate] = useState(new Date().toISOString().slice(0, 10));
const [reference, setReference] = useState('');
const [reason, setReason] = useState('');
const [lines, setLines] = useState<APDocumentLineDraft[]>([blankAPDocumentLine()]);
const [error, setError] = useState<string | null>(null);
const supplier = (suppliers ?? []).find((s) => s.id === supplierId);
const { data: expenseAccounts } = useGLAccounts(supplier ? supplier.branchSegmentValueId : 'MAIN', 'EXPENSE');
const totals = useMemo(() => {
const computed = lines.map((line) => apLineTotals(line, taxCodes ?? []));
return {
subtotal: computed.reduce((sum, l) => sum + l.subtotal, 0),
tax: computed.reduce((sum, l) => sum + l.tax, 0),
total: computed.reduce((sum, l) => sum + l.total, 0),
};
}, [lines, taxCodes]);
const save = async (postAfter: boolean) => {
setError(null);
if (!supplierId) {
setError('Select a supplier');
return;
}
if (lines.some((line) => !line.expenseGLAccountId || !line.description || !Number(line.unitPrice))) {
setError('Every line needs a description, unit price, and expense account');
return;
}
try {
const debitNote = await createDebitNote.mutateAsync({
supplierId,
debitNoteDate,
reference: reference || undefined,
reason: reason || undefined,
lines: lines.map((line) => ({
description: line.description,
quantity: Number(line.quantity) || 1,
unitPrice: Number(line.unitPrice) || 0,
expenseGLAccountId: line.expenseGLAccountId as number,
taxCodeId: line.taxCodeId ? (line.taxCodeId as number) : undefined,
})),
});
navigate(postAfter ? `/ap/debit-notes/${debitNote.id}?post=1` : `/ap/debit-notes/${debitNote.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to save debit note'));
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
New Debit Note
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap' }}>
<TextField
select
label="Supplier"
value={supplierId}
onChange={(e) => setSupplierId(Number(e.target.value))}
sx={{ minWidth: 260 }}
>
{(suppliers ?? []).map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.code} — {s.name}
</MenuItem>
))}
</TextField>
<TextField
label="Debit note date"
type="date"
value={debitNoteDate}
onChange={(e) => setDebitNoteDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField label="Reference" value={reference} onChange={(e) => setReference(e.target.value)} sx={{ minWidth: 180 }} />
<TextField label="Reason" value={reason} onChange={(e) => setReason(e.target.value)} sx={{ minWidth: 220 }} />
</Stack>
</Card>
<Card sx={{ p: 2 }}>
<APDocumentLineEditor lines={lines} onChange={setLines} expenseAccounts={expenseAccounts ?? []} taxCodes={taxCodes ?? []} />
<Button startIcon={<AddIcon />} onClick={() => setLines((prev) => [...prev, blankAPDocumentLine()])} sx={{ mt: 1 }}>
Add line
</Button>
<Stack spacing={0.5} sx={{ mt: 2, alignItems: 'flex-end' }}>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Subtotal
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.subtotal} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Tax
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.tax} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="subtitle1">Total</Typography>
<Typography variant="subtitle1" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.total} />
</Typography>
</Stack>
</Stack>
</Card>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button variant="contained" disabled={createDebitNote.isPending} onClick={() => save(true)}>
Post Debit Note
</Button>
<Button variant="outlined" disabled={createDebitNote.isPending} onClick={() => save(false)}>
Save Draft
</Button>
<Button onClick={() => navigate(-1)}>Cancel</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,224 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useInvoice, usePostInvoice, useVoidInvoice } from '../../../hooks/useAPInvoices';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
export function InvoiceDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const invoiceId = id ? Number(id) : undefined;
const { data: invoice, isLoading } = useInvoice(invoiceId);
const postInvoice = usePostInvoice();
const voidInvoice = useVoidInvoice();
const { hasPermission } = useAuth();
const [error, setError] = useState<string | null>(null);
const autoPosted = useRef(false);
useEffect(() => {
if (searchParams.get('post') === '1' && invoice?.status === 'DRAFT' && !autoPosted.current) {
autoPosted.current = true;
postInvoice.mutateAsync(invoice.id).catch((err) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post invoice');
});
setSearchParams({}, { replace: true });
}
}, [invoice, postInvoice, searchParams, setSearchParams]);
if (isLoading || !invoice) return <CircularProgress />;
const handlePost = async () => {
setError(null);
try {
await postInvoice.mutateAsync(invoice.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post invoice');
}
};
const handleVoid = async () => {
setError(null);
try {
await voidInvoice.mutateAsync(invoice.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to void invoice');
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{invoice.invoiceNumber}</Typography>
<StatusChip status={invoice.status} />
</Stack>
<Stack direction="row" spacing={1.5}>
{invoice.status === 'DRAFT' && hasPermission('ap.invoice.manage') && (
<>
<Button variant="outlined" color="error" onClick={handleVoid} disabled={voidInvoice.isPending}>
Void
</Button>
<Button variant="contained" onClick={handlePost} disabled={postInvoice.isPending}>
Post Invoice
</Button>
</>
)}
</Stack>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Supplier
</Typography>
<Typography
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ap/suppliers/${invoice.supplierId}`)}
>
{invoice.supplier?.name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Invoice Date
</Typography>
<Typography>{invoice.invoiceDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Due Date
</Typography>
<Typography>{invoice.dueDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Supplier Reference
</Typography>
<Typography>{invoice.supplierReference || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Balance
</Typography>
<Typography>
<MoneyCell value={invoice.balance} />
</Typography>
</Box>
</Stack>
</Card>
<Card>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Description</TableCell>
<TableCell align="right">Qty</TableCell>
<TableCell align="right">Unit Price</TableCell>
<TableCell>Expense Account</TableCell>
<TableCell>Tax</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.id}>
<TableCell>{line.description}</TableCell>
<TableCell align="right">{line.quantity}</TableCell>
<TableCell align="right">
<MoneyCell value={line.unitPrice} />
</TableCell>
<TableCell>{line.expenseGLAccount?.accountName}</TableCell>
<TableCell>{line.taxCode?.code ?? '—'}</TableCell>
<TableCell align="right">
<MoneyCell value={line.lineTotal} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={5} align="right">
Subtotal
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.subtotal} />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
Tax
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.taxTotal} />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={invoice.total} />
</strong>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
{invoice.allocations && invoice.allocations.length > 0 && (
<Card sx={{ p: 2, mt: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Allocations
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Source</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoice.allocations.map((allocation) => (
<TableRow key={allocation.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>
{allocation.payment?.paymentNumber ?? allocation.debitNote?.debitNoteNumber}
</TableCell>
<TableCell>{allocation.allocatedAt.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={allocation.amount} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,164 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, MenuItem, Stack, TextField, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useSuppliers } from '../../../hooks/useSuppliers';
import { useGLAccounts } from '../../../hooks/useGLAccounts';
import { useTaxCodes } from '../../../hooks/useTaxCodes';
import { useCreateInvoice } from '../../../hooks/useAPInvoices';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { APDocumentLineEditor, blankAPDocumentLine, apLineTotals, type APDocumentLineDraft } from '../shared/APDocumentLineEditor';
function addDays(dateStr: string, days: number): string {
const date = new Date(dateStr);
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
export function InvoiceFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedSupplierId = searchParams.get('supplierId');
const { data: suppliers } = useSuppliers('MAIN');
const { data: taxCodes } = useTaxCodes();
const createInvoice = useCreateInvoice();
const [supplierId, setSupplierId] = useState<number | ''>(preselectedSupplierId ? Number(preselectedSupplierId) : '');
const [invoiceDate, setInvoiceDate] = useState(new Date().toISOString().slice(0, 10));
const [supplierReference, setSupplierReference] = useState('');
const [lines, setLines] = useState<APDocumentLineDraft[]>([blankAPDocumentLine()]);
const [error, setError] = useState<string | null>(null);
const supplier = (suppliers ?? []).find((s) => s.id === supplierId);
const { data: expenseAccounts } = useGLAccounts(supplier ? supplier.branchSegmentValueId : 'MAIN', 'EXPENSE');
const dueDate = supplier ? addDays(invoiceDate, supplier.paymentTermsDays) : invoiceDate;
const totals = useMemo(() => {
const computed = lines.map((line) => apLineTotals(line, taxCodes ?? []));
return {
subtotal: computed.reduce((sum, l) => sum + l.subtotal, 0),
tax: computed.reduce((sum, l) => sum + l.tax, 0),
total: computed.reduce((sum, l) => sum + l.total, 0),
};
}, [lines, taxCodes]);
const save = async (postAfter: boolean) => {
setError(null);
if (!supplierId) {
setError('Select a supplier');
return;
}
if (lines.some((line) => !line.expenseGLAccountId || !line.description || !Number(line.unitPrice))) {
setError('Every line needs a description, unit price, and expense account');
return;
}
try {
const invoice = await createInvoice.mutateAsync({
supplierId,
invoiceDate,
dueDate,
supplierReference: supplierReference || undefined,
lines: lines.map((line) => ({
description: line.description,
quantity: Number(line.quantity) || 1,
unitPrice: Number(line.unitPrice) || 0,
expenseGLAccountId: line.expenseGLAccountId as number,
taxCodeId: line.taxCodeId ? (line.taxCodeId as number) : undefined,
})),
});
navigate(postAfter ? `/ap/invoices/${invoice.id}?post=1` : `/ap/invoices/${invoice.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to save invoice'));
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
New Supplier Invoice
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap' }}>
<TextField
select
label="Supplier"
value={supplierId}
onChange={(e) => setSupplierId(Number(e.target.value))}
sx={{ minWidth: 260 }}
>
{(suppliers ?? []).map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.code} — {s.name}
</MenuItem>
))}
</TextField>
<TextField
label="Invoice date"
type="date"
value={invoiceDate}
onChange={(e) => setInvoiceDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField label="Due date" type="date" value={dueDate} disabled slotProps={{ inputLabel: { shrink: true } }} />
<TextField
label="Supplier reference"
value={supplierReference}
onChange={(e) => setSupplierReference(e.target.value)}
sx={{ minWidth: 200 }}
/>
</Stack>
</Card>
<Card sx={{ p: 2 }}>
<APDocumentLineEditor lines={lines} onChange={setLines} expenseAccounts={expenseAccounts ?? []} taxCodes={taxCodes ?? []} />
<Button startIcon={<AddIcon />} onClick={() => setLines((prev) => [...prev, blankAPDocumentLine()])} sx={{ mt: 1 }}>
Add line
</Button>
<Stack spacing={0.5} sx={{ mt: 2, alignItems: 'flex-end' }}>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Subtotal
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.subtotal} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Tax
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.tax} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="subtitle1">Total</Typography>
<Typography variant="subtitle1" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.total} />
</Typography>
</Stack>
</Stack>
</Card>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button variant="contained" disabled={createInvoice.isPending} onClick={() => save(true)}>
Post Invoice
</Button>
<Button variant="outlined" disabled={createInvoice.isPending} onClick={() => save(false)}>
Save Draft
</Button>
<Button onClick={() => navigate(-1)}>Cancel</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,65 @@
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useInvoices } from '../../../hooks/useAPInvoices';
import { useBranchStore } from '../../../store/branchStore';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { APInvoice } from '../../../types/api';
export function InvoicesListPage() {
const navigate = useNavigate();
const selected = useBranchStore((state) => state.selected);
const { data: invoices, isLoading } = useInvoices(selected);
const { hasPermission } = useAuth();
const columns: GridColDef<APInvoice>[] = [
{ field: 'invoiceNumber', headerName: 'Invoice #', width: 180 },
{ field: 'supplier', headerName: 'Supplier', flex: 1, valueGetter: (_v, row) => row.supplier?.name },
{ field: 'invoiceDate', headerName: 'Date', width: 120, valueFormatter: (value: string) => value?.slice(0, 10) },
{ field: 'dueDate', headerName: 'Due', width: 120, valueFormatter: (value: string) => value?.slice(0, 10) },
{
field: 'total',
headerName: 'Total',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.total} />,
},
{
field: 'balance',
headerName: 'Balance',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.balance} />,
},
{ field: 'status', headerName: 'Status', width: 120, renderCell: (params) => <StatusChip status={params.row.status} /> },
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Supplier Invoices</Typography>
{hasPermission('ap.invoice.manage') && (
<Button variant="contained" onClick={() => navigate('/ap/invoices/new')}>
New Invoice
</Button>
)}
</Stack>
<Card sx={{ height: 650 }}>
<DataGrid
rows={invoices ?? []}
columns={columns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
onRowClick={(params) => navigate(`/ap/invoices/${params.row.id}`)}
sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' } }}
initialState={{ sorting: { sortModel: [{ field: 'invoiceDate', sort: 'desc' }] } }}
/>
</Card>
</Box>
);
}
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, CircularProgress, Stack, Typography } from '@mui/material';
import { useAllocatePayment, usePayment, usePostPayment, useVoidPayment } from '../../../hooks/useAPPayments';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { APAllocationPanel } from '../shared/APAllocationPanel';
export function PaymentDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const paymentId = id ? Number(id) : undefined;
const { data: payment, isLoading } = usePayment(paymentId);
const postPayment = usePostPayment();
const voidPayment = useVoidPayment();
const allocatePayment = useAllocatePayment();
const { hasPermission } = useAuth();
const [error, setError] = useState<string | null>(null);
const autoPosted = useRef(false);
useEffect(() => {
if (searchParams.get('post') === '1' && payment?.status === 'DRAFT' && !autoPosted.current) {
autoPosted.current = true;
postPayment.mutateAsync(payment.id).catch((err) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post payment');
});
setSearchParams({}, { replace: true });
}
}, [payment, postPayment, searchParams, setSearchParams]);
if (isLoading || !payment) return <CircularProgress />;
const handlePost = async () => {
setError(null);
try {
await postPayment.mutateAsync(payment.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post payment');
}
};
const handleVoid = async () => {
setError(null);
try {
await voidPayment.mutateAsync(payment.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to void payment');
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{payment.paymentNumber}</Typography>
<StatusChip status={payment.status} />
</Stack>
{payment.status === 'DRAFT' && hasPermission('ap.payment.manage') && (
<Stack direction="row" spacing={1.5}>
<Button variant="outlined" color="error" onClick={handleVoid} disabled={voidPayment.isPending}>
Void
</Button>
<Button variant="contained" onClick={handlePost} disabled={postPayment.isPending}>
Post Payment
</Button>
</Stack>
)}
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Supplier
</Typography>
<Typography
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ap/suppliers/${payment.supplierId}`)}
>
{payment.supplier?.name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Date
</Typography>
<Typography>{payment.paymentDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Paid From
</Typography>
<Typography>{payment.bankGLAccount?.accountName}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Amount
</Typography>
<Typography>
<MoneyCell value={payment.amount} />
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Unallocated
</Typography>
<Typography>
<MoneyCell value={payment.unallocatedAmount} />
</Typography>
</Box>
</Stack>
</Card>
{payment.status === 'POSTED' && (
<Card sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Allocate Against Invoices
</Typography>
<APAllocationPanel
supplierId={payment.supplierId}
unallocatedAmount={Number(payment.unallocatedAmount)}
isSubmitting={allocatePayment.isPending}
onAllocate={(allocations) => allocatePayment.mutateAsync({ id: payment.id, allocations })}
/>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, MenuItem, Stack, TextField, Typography } from '@mui/material';
import { useSuppliers } from '../../../hooks/useSuppliers';
import { useGLAccounts } from '../../../hooks/useGLAccounts';
import { useCreatePayment } from '../../../hooks/useAPPayments';
export function PaymentFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedSupplierId = searchParams.get('supplierId');
const { data: suppliers } = useSuppliers('MAIN');
const createPayment = useCreatePayment();
const [supplierId, setSupplierId] = useState<number | ''>(preselectedSupplierId ? Number(preselectedSupplierId) : '');
const [paymentDate, setPaymentDate] = useState(new Date().toISOString().slice(0, 10));
const [amount, setAmount] = useState('');
const [bankGLAccountId, setBankGLAccountId] = useState<number | ''>('');
const [reference, setReference] = useState('');
const [error, setError] = useState<string | null>(null);
const supplier = (suppliers ?? []).find((s) => s.id === supplierId);
const { data: assetAccounts } = useGLAccounts(supplier ? supplier.branchSegmentValueId : 'MAIN', 'ASSET');
const bankAccounts = (assetAccounts ?? []).filter((account) => account.controlType === 'BANK');
const save = async (postAfter: boolean) => {
setError(null);
if (!supplierId || !bankGLAccountId || !Number(amount)) {
setError('Select a supplier, bank account, and enter an amount');
return;
}
try {
const payment = await createPayment.mutateAsync({
supplierId,
paymentDate,
amount: Number(amount),
bankGLAccountId,
reference: reference || undefined,
});
navigate(postAfter ? `/ap/payments/${payment.id}?post=1` : `/ap/payments/${payment.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to save payment'));
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Make Payment
</Typography>
<Card sx={{ p: 2, maxWidth: 480 }}>
<Stack spacing={2}>
<TextField select label="Supplier" value={supplierId} onChange={(e) => setSupplierId(Number(e.target.value))}>
{(suppliers ?? []).map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.code} — {s.name}
</MenuItem>
))}
</TextField>
<TextField
label="Payment date"
type="date"
value={paymentDate}
onChange={(e) => setPaymentDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
slotProps={{ htmlInput: { min: 0, step: '0.01' } }}
/>
<TextField select label="Paid from" value={bankGLAccountId} onChange={(e) => setBankGLAccountId(Number(e.target.value))}>
{bankAccounts.map((account) => (
<MenuItem key={account.id} value={account.id}>
{account.accountCode} — {account.accountName}
</MenuItem>
))}
</TextField>
<TextField label="Reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Stack>
</Card>
{error && (
<Alert severity="error" sx={{ mt: 2, maxWidth: 480 }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button variant="contained" disabled={createPayment.isPending} onClick={() => save(true)}>
Post Payment
</Button>
<Button variant="outlined" disabled={createPayment.isPending} onClick={() => save(false)}>
Save Draft
</Button>
<Button onClick={() => navigate(-1)}>Cancel</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,64 @@
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { usePayments } from '../../../hooks/useAPPayments';
import { useBranchStore } from '../../../store/branchStore';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { APPayment } from '../../../types/api';
export function PaymentsListPage() {
const navigate = useNavigate();
const selected = useBranchStore((state) => state.selected);
const { data: payments, isLoading } = usePayments(selected);
const { hasPermission } = useAuth();
const columns: GridColDef<APPayment>[] = [
{ field: 'paymentNumber', headerName: 'Payment #', width: 180 },
{ field: 'supplier', headerName: 'Supplier', flex: 1, valueGetter: (_v, row) => row.supplier?.name },
{ field: 'paymentDate', headerName: 'Date', width: 120, valueFormatter: (value: string) => value?.slice(0, 10) },
{
field: 'amount',
headerName: 'Amount',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.amount} />,
},
{
field: 'unallocatedAmount',
headerName: 'Unallocated',
width: 130,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.unallocatedAmount} />,
},
{ field: 'status', headerName: 'Status', width: 120, renderCell: (params) => <StatusChip status={params.row.status} /> },
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Payments</Typography>
{hasPermission('ap.payment.manage') && (
<Button variant="contained" onClick={() => navigate('/ap/payments/new')}>
Make Payment
</Button>
)}
</Stack>
<Card sx={{ height: 650 }}>
<DataGrid
rows={payments ?? []}
columns={columns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
onRowClick={(params) => navigate(`/ap/payments/${params.row.id}`)}
sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' } }}
initialState={{ sorting: { sortModel: [{ field: 'paymentDate', sort: 'desc' }] } }}
/>
</Card>
</Box>
);
}
@@ -0,0 +1,126 @@
import { useMemo, useState } from 'react';
import { Alert, Box, Button, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography } from '@mui/material';
import { useInvoices } from '../../../hooks/useAPInvoices';
import { MoneyCell } from '../../../components/common/MoneyCell';
interface APAllocationPanelProps {
supplierId: number;
unallocatedAmount: number;
onAllocate: (allocations: { invoiceId: number; amount: number }[]) => Promise<unknown>;
isSubmitting: boolean;
}
export function APAllocationPanel({ supplierId, unallocatedAmount, onAllocate, isSubmitting }: APAllocationPanelProps) {
const { data: invoices } = useInvoices('MAIN', supplierId);
const openInvoices = (invoices ?? []).filter((invoice) => invoice.status === 'POSTED' && Number(invoice.balance) > 0);
const [amounts, setAmounts] = useState<Record<number, string>>({});
const [error, setError] = useState<string | null>(null);
const totalEntered = useMemo(
() => Object.values(amounts).reduce((sum, value) => sum + (Number(value) || 0), 0),
[amounts],
);
const remaining = unallocatedAmount - totalEntered;
if (unallocatedAmount <= 0) {
return (
<Typography variant="body2" color="text.secondary">
Fully allocated.
</Typography>
);
}
const applyFull = (invoiceId: number, balance: number) => {
const applied = Math.min(balance, unallocatedAmount - totalEntered + (Number(amounts[invoiceId]) || 0));
setAmounts((prev) => ({ ...prev, [invoiceId]: applied > 0 ? applied.toFixed(2) : '' }));
};
const handleApply = async () => {
setError(null);
const allocations = Object.entries(amounts)
.map(([invoiceId, value]) => ({ invoiceId: Number(invoiceId), amount: Number(value) || 0 }))
.filter((allocation) => allocation.amount > 0);
if (allocations.length === 0) {
setError('Enter at least one allocation amount');
return;
}
if (totalEntered > unallocatedAmount) {
setError('Total allocated exceeds the unallocated amount');
return;
}
try {
await onAllocate(allocations);
setAmounts({});
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to allocate'));
}
};
if (openInvoices.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
This supplier has no open invoices to allocate against.
</Typography>
);
}
return (
<Box>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Invoice #</TableCell>
<TableCell>Due</TableCell>
<TableCell align="right">Balance</TableCell>
<TableCell align="right" width={160}>
Apply Amount
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{openInvoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>{invoice.invoiceNumber}</TableCell>
<TableCell>{invoice.dueDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.balance} />
</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={1} sx={{ justifyContent: 'flex-end' }}>
<TextField
size="small"
type="number"
value={amounts[invoice.id] ?? ''}
onChange={(e) => setAmounts((prev) => ({ ...prev, [invoice.id]: e.target.value }))}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
<Button size="small" onClick={() => applyFull(invoice.id, Number(invoice.balance))}>
Full
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Stack direction="row" sx={{ mt: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color={remaining < 0 ? 'error.main' : 'text.secondary'}>
Remaining unallocated: <MoneyCell value={remaining} />
</Typography>
<Button variant="contained" onClick={handleApply} disabled={isSubmitting}>
Apply Allocation
</Button>
</Stack>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
);
}
@@ -0,0 +1,142 @@
import { IconButton, MenuItem, Table, TableBody, TableCell, TableHead, TableRow, TextField } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { GLAccount, TaxCode } from '../../../types/api';
export interface APDocumentLineDraft {
key: number;
description: string;
quantity: string;
unitPrice: string;
expenseGLAccountId: number | '';
taxCodeId: number | '';
}
let keySeed = 0;
export const blankAPDocumentLine = (): APDocumentLineDraft => ({
key: keySeed++,
description: '',
quantity: '1',
unitPrice: '',
expenseGLAccountId: '',
taxCodeId: '',
});
export function apLineTotals(line: APDocumentLineDraft, taxCodes: TaxCode[]) {
const quantity = Number(line.quantity) || 0;
const unitPrice = Number(line.unitPrice) || 0;
const subtotal = quantity * unitPrice;
const rate = line.taxCodeId ? Number(taxCodes.find((t) => t.id === line.taxCodeId)?.ratePercent ?? 0) : 0;
const tax = subtotal * (rate / 100);
return { subtotal, tax, total: subtotal + tax };
}
interface APDocumentLineEditorProps {
lines: APDocumentLineDraft[];
onChange: (lines: APDocumentLineDraft[]) => void;
expenseAccounts: GLAccount[];
taxCodes: TaxCode[];
}
export function APDocumentLineEditor({ lines, onChange, expenseAccounts, taxCodes }: APDocumentLineEditorProps) {
const updateLine = (key: number, patch: Partial<APDocumentLineDraft>) =>
onChange(lines.map((line) => (line.key === key ? { ...line, ...patch } : line)));
const removeLine = (key: number) => onChange(lines.length > 1 ? lines.filter((line) => line.key !== key) : lines);
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell width="30%">Description</TableCell>
<TableCell width="10%" align="right">
Qty
</TableCell>
<TableCell width="14%" align="right">
Unit Price
</TableCell>
<TableCell width="20%">Expense Account</TableCell>
<TableCell width="14%">Tax</TableCell>
<TableCell width="10%" align="right">
Amount
</TableCell>
<TableCell width="2%" />
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
const { total } = apLineTotals(line, taxCodes);
return (
<TableRow key={line.key}>
<TableCell>
<TextField
fullWidth
size="small"
value={line.description}
onChange={(e) => updateLine(line.key, { description: e.target.value })}
/>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={line.quantity}
onChange={(e) => updateLine(line.key, { quantity: e.target.value })}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={line.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
</TableCell>
<TableCell>
<TextField
select
fullWidth
size="small"
value={line.expenseGLAccountId}
onChange={(e) => updateLine(line.key, { expenseGLAccountId: Number(e.target.value) })}
>
{expenseAccounts.map((account) => (
<MenuItem key={account.id} value={account.id}>
{account.accountCode} — {account.accountName}
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell>
<TextField
select
fullWidth
size="small"
value={line.taxCodeId}
onChange={(e) => updateLine(line.key, { taxCodeId: e.target.value ? Number(e.target.value) : '' })}
>
<MenuItem value="">None</MenuItem>
{taxCodes.map((taxCode) => (
<MenuItem key={taxCode.id} value={taxCode.id}>
{taxCode.code} ({Number(taxCode.ratePercent)}%)
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell align="right">
<MoneyCell value={total} />
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => removeLine(line.key)} disabled={lines.length <= 1}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -0,0 +1,218 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Box, Button, Card, CircularProgress, Stack, Tab, Tabs, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { useSupplier } from '../../../hooks/useSuppliers';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { APDebitNote, APInvoice, APPayment } from '../../../types/api';
function InvoicesTable({ invoices, onRowClick }: { invoices: APInvoice[]; onRowClick: (id: number) => void }) {
if (invoices.length === 0) return <Typography color="text.secondary">No invoices yet.</Typography>;
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Invoice #</TableCell>
<TableCell>Supplier Ref</TableCell>
<TableCell>Date</TableCell>
<TableCell>Due</TableCell>
<TableCell align="right">Total</TableCell>
<TableCell align="right">Balance</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id} onClick={() => onRowClick(invoice.id)} sx={{ cursor: 'pointer' }}>
<TableCell sx={{ fontFamily: 'monospace' }}>{invoice.invoiceNumber}</TableCell>
<TableCell>{invoice.supplierReference || '—'}</TableCell>
<TableCell>{invoice.invoiceDate.slice(0, 10)}</TableCell>
<TableCell>{invoice.dueDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.total} />
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.balance} />
</TableCell>
<TableCell>
<StatusChip status={invoice.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function PaymentsTable({ payments }: { payments: APPayment[] }) {
if (payments.length === 0) return <Typography color="text.secondary">No payments yet.</Typography>;
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Payment #</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="right">Unallocated</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{payments.map((payment) => (
<TableRow key={payment.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>{payment.paymentNumber}</TableCell>
<TableCell>{payment.paymentDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={payment.amount} />
</TableCell>
<TableCell align="right">
<MoneyCell value={payment.unallocatedAmount} />
</TableCell>
<TableCell>
<StatusChip status={payment.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function DebitNotesTable({ debitNotes }: { debitNotes: APDebitNote[] }) {
if (debitNotes.length === 0) return <Typography color="text.secondary">No debit notes yet.</Typography>;
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Debit Note #</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Total</TableCell>
<TableCell align="right">Unallocated</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{debitNotes.map((debitNote) => (
<TableRow key={debitNote.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>{debitNote.debitNoteNumber}</TableCell>
<TableCell>{debitNote.debitNoteDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={debitNote.total} />
</TableCell>
<TableCell align="right">
<MoneyCell value={debitNote.balance} />
</TableCell>
<TableCell>
<StatusChip status={debitNote.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
export function SupplierDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { data, isLoading } = useSupplier(id ? Number(id) : undefined);
const { hasPermission } = useAuth();
const [tab, setTab] = useState(0);
if (isLoading || !data) return <CircularProgress />;
const { supplier, invoices, debitNotes, payments, outstandingBalance, overdueBalance } = data;
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{supplier.name}</Typography>
<StatusChip status={supplier.isActive ? 'ACTIVE' : 'CLOSED'} />
</Stack>
<Stack direction="row" spacing={1.5}>
{hasPermission('ap.payment.manage') && (
<Button variant="outlined" onClick={() => navigate(`/ap/payments/new?supplierId=${supplier.id}`)}>
Make Payment
</Button>
)}
{hasPermission('ap.invoice.manage') && (
<Button variant="contained" onClick={() => navigate(`/ap/invoices/new?supplierId=${supplier.id}`)}>
New Invoice
</Button>
)}
</Stack>
</Stack>
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap' }}>
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary">
Outstanding Balance
</Typography>
<Typography variant="h5">
<MoneyCell value={outstandingBalance} />
</Typography>
</Card>
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary">
Overdue (30+ days)
</Typography>
<Typography variant="h5" color={overdueBalance > 0 ? 'error.main' : 'text.primary'}>
<MoneyCell value={overdueBalance} />
</Typography>
</Card>
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary">
Payment Terms
</Typography>
<Typography variant="h5">{supplier.paymentTermsDays} days</Typography>
</Card>
</Stack>
<Card sx={{ p: 2.5, mb: 3 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Contact Details
</Typography>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Code
</Typography>
<Typography variant="body2">{supplier.code}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Email
</Typography>
<Typography variant="body2">{supplier.email || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Phone
</Typography>
<Typography variant="body2">{supplier.phone || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Tax Number
</Typography>
<Typography variant="body2">{supplier.taxNumber || '—'}</Typography>
</Box>
</Stack>
</Card>
<Tabs value={tab} onChange={(_e, value) => setTab(value)} sx={{ mb: 2 }}>
<Tab label={`Invoices (${invoices.length})`} />
<Tab label={`Payments (${payments.length})`} />
<Tab label={`Debit Notes (${debitNotes.length})`} />
</Tabs>
<Card sx={{ p: 2 }}>
{tab === 0 && <InvoicesTable invoices={invoices} onRowClick={(invoiceId) => navigate(`/ap/invoices/${invoiceId}`)} />}
{tab === 1 && <PaymentsTable payments={payments} />}
{tab === 2 && <DebitNotesTable debitNotes={debitNotes} />}
</Card>
</Box>
);
}
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material';
import { useCreateSupplier } from '../../../hooks/useSuppliers';
import { useSegmentDefinitions } from '../../../hooks/useSegments';
export function SupplierDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const createSupplier = useCreateSupplier();
const { data: definitions } = useSegmentDefinitions();
const branchDef = (definitions ?? []).find((def) => def.isBranchSegment);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [branchSegmentValueId, setBranchSegmentValueId] = useState<number | ''>('');
const [paymentTermsDays, setPaymentTermsDays] = useState('30');
const [error, setError] = useState<string | null>(null);
const reset = () => {
setCode('');
setName('');
setEmail('');
setPhone('');
setBranchSegmentValueId('');
setPaymentTermsDays('30');
};
const handleSubmit = async () => {
setError(null);
if (!branchSegmentValueId) {
setError('Select a branch');
return;
}
try {
await createSupplier.mutateAsync({
code,
name,
email: email || undefined,
phone: phone || undefined,
branchSegmentValueId,
paymentTermsDays: paymentTermsDays ? Number(paymentTermsDays) : undefined,
});
onClose();
reset();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create supplier');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Supplier</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Supplier code" value={code} onChange={(e) => setCode(e.target.value)} />
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<TextField label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextField label="Phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
<TextField
select
label="Branch"
value={branchSegmentValueId}
onChange={(e) => setBranchSegmentValueId(Number(e.target.value))}
>
{(branchDef?.values ?? []).map((value) => (
<MenuItem key={value.id} value={value.id}>
{value.code} — {value.description}
</MenuItem>
))}
</TextField>
<TextField
label="Payment terms (days)"
type="number"
value={paymentTermsDays}
onChange={(e) => setPaymentTermsDays(e.target.value)}
/>
{error && <span style={{ color: 'red' }}>{error}</span>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={createSupplier.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,57 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useSuppliers } from '../../../hooks/useSuppliers';
import { useBranchStore } from '../../../store/branchStore';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import type { Supplier } from '../../../types/api';
import { SupplierDialog } from './SupplierDialog';
export function SuppliersListPage() {
const navigate = useNavigate();
const selected = useBranchStore((state) => state.selected);
const { data: suppliers, isLoading } = useSuppliers(selected);
const { hasPermission } = useAuth();
const [dialogOpen, setDialogOpen] = useState(false);
const columns: GridColDef<Supplier>[] = [
{ field: 'code', headerName: 'Code', width: 140 },
{ field: 'name', headerName: 'Name', flex: 1 },
{ field: 'email', headerName: 'Email', flex: 1 },
{ field: 'phone', headerName: 'Phone', width: 160 },
{ field: 'paymentTermsDays', headerName: 'Terms (days)', width: 130 },
{
field: 'isActive',
headerName: 'Status',
width: 100,
renderCell: (params) => <StatusChip status={params.row.isActive ? 'ACTIVE' : 'CLOSED'} />,
},
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Suppliers</Typography>
{hasPermission('ap.supplier.manage') && (
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New Supplier
</Button>
)}
</Stack>
<Card sx={{ height: 650 }}>
<DataGrid
rows={suppliers ?? []}
columns={columns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
onRowClick={(params) => navigate(`/ap/suppliers/${params.row.id}`)}
sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' } }}
/>
</Card>
<SupplierDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
);
}
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useAllocateCreditNote, useCreditNote, usePostCreditNote, useVoidCreditNote } from '../../../hooks/useARCreditNotes';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { AllocationPanel } from '../shared/AllocationPanel';
export function CreditNoteDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const creditNoteId = id ? Number(id) : undefined;
const { data: creditNote, isLoading } = useCreditNote(creditNoteId);
const postCreditNote = usePostCreditNote();
const voidCreditNote = useVoidCreditNote();
const allocateCreditNote = useAllocateCreditNote();
const { hasPermission } = useAuth();
const [error, setError] = useState<string | null>(null);
const autoPosted = useRef(false);
useEffect(() => {
if (searchParams.get('post') === '1' && creditNote?.status === 'DRAFT' && !autoPosted.current) {
autoPosted.current = true;
postCreditNote.mutateAsync(creditNote.id).catch((err) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post credit note');
});
setSearchParams({}, { replace: true });
}
}, [creditNote, postCreditNote, searchParams, setSearchParams]);
if (isLoading || !creditNote) return <CircularProgress />;
const handlePost = async () => {
setError(null);
try {
await postCreditNote.mutateAsync(creditNote.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post credit note');
}
};
const handleVoid = async () => {
setError(null);
try {
await voidCreditNote.mutateAsync(creditNote.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to void credit note');
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{creditNote.creditNoteNumber}</Typography>
<StatusChip status={creditNote.status} />
</Stack>
{creditNote.status === 'DRAFT' && hasPermission('ar.creditnote.manage') && (
<Stack direction="row" spacing={1.5}>
<Button variant="outlined" color="error" onClick={handleVoid} disabled={voidCreditNote.isPending}>
Void
</Button>
<Button variant="contained" onClick={handlePost} disabled={postCreditNote.isPending}>
Post Credit Note
</Button>
</Stack>
)}
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Customer
</Typography>
<Typography
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ar/customers/${creditNote.customerId}`)}
>
{creditNote.customer?.name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Date
</Typography>
<Typography>{creditNote.creditNoteDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Reason
</Typography>
<Typography>{creditNote.reason || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Unallocated Balance
</Typography>
<Typography>
<MoneyCell value={creditNote.balance} />
</Typography>
</Box>
</Stack>
</Card>
<Card>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Description</TableCell>
<TableCell align="right">Qty</TableCell>
<TableCell align="right">Unit Price</TableCell>
<TableCell>Revenue Account</TableCell>
<TableCell>Tax</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{creditNote.lines.map((line) => (
<TableRow key={line.id}>
<TableCell>{line.description}</TableCell>
<TableCell align="right">{line.quantity}</TableCell>
<TableCell align="right">
<MoneyCell value={line.unitPrice} />
</TableCell>
<TableCell>{line.revenueGLAccount?.accountName}</TableCell>
<TableCell>{line.taxCode?.code ?? '—'}</TableCell>
<TableCell align="right">
<MoneyCell value={line.lineTotal} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={5} align="right">
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={creditNote.total} />
</strong>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
{creditNote.status === 'POSTED' && (
<Card sx={{ p: 2, mt: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Allocate Against Invoices
</Typography>
<AllocationPanel
customerId={creditNote.customerId}
unallocatedAmount={Number(creditNote.balance)}
isSubmitting={allocateCreditNote.isPending}
onAllocate={(allocations) => allocateCreditNote.mutateAsync({ id: creditNote.id, allocations })}
/>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,153 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, MenuItem, Stack, TextField, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useCustomers } from '../../../hooks/useCustomers';
import { useGLAccounts } from '../../../hooks/useGLAccounts';
import { useTaxCodes } from '../../../hooks/useTaxCodes';
import { useCreateCreditNote } from '../../../hooks/useARCreditNotes';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { DocumentLineEditor, blankDocumentLine, lineTotals, type DocumentLineDraft } from '../shared/DocumentLineEditor';
export function CreditNoteFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedCustomerId = searchParams.get('customerId');
const { data: customers } = useCustomers('MAIN');
const { data: taxCodes } = useTaxCodes();
const createCreditNote = useCreateCreditNote();
const [customerId, setCustomerId] = useState<number | ''>(preselectedCustomerId ? Number(preselectedCustomerId) : '');
const [creditNoteDate, setCreditNoteDate] = useState(new Date().toISOString().slice(0, 10));
const [reference, setReference] = useState('');
const [reason, setReason] = useState('');
const [lines, setLines] = useState<DocumentLineDraft[]>([blankDocumentLine()]);
const [error, setError] = useState<string | null>(null);
const customer = (customers ?? []).find((c) => c.id === customerId);
const { data: revenueAccounts } = useGLAccounts(customer ? customer.branchSegmentValueId : 'MAIN', 'INCOME');
const totals = useMemo(() => {
const computed = lines.map((line) => lineTotals(line, taxCodes ?? []));
return {
subtotal: computed.reduce((sum, l) => sum + l.subtotal, 0),
tax: computed.reduce((sum, l) => sum + l.tax, 0),
total: computed.reduce((sum, l) => sum + l.total, 0),
};
}, [lines, taxCodes]);
const save = async (postAfter: boolean) => {
setError(null);
if (!customerId) {
setError('Select a customer');
return;
}
if (lines.some((line) => !line.revenueGLAccountId || !line.description || !Number(line.unitPrice))) {
setError('Every line needs a description, unit price, and revenue account');
return;
}
try {
const creditNote = await createCreditNote.mutateAsync({
customerId,
creditNoteDate,
reference: reference || undefined,
reason: reason || undefined,
lines: lines.map((line) => ({
description: line.description,
quantity: Number(line.quantity) || 1,
unitPrice: Number(line.unitPrice) || 0,
revenueGLAccountId: line.revenueGLAccountId as number,
taxCodeId: line.taxCodeId ? (line.taxCodeId as number) : undefined,
})),
});
navigate(postAfter ? `/ar/credit-notes/${creditNote.id}?post=1` : `/ar/credit-notes/${creditNote.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to save credit note'));
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
New Credit Note
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap' }}>
<TextField
select
label="Customer"
value={customerId}
onChange={(e) => setCustomerId(Number(e.target.value))}
sx={{ minWidth: 260 }}
>
{(customers ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.code} — {c.name}
</MenuItem>
))}
</TextField>
<TextField
label="Credit note date"
type="date"
value={creditNoteDate}
onChange={(e) => setCreditNoteDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField label="Reference" value={reference} onChange={(e) => setReference(e.target.value)} sx={{ minWidth: 180 }} />
<TextField label="Reason" value={reason} onChange={(e) => setReason(e.target.value)} sx={{ minWidth: 220 }} />
</Stack>
</Card>
<Card sx={{ p: 2 }}>
<DocumentLineEditor lines={lines} onChange={setLines} revenueAccounts={revenueAccounts ?? []} taxCodes={taxCodes ?? []} />
<Button startIcon={<AddIcon />} onClick={() => setLines((prev) => [...prev, blankDocumentLine()])} sx={{ mt: 1 }}>
Add line
</Button>
<Stack spacing={0.5} sx={{ mt: 2, alignItems: 'flex-end' }}>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Subtotal
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.subtotal} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Tax
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.tax} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="subtitle1">Total</Typography>
<Typography variant="subtitle1" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.total} />
</Typography>
</Stack>
</Stack>
</Card>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button variant="contained" disabled={createCreditNote.isPending} onClick={() => save(true)}>
Post Credit Note
</Button>
<Button variant="outlined" disabled={createCreditNote.isPending} onClick={() => save(false)}>
Save Draft
</Button>
<Button onClick={() => navigate(-1)}>Cancel</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,224 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Box, Button, Card, CircularProgress, Stack, Tab, Tabs, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { useCustomer } from '../../../hooks/useCustomers';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { ARCreditNote, ARInvoice, ARReceipt } from '../../../types/api';
function InvoicesTable({ invoices, onRowClick }: { invoices: ARInvoice[]; onRowClick: (id: number) => void }) {
if (invoices.length === 0) return <Typography color="text.secondary">No invoices yet.</Typography>;
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Invoice #</TableCell>
<TableCell>Date</TableCell>
<TableCell>Due</TableCell>
<TableCell align="right">Total</TableCell>
<TableCell align="right">Balance</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id} onClick={() => onRowClick(invoice.id)} sx={{ cursor: 'pointer' }}>
<TableCell sx={{ fontFamily: 'monospace' }}>{invoice.invoiceNumber}</TableCell>
<TableCell>{invoice.invoiceDate.slice(0, 10)}</TableCell>
<TableCell>{invoice.dueDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.total} />
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.balance} />
</TableCell>
<TableCell>
<StatusChip status={invoice.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function ReceiptsTable({ receipts }: { receipts: ARReceipt[] }) {
if (receipts.length === 0) return <Typography color="text.secondary">No receipts yet.</Typography>;
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Receipt #</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="right">Unallocated</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{receipts.map((receipt) => (
<TableRow key={receipt.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>{receipt.receiptNumber}</TableCell>
<TableCell>{receipt.receiptDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={receipt.amount} />
</TableCell>
<TableCell align="right">
<MoneyCell value={receipt.unallocatedAmount} />
</TableCell>
<TableCell>
<StatusChip status={receipt.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function CreditNotesTable({ creditNotes }: { creditNotes: ARCreditNote[] }) {
if (creditNotes.length === 0) return <Typography color="text.secondary">No credit notes yet.</Typography>;
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Credit Note #</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Total</TableCell>
<TableCell align="right">Unallocated</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{creditNotes.map((creditNote) => (
<TableRow key={creditNote.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>{creditNote.creditNoteNumber}</TableCell>
<TableCell>{creditNote.creditNoteDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={creditNote.total} />
</TableCell>
<TableCell align="right">
<MoneyCell value={creditNote.balance} />
</TableCell>
<TableCell>
<StatusChip status={creditNote.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
export function CustomerDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { data, isLoading } = useCustomer(id ? Number(id) : undefined);
const { hasPermission } = useAuth();
const [tab, setTab] = useState(0);
if (isLoading || !data) return <CircularProgress />;
const { customer, invoices, creditNotes, receipts, outstandingBalance, overdueBalance } = data;
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{customer.name}</Typography>
<StatusChip status={customer.isActive ? 'ACTIVE' : 'CLOSED'} />
</Stack>
<Stack direction="row" spacing={1.5}>
{hasPermission('ar.receipt.manage') && (
<Button variant="outlined" onClick={() => navigate(`/ar/receipts/new?customerId=${customer.id}`)}>
Receive Payment
</Button>
)}
{hasPermission('ar.invoice.manage') && (
<Button variant="contained" onClick={() => navigate(`/ar/invoices/new?customerId=${customer.id}`)}>
New Invoice
</Button>
)}
</Stack>
</Stack>
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap' }}>
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary">
Outstanding Balance
</Typography>
<Typography variant="h5">
<MoneyCell value={outstandingBalance} />
</Typography>
</Card>
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary">
Overdue (30+ days)
</Typography>
<Typography variant="h5" color={overdueBalance > 0 ? 'error.main' : 'text.primary'}>
<MoneyCell value={overdueBalance} />
</Typography>
</Card>
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary">
Credit Limit
</Typography>
<Typography variant="h5">
<MoneyCell value={customer.creditLimit} />
</Typography>
</Card>
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary">
Payment Terms
</Typography>
<Typography variant="h5">{customer.paymentTermsDays} days</Typography>
</Card>
</Stack>
<Card sx={{ p: 2.5, mb: 3 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Contact Details
</Typography>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Code
</Typography>
<Typography variant="body2">{customer.code}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Email
</Typography>
<Typography variant="body2">{customer.email || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Phone
</Typography>
<Typography variant="body2">{customer.phone || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Tax Number
</Typography>
<Typography variant="body2">{customer.taxNumber || '—'}</Typography>
</Box>
</Stack>
</Card>
<Tabs value={tab} onChange={(_e, value) => setTab(value)} sx={{ mb: 2 }}>
<Tab label={`Invoices (${invoices.length})`} />
<Tab label={`Payments (${receipts.length})`} />
<Tab label={`Credit Notes (${creditNotes.length})`} />
</Tabs>
<Card sx={{ p: 2 }}>
{tab === 0 && <InvoicesTable invoices={invoices} onRowClick={(invoiceId) => navigate(`/ar/invoices/${invoiceId}`)} />}
{tab === 1 && <ReceiptsTable receipts={receipts} />}
{tab === 2 && <CreditNotesTable creditNotes={creditNotes} />}
</Card>
</Box>
);
}
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from '@mui/material';
import { useCreateCustomer } from '../../../hooks/useCustomers';
import { useSegmentDefinitions } from '../../../hooks/useSegments';
export function CustomerDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const createCustomer = useCreateCustomer();
const { data: definitions } = useSegmentDefinitions();
const branchDef = (definitions ?? []).find((def) => def.isBranchSegment);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [branchSegmentValueId, setBranchSegmentValueId] = useState<number | ''>('');
const [creditLimit, setCreditLimit] = useState('');
const [paymentTermsDays, setPaymentTermsDays] = useState('30');
const [error, setError] = useState<string | null>(null);
const reset = () => {
setCode('');
setName('');
setEmail('');
setPhone('');
setBranchSegmentValueId('');
setCreditLimit('');
setPaymentTermsDays('30');
};
const handleSubmit = async () => {
setError(null);
if (!branchSegmentValueId) {
setError('Select a branch');
return;
}
try {
await createCustomer.mutateAsync({
code,
name,
email: email || undefined,
phone: phone || undefined,
branchSegmentValueId,
creditLimit: creditLimit ? Number(creditLimit) : undefined,
paymentTermsDays: paymentTermsDays ? Number(paymentTermsDays) : undefined,
});
onClose();
reset();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create customer');
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>New Customer</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Customer code" value={code} onChange={(e) => setCode(e.target.value)} />
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<TextField label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextField label="Phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
<TextField
select
label="Branch"
value={branchSegmentValueId}
onChange={(e) => setBranchSegmentValueId(Number(e.target.value))}
>
{(branchDef?.values ?? []).map((value) => (
<MenuItem key={value.id} value={value.id}>
{value.code} — {value.description}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={2}>
<TextField
label="Credit limit"
type="number"
value={creditLimit}
onChange={(e) => setCreditLimit(e.target.value)}
fullWidth
/>
<TextField
label="Payment terms (days)"
type="number"
value={paymentTermsDays}
onChange={(e) => setPaymentTermsDays(e.target.value)}
fullWidth
/>
</Stack>
{error && <span style={{ color: 'red' }}>{error}</span>}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={createCustomer.isPending}>
Create
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useCustomers } from '../../../hooks/useCustomers';
import { useBranchStore } from '../../../store/branchStore';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { Customer } from '../../../types/api';
import { CustomerDialog } from './CustomerDialog';
export function CustomersListPage() {
const navigate = useNavigate();
const selected = useBranchStore((state) => state.selected);
const { data: customers, isLoading } = useCustomers(selected);
const { hasPermission } = useAuth();
const [dialogOpen, setDialogOpen] = useState(false);
const columns: GridColDef<Customer>[] = [
{ field: 'code', headerName: 'Code', width: 140 },
{ field: 'name', headerName: 'Name', flex: 1 },
{ field: 'email', headerName: 'Email', flex: 1 },
{ field: 'phone', headerName: 'Phone', width: 160 },
{
field: 'creditLimit',
headerName: 'Credit Limit',
width: 140,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.creditLimit} />,
},
{ field: 'paymentTermsDays', headerName: 'Terms (days)', width: 130 },
{
field: 'isActive',
headerName: 'Status',
width: 100,
renderCell: (params) => <StatusChip status={params.row.isActive ? 'ACTIVE' : 'CLOSED'} />,
},
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Customers</Typography>
{hasPermission('ar.customer.manage') && (
<Button variant="contained" onClick={() => setDialogOpen(true)}>
New Customer
</Button>
)}
</Stack>
<Card sx={{ height: 650 }}>
<DataGrid
rows={customers ?? []}
columns={columns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
onRowClick={(params) => navigate(`/ar/customers/${params.row.id}`)}
sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' } }}
/>
</Card>
<CustomerDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</Box>
);
}
@@ -0,0 +1,224 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useInvoice, usePostInvoice, useVoidInvoice } from '../../../hooks/useARInvoices';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
export function InvoiceDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const invoiceId = id ? Number(id) : undefined;
const { data: invoice, isLoading } = useInvoice(invoiceId);
const postInvoice = usePostInvoice();
const voidInvoice = useVoidInvoice();
const { hasPermission } = useAuth();
const [error, setError] = useState<string | null>(null);
const autoPosted = useRef(false);
useEffect(() => {
if (searchParams.get('post') === '1' && invoice?.status === 'DRAFT' && !autoPosted.current) {
autoPosted.current = true;
postInvoice.mutateAsync(invoice.id).catch((err) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post invoice');
});
setSearchParams({}, { replace: true });
}
}, [invoice, postInvoice, searchParams, setSearchParams]);
if (isLoading || !invoice) return <CircularProgress />;
const handlePost = async () => {
setError(null);
try {
await postInvoice.mutateAsync(invoice.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post invoice');
}
};
const handleVoid = async () => {
setError(null);
try {
await voidInvoice.mutateAsync(invoice.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to void invoice');
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{invoice.invoiceNumber}</Typography>
<StatusChip status={invoice.status} />
</Stack>
<Stack direction="row" spacing={1.5}>
{invoice.status === 'DRAFT' && hasPermission('ar.invoice.manage') && (
<>
<Button variant="outlined" color="error" onClick={handleVoid} disabled={voidInvoice.isPending}>
Void
</Button>
<Button variant="contained" onClick={handlePost} disabled={postInvoice.isPending}>
Post Invoice
</Button>
</>
)}
</Stack>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Customer
</Typography>
<Typography
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ar/customers/${invoice.customerId}`)}
>
{invoice.customer?.name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Invoice Date
</Typography>
<Typography>{invoice.invoiceDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Due Date
</Typography>
<Typography>{invoice.dueDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Reference
</Typography>
<Typography>{invoice.reference || '—'}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Balance
</Typography>
<Typography>
<MoneyCell value={invoice.balance} />
</Typography>
</Box>
</Stack>
</Card>
<Card>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Description</TableCell>
<TableCell align="right">Qty</TableCell>
<TableCell align="right">Unit Price</TableCell>
<TableCell>Revenue Account</TableCell>
<TableCell>Tax</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoice.lines.map((line) => (
<TableRow key={line.id}>
<TableCell>{line.description}</TableCell>
<TableCell align="right">{line.quantity}</TableCell>
<TableCell align="right">
<MoneyCell value={line.unitPrice} />
</TableCell>
<TableCell>{line.revenueGLAccount?.accountName}</TableCell>
<TableCell>{line.taxCode?.code ?? '—'}</TableCell>
<TableCell align="right">
<MoneyCell value={line.lineTotal} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={5} align="right">
Subtotal
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.subtotal} />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
Tax
</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.taxTotal} />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={invoice.total} />
</strong>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
{invoice.allocations && invoice.allocations.length > 0 && (
<Card sx={{ p: 2, mt: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Allocations
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Source</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoice.allocations.map((allocation) => (
<TableRow key={allocation.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>
{allocation.receipt?.receiptNumber ?? allocation.creditNote?.creditNoteNumber}
</TableCell>
<TableCell>{allocation.allocatedAt.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={allocation.amount} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,178 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, MenuItem, Stack, TextField, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useCustomers } from '../../../hooks/useCustomers';
import { useGLAccounts } from '../../../hooks/useGLAccounts';
import { useTaxCodes } from '../../../hooks/useTaxCodes';
import { useCreateInvoice } from '../../../hooks/useARInvoices';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { DocumentLineEditor, blankDocumentLine, lineTotals, type DocumentLineDraft } from '../shared/DocumentLineEditor';
function addDays(dateStr: string, days: number): string {
const date = new Date(dateStr);
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
export function InvoiceFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedCustomerId = searchParams.get('customerId');
const { data: customers } = useCustomers('MAIN');
const { data: taxCodes } = useTaxCodes();
const createInvoice = useCreateInvoice();
const [customerId, setCustomerId] = useState<number | ''>(preselectedCustomerId ? Number(preselectedCustomerId) : '');
const [invoiceDate, setInvoiceDate] = useState(new Date().toISOString().slice(0, 10));
const [reference, setReference] = useState('');
const [lines, setLines] = useState<DocumentLineDraft[]>([blankDocumentLine()]);
const [error, setError] = useState<string | null>(null);
const customer = (customers ?? []).find((c) => c.id === customerId);
const { data: revenueAccounts } = useGLAccounts(customer ? customer.branchSegmentValueId : 'MAIN', 'INCOME');
const dueDate = customer ? addDays(invoiceDate, customer.paymentTermsDays) : invoiceDate;
const totals = useMemo(() => {
const computed = lines.map((line) => lineTotals(line, taxCodes ?? []));
return {
subtotal: computed.reduce((sum, l) => sum + l.subtotal, 0),
tax: computed.reduce((sum, l) => sum + l.tax, 0),
total: computed.reduce((sum, l) => sum + l.total, 0),
};
}, [lines, taxCodes]);
const save = async (postAfter: boolean) => {
setError(null);
if (!customerId) {
setError('Select a customer');
return;
}
if (lines.some((line) => !line.revenueGLAccountId || !line.description || !Number(line.unitPrice))) {
setError('Every line needs a description, unit price, and revenue account');
return;
}
try {
const invoice = await createInvoice.mutateAsync({
customerId,
invoiceDate,
dueDate,
reference: reference || undefined,
lines: lines.map((line) => ({
description: line.description,
quantity: Number(line.quantity) || 1,
unitPrice: Number(line.unitPrice) || 0,
revenueGLAccountId: line.revenueGLAccountId as number,
taxCodeId: line.taxCodeId ? (line.taxCodeId as number) : undefined,
})),
});
navigate(postAfter ? `/ar/invoices/${invoice.id}?post=1` : `/ar/invoices/${invoice.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to save invoice'));
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
New Invoice
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap' }}>
<TextField
select
label="Customer"
value={customerId}
onChange={(e) => setCustomerId(Number(e.target.value))}
sx={{ minWidth: 260 }}
>
{(customers ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.code} — {c.name}
</MenuItem>
))}
</TextField>
<TextField
label="Invoice date"
type="date"
value={invoiceDate}
onChange={(e) => setInvoiceDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField label="Due date" type="date" value={dueDate} disabled slotProps={{ inputLabel: { shrink: true } }} />
<TextField label="Reference / PO number" value={reference} onChange={(e) => setReference(e.target.value)} sx={{ minWidth: 200 }} />
</Stack>
</Card>
<Card sx={{ p: 2 }}>
<DocumentLineEditor lines={lines} onChange={setLines} revenueAccounts={revenueAccounts ?? []} taxCodes={taxCodes ?? []} />
<Button startIcon={<AddIcon />} onClick={() => setLines((prev) => [...prev, blankDocumentLine()])} sx={{ mt: 1 }}>
Add line
</Button>
<Stack spacing={0.5} sx={{ mt: 2, alignItems: 'flex-end' }}>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Subtotal
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.subtotal} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="body2" color="text.secondary">
Tax
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.tax} />
</Typography>
</Stack>
<Stack direction="row" spacing={4}>
<Typography variant="subtitle1">Total</Typography>
<Typography variant="subtitle1" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={totals.total} />
</Typography>
</Stack>
</Stack>
</Card>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Card sx={{ p: 2, flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Notes
</Typography>
<Button size="small" disabled>
+ Add Note
</Button>
</Card>
<Card sx={{ p: 2, flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Attachments
</Typography>
<Button size="small" disabled>
+ Add Attachment
</Button>
</Card>
</Stack>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button variant="contained" disabled={createInvoice.isPending} onClick={() => save(true)}>
Post Invoice
</Button>
<Button variant="outlined" disabled={createInvoice.isPending} onClick={() => save(false)}>
Save Draft
</Button>
<Button onClick={() => navigate(-1)}>Cancel</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,65 @@
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useInvoices } from '../../../hooks/useARInvoices';
import { useBranchStore } from '../../../store/branchStore';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { ARInvoice } from '../../../types/api';
export function InvoicesListPage() {
const navigate = useNavigate();
const selected = useBranchStore((state) => state.selected);
const { data: invoices, isLoading } = useInvoices(selected);
const { hasPermission } = useAuth();
const columns: GridColDef<ARInvoice>[] = [
{ field: 'invoiceNumber', headerName: 'Invoice #', width: 180 },
{ field: 'customer', headerName: 'Customer', flex: 1, valueGetter: (_v, row) => row.customer?.name },
{ field: 'invoiceDate', headerName: 'Date', width: 120, valueFormatter: (value: string) => value?.slice(0, 10) },
{ field: 'dueDate', headerName: 'Due', width: 120, valueFormatter: (value: string) => value?.slice(0, 10) },
{
field: 'total',
headerName: 'Total',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.total} />,
},
{
field: 'balance',
headerName: 'Balance',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.balance} />,
},
{ field: 'status', headerName: 'Status', width: 120, renderCell: (params) => <StatusChip status={params.row.status} /> },
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Invoices</Typography>
{hasPermission('ar.invoice.manage') && (
<Button variant="contained" onClick={() => navigate('/ar/invoices/new')}>
New Invoice
</Button>
)}
</Stack>
<Card sx={{ height: 650 }}>
<DataGrid
rows={invoices ?? []}
columns={columns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
onRowClick={(params) => navigate(`/ar/invoices/${params.row.id}`)}
sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' } }}
initialState={{ sorting: { sortModel: [{ field: 'invoiceDate', sort: 'desc' }] } }}
/>
</Card>
</Box>
);
}
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, CircularProgress, Stack, Typography } from '@mui/material';
import { useAllocateReceipt, usePostReceipt, useReceipt, useVoidReceipt } from '../../../hooks/useARReceipts';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import { AllocationPanel } from '../shared/AllocationPanel';
export function ReceiptDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const receiptId = id ? Number(id) : undefined;
const { data: receipt, isLoading } = useReceipt(receiptId);
const postReceipt = usePostReceipt();
const voidReceipt = useVoidReceipt();
const allocateReceipt = useAllocateReceipt();
const { hasPermission } = useAuth();
const [error, setError] = useState<string | null>(null);
const autoPosted = useRef(false);
useEffect(() => {
if (searchParams.get('post') === '1' && receipt?.status === 'DRAFT' && !autoPosted.current) {
autoPosted.current = true;
postReceipt.mutateAsync(receipt.id).catch((err) => {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post receipt');
});
setSearchParams({}, { replace: true });
}
}, [receipt, postReceipt, searchParams, setSearchParams]);
if (isLoading || !receipt) return <CircularProgress />;
const handlePost = async () => {
setError(null);
try {
await postReceipt.mutateAsync(receipt.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to post receipt');
}
};
const handleVoid = async () => {
setError(null);
try {
await voidReceipt.mutateAsync(receipt.id);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to void receipt');
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography variant="h5">{receipt.receiptNumber}</Typography>
<StatusChip status={receipt.status} />
</Stack>
{receipt.status === 'DRAFT' && hasPermission('ar.receipt.manage') && (
<Stack direction="row" spacing={1.5}>
<Button variant="outlined" color="error" onClick={handleVoid} disabled={voidReceipt.isPending}>
Void
</Button>
<Button variant="contained" onClick={handlePost} disabled={postReceipt.isPending}>
Post Receipt
</Button>
</Stack>
)}
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4} sx={{ flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="text.secondary">
Customer
</Typography>
<Typography
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ar/customers/${receipt.customerId}`)}
>
{receipt.customer?.name}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Date
</Typography>
<Typography>{receipt.receiptDate.slice(0, 10)}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Deposited To
</Typography>
<Typography>{receipt.bankGLAccount?.accountName}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Amount
</Typography>
<Typography>
<MoneyCell value={receipt.amount} />
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Unallocated
</Typography>
<Typography>
<MoneyCell value={receipt.unallocatedAmount} />
</Typography>
</Box>
</Stack>
</Card>
{receipt.status === 'POSTED' && (
<Card sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
Allocate Against Invoices
</Typography>
<AllocationPanel
customerId={receipt.customerId}
unallocatedAmount={Number(receipt.unallocatedAmount)}
isSubmitting={allocateReceipt.isPending}
onAllocate={(allocations) => allocateReceipt.mutateAsync({ id: receipt.id, allocations })}
/>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert, Box, Button, Card, MenuItem, Stack, TextField, Typography } from '@mui/material';
import { useCustomers } from '../../../hooks/useCustomers';
import { useGLAccounts } from '../../../hooks/useGLAccounts';
import { useCreateReceipt } from '../../../hooks/useARReceipts';
export function ReceiptFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedCustomerId = searchParams.get('customerId');
const { data: customers } = useCustomers('MAIN');
const createReceipt = useCreateReceipt();
const [customerId, setCustomerId] = useState<number | ''>(preselectedCustomerId ? Number(preselectedCustomerId) : '');
const [receiptDate, setReceiptDate] = useState(new Date().toISOString().slice(0, 10));
const [amount, setAmount] = useState('');
const [bankGLAccountId, setBankGLAccountId] = useState<number | ''>('');
const [reference, setReference] = useState('');
const [error, setError] = useState<string | null>(null);
const customer = (customers ?? []).find((c) => c.id === customerId);
const { data: assetAccounts } = useGLAccounts(customer ? customer.branchSegmentValueId : 'MAIN', 'ASSET');
const bankAccounts = (assetAccounts ?? []).filter((account) => account.controlType === 'BANK');
const save = async (postAfter: boolean) => {
setError(null);
if (!customerId || !bankGLAccountId || !Number(amount)) {
setError('Select a customer, bank account, and enter an amount');
return;
}
try {
const receipt = await createReceipt.mutateAsync({
customerId,
receiptDate,
amount: Number(amount),
bankGLAccountId,
reference: reference || undefined,
});
navigate(postAfter ? `/ar/receipts/${receipt.id}?post=1` : `/ar/receipts/${receipt.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to save receipt'));
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Receive Payment
</Typography>
<Card sx={{ p: 2, maxWidth: 480 }}>
<Stack spacing={2}>
<TextField select label="Customer" value={customerId} onChange={(e) => setCustomerId(Number(e.target.value))}>
{(customers ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.code} — {c.name}
</MenuItem>
))}
</TextField>
<TextField
label="Receipt date"
type="date"
value={receiptDate}
onChange={(e) => setReceiptDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="Amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
slotProps={{ htmlInput: { min: 0, step: '0.01' } }}
/>
<TextField select label="Deposited to" value={bankGLAccountId} onChange={(e) => setBankGLAccountId(Number(e.target.value))}>
{bankAccounts.map((account) => (
<MenuItem key={account.id} value={account.id}>
{account.accountCode} — {account.accountName}
</MenuItem>
))}
</TextField>
<TextField label="Reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Stack>
</Card>
{error && (
<Alert severity="error" sx={{ mt: 2, maxWidth: 480 }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button variant="contained" disabled={createReceipt.isPending} onClick={() => save(true)}>
Post Receipt
</Button>
<Button variant="outlined" disabled={createReceipt.isPending} onClick={() => save(false)}>
Save Draft
</Button>
<Button onClick={() => navigate(-1)}>Cancel</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,64 @@
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, Stack, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useReceipts } from '../../../hooks/useARReceipts';
import { useBranchStore } from '../../../store/branchStore';
import { useAuth } from '../../../hooks/useAuth';
import { StatusChip } from '../../../components/common/StatusChip';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { ARReceipt } from '../../../types/api';
export function ReceiptsListPage() {
const navigate = useNavigate();
const selected = useBranchStore((state) => state.selected);
const { data: receipts, isLoading } = useReceipts(selected);
const { hasPermission } = useAuth();
const columns: GridColDef<ARReceipt>[] = [
{ field: 'receiptNumber', headerName: 'Receipt #', width: 180 },
{ field: 'customer', headerName: 'Customer', flex: 1, valueGetter: (_v, row) => row.customer?.name },
{ field: 'receiptDate', headerName: 'Date', width: 120, valueFormatter: (value: string) => value?.slice(0, 10) },
{
field: 'amount',
headerName: 'Amount',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.amount} />,
},
{
field: 'unallocatedAmount',
headerName: 'Unallocated',
width: 130,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.unallocatedAmount} />,
},
{ field: 'status', headerName: 'Status', width: 120, renderCell: (params) => <StatusChip status={params.row.status} /> },
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">Receipts</Typography>
{hasPermission('ar.receipt.manage') && (
<Button variant="contained" onClick={() => navigate('/ar/receipts/new')}>
Receive Payment
</Button>
)}
</Stack>
<Card sx={{ height: 650 }}>
<DataGrid
rows={receipts ?? []}
columns={columns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
onRowClick={(params) => navigate(`/ar/receipts/${params.row.id}`)}
sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' } }}
initialState={{ sorting: { sortModel: [{ field: 'receiptDate', sort: 'desc' }] } }}
/>
</Card>
</Box>
);
}
@@ -0,0 +1,126 @@
import { useMemo, useState } from 'react';
import { Alert, Box, Button, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography } from '@mui/material';
import { useInvoices } from '../../../hooks/useARInvoices';
import { MoneyCell } from '../../../components/common/MoneyCell';
interface AllocationPanelProps {
customerId: number;
unallocatedAmount: number;
onAllocate: (allocations: { invoiceId: number; amount: number }[]) => Promise<unknown>;
isSubmitting: boolean;
}
export function AllocationPanel({ customerId, unallocatedAmount, onAllocate, isSubmitting }: AllocationPanelProps) {
const { data: invoices } = useInvoices('MAIN', customerId);
const openInvoices = (invoices ?? []).filter((invoice) => invoice.status === 'POSTED' && Number(invoice.balance) > 0);
const [amounts, setAmounts] = useState<Record<number, string>>({});
const [error, setError] = useState<string | null>(null);
const totalEntered = useMemo(
() => Object.values(amounts).reduce((sum, value) => sum + (Number(value) || 0), 0),
[amounts],
);
const remaining = unallocatedAmount - totalEntered;
if (unallocatedAmount <= 0) {
return (
<Typography variant="body2" color="text.secondary">
Fully allocated.
</Typography>
);
}
const applyFull = (invoiceId: number, balance: number) => {
const applied = Math.min(balance, unallocatedAmount - totalEntered + (Number(amounts[invoiceId]) || 0));
setAmounts((prev) => ({ ...prev, [invoiceId]: applied > 0 ? applied.toFixed(2) : '' }));
};
const handleApply = async () => {
setError(null);
const allocations = Object.entries(amounts)
.map(([invoiceId, value]) => ({ invoiceId: Number(invoiceId), amount: Number(value) || 0 }))
.filter((allocation) => allocation.amount > 0);
if (allocations.length === 0) {
setError('Enter at least one allocation amount');
return;
}
if (totalEntered > unallocatedAmount) {
setError('Total allocated exceeds the unallocated amount');
return;
}
try {
await onAllocate(allocations);
setAmounts({});
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to allocate'));
}
};
if (openInvoices.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
This customer has no open invoices to allocate against.
</Typography>
);
}
return (
<Box>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Invoice #</TableCell>
<TableCell>Due</TableCell>
<TableCell align="right">Balance</TableCell>
<TableCell align="right" width={160}>
Apply Amount
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{openInvoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell sx={{ fontFamily: 'monospace' }}>{invoice.invoiceNumber}</TableCell>
<TableCell>{invoice.dueDate.slice(0, 10)}</TableCell>
<TableCell align="right">
<MoneyCell value={invoice.balance} />
</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={1} sx={{ justifyContent: 'flex-end' }}>
<TextField
size="small"
type="number"
value={amounts[invoice.id] ?? ''}
onChange={(e) => setAmounts((prev) => ({ ...prev, [invoice.id]: e.target.value }))}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
<Button size="small" onClick={() => applyFull(invoice.id, Number(invoice.balance))}>
Full
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Stack direction="row" sx={{ mt: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color={remaining < 0 ? 'error.main' : 'text.secondary'}>
Remaining unallocated: <MoneyCell value={remaining} />
</Typography>
<Button variant="contained" onClick={handleApply} disabled={isSubmitting}>
Apply Allocation
</Button>
</Stack>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
);
}
@@ -0,0 +1,142 @@
import { IconButton, MenuItem, Table, TableBody, TableCell, TableHead, TableRow, TextField } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import { MoneyCell } from '../../../components/common/MoneyCell';
import type { GLAccount, TaxCode } from '../../../types/api';
export interface DocumentLineDraft {
key: number;
description: string;
quantity: string;
unitPrice: string;
revenueGLAccountId: number | '';
taxCodeId: number | '';
}
let keySeed = 0;
export const blankDocumentLine = (): DocumentLineDraft => ({
key: keySeed++,
description: '',
quantity: '1',
unitPrice: '',
revenueGLAccountId: '',
taxCodeId: '',
});
export function lineTotals(line: DocumentLineDraft, taxCodes: TaxCode[]) {
const quantity = Number(line.quantity) || 0;
const unitPrice = Number(line.unitPrice) || 0;
const subtotal = quantity * unitPrice;
const rate = line.taxCodeId ? Number(taxCodes.find((t) => t.id === line.taxCodeId)?.ratePercent ?? 0) : 0;
const tax = subtotal * (rate / 100);
return { subtotal, tax, total: subtotal + tax };
}
interface DocumentLineEditorProps {
lines: DocumentLineDraft[];
onChange: (lines: DocumentLineDraft[]) => void;
revenueAccounts: GLAccount[];
taxCodes: TaxCode[];
}
export function DocumentLineEditor({ lines, onChange, revenueAccounts, taxCodes }: DocumentLineEditorProps) {
const updateLine = (key: number, patch: Partial<DocumentLineDraft>) =>
onChange(lines.map((line) => (line.key === key ? { ...line, ...patch } : line)));
const removeLine = (key: number) => onChange(lines.length > 1 ? lines.filter((line) => line.key !== key) : lines);
return (
<Table size="small">
<TableHead>
<TableRow>
<TableCell width="30%">Description</TableCell>
<TableCell width="10%" align="right">
Qty
</TableCell>
<TableCell width="14%" align="right">
Unit Price
</TableCell>
<TableCell width="20%">Revenue Account</TableCell>
<TableCell width="14%">Tax</TableCell>
<TableCell width="10%" align="right">
Amount
</TableCell>
<TableCell width="2%" />
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
const { total } = lineTotals(line, taxCodes);
return (
<TableRow key={line.key}>
<TableCell>
<TextField
fullWidth
size="small"
value={line.description}
onChange={(e) => updateLine(line.key, { description: e.target.value })}
/>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={line.quantity}
onChange={(e) => updateLine(line.key, { quantity: e.target.value })}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={line.unitPrice}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
</TableCell>
<TableCell>
<TextField
select
fullWidth
size="small"
value={line.revenueGLAccountId}
onChange={(e) => updateLine(line.key, { revenueGLAccountId: Number(e.target.value) })}
>
{revenueAccounts.map((account) => (
<MenuItem key={account.id} value={account.id}>
{account.accountCode} — {account.accountName}
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell>
<TextField
select
fullWidth
size="small"
value={line.taxCodeId}
onChange={(e) => updateLine(line.key, { taxCodeId: e.target.value ? Number(e.target.value) : '' })}
>
<MenuItem value="">None</MenuItem>
{taxCodes.map((taxCode) => (
<MenuItem key={taxCode.id} value={taxCode.id}>
{taxCode.code} ({Number(taxCode.ratePercent)}%)
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell align="right">
<MoneyCell value={total} />
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => removeLine(line.key)} disabled={lines.length <= 1}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert, Box, CircularProgress, Typography } from '@mui/material';
import { useAuth } from '../../hooks/useAuth';
/**
* Lands here after the Account Manager redirects with a one-time handoff token
* (see manager/src/modules/companies/companies.service.ts issueHandoff()). Exchanges it for a
* session in this company app and moves on — there is no local login form to fall back to.
*/
export function HandoffPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { handoff, handoffError } = useAuth();
const attempted = useRef(false);
useEffect(() => {
const token = searchParams.get('token');
if (!token || attempted.current) return;
attempted.current = true;
handoff(token)
.then(() => navigate('/', { replace: true }))
.catch(() => undefined);
}, [searchParams, handoff, navigate]);
return (
<Box sx={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 2 }}>
{!handoffError && (
<>
<CircularProgress />
<Typography color="text.secondary">Signing you in…</Typography>
</>
)}
{handoffError && (
<Alert severity="error" sx={{ maxWidth: 420 }}>
{(handoffError as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'Could not complete sign-in. Return to the Account Manager and try again.'}
</Alert>
)}
</Box>
);
}
@@ -0,0 +1,197 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Card, CircularProgress, Stack, Typography } from '@mui/material';
import PostAddOutlinedIcon from '@mui/icons-material/PostAddOutlined';
import AccountBalanceOutlinedIcon from '@mui/icons-material/AccountBalanceOutlined';
import MenuBookOutlinedIcon from '@mui/icons-material/MenuBookOutlined';
import GroupOutlinedIcon from '@mui/icons-material/GroupOutlined';
import { useJournalEntries } from '../../hooks/useJournalEntries';
import { useAuth } from '../../hooks/useAuth';
import { StatusChip } from '../../components/common/StatusChip';
import { MoneyCell } from '../../components/common/MoneyCell';
import { ACCENT_BLUE } from '../../theme';
import type { GLAccountType } from '../../types/api';
const TYPE_COLOR: Record<GLAccountType, string> = {
ASSET: '#2f6fed',
LIABILITY: '#e0631f',
EQUITY: '#7c3aed',
INCOME: '#1a9e6e',
EXPENSE: '#d1364c',
};
function KpiTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<Card sx={{ p: 2.5, flex: 1, minWidth: 200 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
{label}
</Typography>
<Typography variant="h5">{value}</Typography>
{sub && (
<Typography variant="caption" color="text.secondary">
{sub}
</Typography>
)}
</Card>
);
}
export function DashboardPage() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { data: entries, isLoading } = useJournalEntries('MAIN');
const stats = useMemo(() => {
const all = entries ?? [];
const posted = all.filter((e) => e.status === 'POSTED');
const draft = all.filter((e) => e.status === 'DRAFT');
const totalPostedValue = posted.reduce((sum, e) => sum + Number(e.totalDebits), 0);
const accountIds = new Set<number>();
const movementByType = new Map<GLAccountType, number>();
for (const entry of posted) {
for (const line of entry.lines) {
accountIds.add(line.glAccountId);
const net = Number(line.debit) - Number(line.credit);
const signed = line.glAccount.normalBalance === 'DEBIT' ? net : -net;
movementByType.set(line.glAccount.accountType, (movementByType.get(line.glAccount.accountType) ?? 0) + Math.abs(signed));
}
}
const recent = [...all].sort((a, b) => b.entryDate.localeCompare(a.entryDate)).slice(0, 8);
const maxMovement = Math.max(1, ...Array.from(movementByType.values()));
return { posted, draft, totalPostedValue, accountsInUse: accountIds.size, movementByType, recent, maxMovement };
}, [entries]);
if (isLoading) return <CircularProgress />;
const quickActions = [
{ label: 'New Manual Entry', path: '/gl/journal-entries/new', icon: <PostAddOutlinedIcon />, permission: 'gl.journal.post' },
{ label: 'Chart of Accounts', path: '/admin/chart-of-accounts', icon: <AccountBalanceOutlinedIcon />, permission: 'gl.account.manage' },
{ label: 'Journals', path: '/admin/journals', icon: <MenuBookOutlinedIcon />, permission: 'gl.journal.manage' },
{ label: 'Users & Roles', path: '/admin/users', icon: <GroupOutlinedIcon />, permission: 'admin.users.manage' },
].filter((action) => !action.permission || hasPermission(action.permission));
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Dashboard
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Overview of general ledger activity across all branches.
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap' }}>
<KpiTile label="Posted Entries" value={String(stats.posted.length)} sub={`${stats.accountsInUse} accounts in use`} />
<KpiTile label="Draft Entries" value={String(stats.draft.length)} sub="Awaiting posting" />
<KpiTile
label="Total Posted Movement"
value={stats.totalPostedValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
sub="Sum of posted debit legs"
/>
<KpiTile label="Total Entries" value={String((entries ?? []).length)} sub="All journals, all branches" />
</Stack>
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', alignItems: 'stretch' }}>
<Card sx={{ p: 2.5, flex: 2, minWidth: 320 }}>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Posted Movement by Account Type
</Typography>
{stats.movementByType.size === 0 ? (
<Typography variant="body2" color="text.secondary">
No posted entries yet.
</Typography>
) : (
<Stack spacing={1.5}>
{(Object.keys(TYPE_COLOR) as GLAccountType[])
.filter((type) => stats.movementByType.has(type))
.map((type) => {
const value = stats.movementByType.get(type) ?? 0;
const pct = (value / stats.maxMovement) * 100;
return (
<Box key={type}>
<Stack direction="row" sx={{ justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2">{type}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
<MoneyCell value={value} />
</Typography>
</Stack>
<Box sx={{ height: 8, borderRadius: 4, bgcolor: '#eef1f8', overflow: 'hidden' }}>
<Box sx={{ height: '100%', width: `${pct}%`, bgcolor: TYPE_COLOR[type], borderRadius: 4 }} />
</Box>
</Box>
);
})}
</Stack>
)}
</Card>
<Card sx={{ p: 2.5, flex: 1, minWidth: 220 }}>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Quick Actions
</Typography>
<Stack spacing={1}>
{quickActions.map((action) => (
<Stack
key={action.path}
direction="row"
spacing={1.5}
onClick={() => navigate(action.path)}
sx={{
alignItems: 'center',
p: 1,
borderRadius: 2,
cursor: 'pointer',
color: ACCENT_BLUE,
'&:hover': { bgcolor: '#f0f4ff' },
}}
>
{action.icon}
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{action.label}
</Typography>
</Stack>
))}
</Stack>
</Card>
</Stack>
<Card sx={{ p: 2.5 }}>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Recent Activity
</Typography>
{stats.recent.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No journal entries yet.
</Typography>
) : (
<Stack divider={<Box sx={{ borderBottom: '1px solid', borderColor: 'divider' }} />}>
{stats.recent.map((entry) => (
<Stack
key={entry.id}
direction="row"
spacing={2}
sx={{ py: 1.25, alignItems: 'center', cursor: 'pointer' }}
onClick={() => navigate(`/gl/journal-entries/${entry.id}`)}
>
<Box sx={{ width: 90 }}>
<Typography variant="caption" color="text.secondary">
{entry.entryDate.slice(0, 10)}
</Typography>
</Box>
<Typography variant="body2" sx={{ width: 140, fontFamily: 'monospace' }}>
{entry.entryNumber}
</Typography>
<Typography variant="body2" sx={{ flex: 1 }} noWrap>
{entry.description}
</Typography>
<Typography variant="body2" sx={{ width: 100, textAlign: 'right' }}>
<MoneyCell value={entry.totalDebits} />
</Typography>
<StatusChip status={entry.status} />
</Stack>
))}
</Stack>
)}
</Card>
</Box>
);
}
@@ -0,0 +1,129 @@
import { useMemo, useState } from 'react';
import { Box, Card, MenuItem, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography } from '@mui/material';
import { useJournalEntries } from '../../hooks/useJournalEntries';
import { useGLAccounts } from '../../hooks/useGLAccounts';
import { useBranchStore } from '../../store/branchStore';
import { MoneyCell } from '../../components/common/MoneyCell';
/** Account-centric drill-down, computed client-side from posted entries already in cache — no new endpoint. */
export function GLAccountLedgerView() {
const selected = useBranchStore((state) => state.selected);
const { data: accounts } = useGLAccounts(selected);
const { data: entries, isLoading } = useJournalEntries(selected);
const [accountId, setAccountId] = useState<number | ''>('');
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState('');
const account = (accounts ?? []).find((a) => a.id === accountId);
const rows = useMemo(() => {
if (!accountId) return [];
let running = 0;
return (entries ?? [])
.filter((entry) => entry.status === 'POSTED')
.flatMap((entry) =>
entry.lines
.filter((line) => line.glAccountId === accountId)
.map((line) => ({ entry, line })),
)
.filter(({ entry }) => (!fromDate || entry.entryDate.slice(0, 10) >= fromDate) && (!toDate || entry.entryDate.slice(0, 10) <= toDate))
.sort((a, b) => a.entry.entryDate.localeCompare(b.entry.entryDate))
.map(({ entry, line }) => {
const movement = account?.normalBalance === 'DEBIT' ? Number(line.debit) - Number(line.credit) : Number(line.credit) - Number(line.debit);
running += movement;
return { entry, line, running };
});
}, [entries, accountId, fromDate, toDate, account?.normalBalance]);
return (
<Box>
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap' }}>
<TextField
select
label="Account"
size="small"
value={accountId}
onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')}
sx={{ minWidth: 280 }}
>
{(accounts ?? [])
.filter((a) => a.isPostable)
.map((a) => (
<MenuItem key={a.id} value={a.id}>
{a.accountCode} — {a.accountName}
</MenuItem>
))}
</TextField>
<TextField
label="From"
type="date"
size="small"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="To"
type="date"
size="small"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</Card>
{!accountId ? (
<Typography variant="body2" color="text.secondary">
Select an account to view its posted transaction history and running balance.
</Typography>
) : (
<Card>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Reference</TableCell>
<TableCell>Description</TableCell>
<TableCell align="right">Debit</TableCell>
<TableCell align="right">Credit</TableCell>
<TableCell align="right">Balance</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<Typography variant="body2" color="text.secondary">
No posted transactions for this account in the selected range.
</Typography>
</TableCell>
</TableRow>
)}
{rows.map(({ entry, line, running }) => (
<TableRow key={line.id}>
<TableCell>{entry.entryDate.slice(0, 10)}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{entry.entryNumber}</TableCell>
<TableCell>{line.description || entry.description}</TableCell>
<TableCell align="right">
<MoneyCell value={line.debit} />
</TableCell>
<TableCell align="right">
<MoneyCell value={line.credit} />
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={running} />
</strong>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import { useJournalEntry, useReverseJournalEntry } from '../../hooks/useJournalEntries';
import { StatusChip } from '../../components/common/StatusChip';
import { MoneyCell } from '../../components/common/MoneyCell';
import { ConfirmDialog } from '../../components/common/ConfirmDialog';
import { useAuth } from '../../hooks/useAuth';
export function JournalEntryDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { data: entry, isLoading } = useJournalEntry(id ? Number(id) : undefined);
const reverseEntry = useReverseJournalEntry();
const [confirmOpen, setConfirmOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
if (isLoading || !entry) return <CircularProgress />;
const handleReverse = async () => {
try {
const reversal = await reverseEntry.mutateAsync({ id: entry.id });
setConfirmOpen(false);
navigate(`/gl/journal-entries/${reversal.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? 'Failed to reverse entry');
setConfirmOpen(false);
}
};
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">{entry.entryNumber}</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<StatusChip status={entry.status} />
{entry.status === 'POSTED' && hasPermission('gl.journal.post') && (
<Button color="error" variant="outlined" onClick={() => setConfirmOpen(true)}>
Reverse Entry
</Button>
)}
</Stack>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={4}>
<Box>
<Typography variant="caption" color="text.secondary">
Journal
</Typography>
<Typography>{entry.journal?.code}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Date
</Typography>
<Typography>{entry.entryDate.slice(0, 10)}</Typography>
</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="caption" color="text.secondary">
Description
</Typography>
<Typography>{entry.description}</Typography>
</Box>
</Stack>
</Paper>
<Paper>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>GL Account</TableCell>
<TableCell>Description</TableCell>
<TableCell align="right">Debit</TableCell>
<TableCell align="right">Credit</TableCell>
</TableRow>
</TableHead>
<TableBody>
{entry.lines.map((line) => (
<TableRow key={line.id}>
<TableCell>
{line.glAccount.accountCode} — {line.glAccount.accountName}
</TableCell>
<TableCell>{line.description}</TableCell>
<TableCell align="right">
<MoneyCell value={line.debit} />
</TableCell>
<TableCell align="right">
<MoneyCell value={line.credit} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={2} align="right">
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={entry.totalDebits} />
</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={entry.totalCredits} />
</strong>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Paper>
<ConfirmDialog
open={confirmOpen}
title="Reverse this journal entry?"
description="This creates a new balanced entry with every line's debit and credit flipped, and marks this entry as reversed. It cannot be undone."
confirmLabel="Reverse"
onConfirm={handleReverse}
onCancel={() => setConfirmOpen(false)}
loading={reverseEntry.isPending}
/>
</Box>
);
}
@@ -0,0 +1,255 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
IconButton,
MenuItem,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import { useJournals } from '../../hooks/useJournals';
import { useGLAccounts } from '../../hooks/useGLAccounts';
import { useSegmentDefinitions } from '../../hooks/useSegments';
import { useCreateManualEntry } from '../../hooks/useJournalEntries';
interface LineDraft {
key: number;
glAccountId: number | '';
debit: string;
credit: string;
description: string;
branchSegmentValueId: number | '';
}
let keySeed = 0;
const blankLine = (): LineDraft => ({ key: keySeed++, glAccountId: '', debit: '', credit: '', description: '', branchSegmentValueId: '' });
export function JournalEntryFormPage() {
const navigate = useNavigate();
const { data: journals } = useJournals();
const { data: accounts } = useGLAccounts('MAIN');
const { data: definitions } = useSegmentDefinitions();
const branchDef = (definitions ?? []).find((def) => def.isBranchSegment);
const createEntry = useCreateManualEntry();
const [journalCode, setJournalCode] = useState('GJ');
const [entryDate, setEntryDate] = useState(new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState('');
const [lines, setLines] = useState<LineDraft[]>([blankLine(), blankLine()]);
const [error, setError] = useState<string | null>(null);
const totalDebits = lines.reduce((sum, line) => sum + (Number(line.debit) || 0), 0);
const totalCredits = lines.reduce((sum, line) => sum + (Number(line.credit) || 0), 0);
const isBalanced = totalDebits > 0 && totalDebits === totalCredits;
const updateLine = (key: number, patch: Partial<LineDraft>) =>
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)));
const removeLine = (key: number) => setLines((prev) => (prev.length > 2 ? prev.filter((line) => line.key !== key) : prev));
const handleSubmit = async () => {
setError(null);
if (!isBalanced) {
setError('Debits and credits must balance before this entry can be posted');
return;
}
if (lines.some((line) => !line.glAccountId || (Number(line.debit) === 0 && Number(line.credit) === 0))) {
setError('Every line needs a GL account and a debit or credit amount');
return;
}
try {
const entry = await createEntry.mutateAsync({
journalCode,
entryDate,
description,
lines: lines.map((line) => ({
glAccountId: line.glAccountId as number,
debit: Number(line.debit) || 0,
credit: Number(line.credit) || 0,
description: line.description || undefined,
branchSegmentValueId: line.branchSegmentValueId ? (line.branchSegmentValueId as number) : undefined,
})),
});
navigate(`/gl/journal-entries/${entry.id}`);
} catch (err) {
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
setError(message ?? (err instanceof Error ? err.message : 'Failed to post entry'));
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
New Manual Journal Entry
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2}>
<TextField select label="Journal" value={journalCode} onChange={(e) => setJournalCode(e.target.value)} sx={{ minWidth: 200 }}>
{(journals ?? []).map((journal) => (
<MenuItem key={journal.id} value={journal.code}>
{journal.code} — {journal.name}
</MenuItem>
))}
</TextField>
<TextField
label="Entry date"
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField label="Description" value={description} onChange={(e) => setDescription(e.target.value)} fullWidth />
</Stack>
</Paper>
<Paper sx={{ p: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell width="28%">GL Account</TableCell>
<TableCell width="18%">Line description</TableCell>
<TableCell width="14%">Branch</TableCell>
<TableCell width="12%" align="right">
Debit
</TableCell>
<TableCell width="12%" align="right">
Credit
</TableCell>
<TableCell width="6%" />
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell>
<TextField
select
fullWidth
size="small"
value={line.glAccountId}
onChange={(e) => updateLine(line.key, { glAccountId: Number(e.target.value) })}
>
{(accounts ?? []).map((account) => (
<MenuItem key={account.id} value={account.id}>
{account.accountCode} — {account.accountName}
</MenuItem>
))}
</TextField>
</TableCell>
<TableCell>
<TextField
fullWidth
size="small"
value={line.description}
onChange={(e) => updateLine(line.key, { description: e.target.value })}
/>
</TableCell>
<TableCell>
{branchDef && (
<TextField
select
fullWidth
size="small"
value={line.branchSegmentValueId}
onChange={(e) => updateLine(line.key, { branchSegmentValueId: e.target.value ? Number(e.target.value) : '' })}
>
<MenuItem value="">—</MenuItem>
{branchDef.values.map((value) => (
<MenuItem key={value.id} value={value.id}>
{value.code}
</MenuItem>
))}
</TextField>
)}
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={line.debit}
onChange={(e) => updateLine(line.key, { debit: e.target.value, credit: e.target.value ? '' : line.credit })}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={line.credit}
onChange={(e) => updateLine(line.key, { credit: e.target.value, debit: e.target.value ? '' : line.debit })}
slotProps={{ htmlInput: { min: 0, step: '0.01', style: { textAlign: 'right' } } }}
/>
</TableCell>
<TableCell>
<IconButton size="small" onClick={() => removeLine(line.key)} disabled={lines.length <= 2}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button startIcon={<AddIcon />} onClick={() => setLines((prev) => [...prev, blankLine()])} sx={{ mt: 1 }}>
Add line
</Button>
<Stack direction="row" spacing={4} sx={{ mt: 2, pr: 8, justifyContent: 'flex-end' }}>
<Typography variant="body2">
Total debits: <strong>{totalDebits.toFixed(2)}</strong>
</Typography>
<Typography variant="body2">
Total credits: <strong>{totalCredits.toFixed(2)}</strong>
</Typography>
<Typography variant="body2" color={isBalanced ? 'success.main' : 'error.main'}>
{isBalanced ? 'Balanced ✓' : 'Not balanced'}
</Typography>
</Stack>
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Paper sx={{ p: 2, flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Attachments
</Typography>
<Button size="small" disabled>
+ Add Attachment
</Button>
</Paper>
<Paper sx={{ p: 2, flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Notes
</Typography>
<Button size="small" disabled>
+ Add Note
</Button>
</Paper>
</Stack>
<Stack direction="row" spacing={2} sx={{ mt: 2 }}>
<Button variant="contained" disabled={!isBalanced || createEntry.isPending} onClick={handleSubmit}>
Post Entry
</Button>
<Button onClick={() => navigate('/gl/journal-entries')}>Cancel</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Card, Stack, Tab, Tabs, Typography } from '@mui/material';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { useJournalEntries } from '../../hooks/useJournalEntries';
import { useBranchStore } from '../../store/branchStore';
import { StatusChip } from '../../components/common/StatusChip';
import { MoneyCell } from '../../components/common/MoneyCell';
import { useAuth } from '../../hooks/useAuth';
import { GLAccountLedgerView } from './GLAccountLedgerView';
import type { JournalEntry } from '../../types/api';
export function JournalEntryListPage() {
const navigate = useNavigate();
const selected = useBranchStore((state) => state.selected);
const { data: entries, isLoading } = useJournalEntries(selected);
const { hasPermission } = useAuth();
const [tab, setTab] = useState(0);
const columns: GridColDef<JournalEntry>[] = [
{ field: 'entryNumber', headerName: 'Entry #', width: 180 },
{ field: 'entryDate', headerName: 'Date', width: 120, valueFormatter: (value: string) => value?.slice(0, 10) },
{ field: 'description', headerName: 'Description', flex: 1 },
{ field: 'journal', headerName: 'Journal', width: 140, valueGetter: (_v, row) => row.journal?.code },
{
field: 'totalDebits',
headerName: 'Debits',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.totalDebits} />,
},
{
field: 'totalCredits',
headerName: 'Credits',
width: 120,
align: 'right',
headerAlign: 'right',
renderCell: (params) => <MoneyCell value={params.row.totalCredits} />,
},
{ field: 'status', headerName: 'Status', width: 120, renderCell: (params) => <StatusChip status={params.row.status} /> },
];
return (
<Box>
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">General Ledger</Typography>
{hasPermission('gl.journal.post') && (
<Button variant="contained" onClick={() => navigate('/gl/journal-entries/new')}>
New Manual Entry
</Button>
)}
</Stack>
<Tabs value={tab} onChange={(_e, value) => setTab(value)} sx={{ mb: 2 }}>
<Tab label="All Entries" />
<Tab label="By Account" />
</Tabs>
{tab === 0 && (
<Card sx={{ height: 650 }}>
<DataGrid
rows={entries ?? []}
columns={columns}
loading={isLoading}
density="compact"
disableRowSelectionOnClick
onRowClick={(params) => navigate(`/gl/journal-entries/${params.row.id}`)}
sx={{ border: 'none', '& .MuiDataGrid-row': { cursor: 'pointer' } }}
initialState={{ pagination: { paginationModel: { pageSize: 25 } }, sorting: { sortModel: [{ field: 'entryDate', sort: 'desc' }] } }}
/>
</Card>
)}
{tab === 1 && <GLAccountLedgerView />}
</Box>
);
}
@@ -0,0 +1,88 @@
import { Box, Card, Chip, Stack, Typography } from '@mui/material';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
interface ReportDef {
name: string;
description: string;
}
const CATEGORIES: { title: string; reports: ReportDef[] }[] = [
{
title: 'Financial',
reports: [
{ name: 'Trial Balance', description: 'Debit and credit balances for every account, Main or per-branch.' },
{ name: 'Profit & Loss Statement', description: 'Income and expense performance for a selected period.' },
{ name: 'Balance Sheet', description: 'Assets, liabilities, and equity as at a selected date.' },
{ name: 'General Ledger', description: 'Full transaction detail for any account and date range.' },
],
},
{
title: 'Receivables',
reports: [
{ name: 'Aged Receivables', description: 'Outstanding customer balances by age bucket.' },
{ name: 'Customer Statement', description: 'Transaction history and running balance per customer.' },
],
},
{
title: 'Payables',
reports: [
{ name: 'Aged Payables', description: 'Outstanding supplier balances by age bucket.' },
{ name: 'Payment Run', description: 'Supplier invoices selected for the next payment batch.' },
],
},
{
title: 'Management',
reports: [
{ name: 'Budget vs Actual', description: 'Budgeted figures compared against actual GL movement.' },
{ name: 'Branch Performance', description: 'Key metrics compared across branches.' },
{ name: 'Stock Valuation', description: 'On-hand stock quantities and value by warehouse.' },
],
},
];
export function ReportsPage() {
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Reports Centre
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Financial and management reports. Reports become available as each module is built.
</Typography>
<Stack spacing={3}>
{CATEGORIES.map((category) => (
<Box key={category.title}>
<Typography variant="subtitle1" sx={{ mb: 1.5 }}>
{category.title}
</Typography>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap' }}>
{category.reports.map((report) => (
<Card
key={report.name}
sx={{
p: 2,
width: 280,
opacity: 0.6,
cursor: 'not-allowed',
}}
>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'flex-start', mb: 1 }}>
<DescriptionOutlinedIcon fontSize="small" color="action" />
<Typography variant="body2" sx={{ fontWeight: 600, flex: 1 }}>
{report.name}
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
{report.description}
</Typography>
<Chip label="Coming soon" size="small" />
</Card>
))}
</Stack>
</Box>
))}
</Stack>
</Box>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
allocateDebitNote,
createDebitNote,
fetchDebitNote,
fetchDebitNotes,
postDebitNote,
voidDebitNote,
} from '../api/apDebitNotes.api';
import type { BranchSelection } from '../store/branchStore';
export function useDebitNotes(branchScope: BranchSelection, supplierId?: number) {
return useQuery({ queryKey: ['apDebitNotes', branchScope, supplierId], queryFn: () => fetchDebitNotes(branchScope, supplierId) });
}
export function useDebitNote(id: number | undefined) {
return useQuery({
queryKey: ['apDebitNote', id],
queryFn: () => fetchDebitNote(id as number),
enabled: id !== undefined,
});
}
function invalidateDebitNoteQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['apDebitNotes'] });
queryClient.invalidateQueries({ queryKey: ['apDebitNote'] });
queryClient.invalidateQueries({ queryKey: ['apInvoice'] });
queryClient.invalidateQueries({ queryKey: ['apInvoices'] });
queryClient.invalidateQueries({ queryKey: ['supplier'] });
queryClient.invalidateQueries({ queryKey: ['glAccounts'] });
}
export function useCreateDebitNote() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: createDebitNote, onSuccess: () => invalidateDebitNoteQueries(queryClient) });
}
export function usePostDebitNote() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: postDebitNote, onSuccess: () => invalidateDebitNoteQueries(queryClient) });
}
export function useVoidDebitNote() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: voidDebitNote, onSuccess: () => invalidateDebitNoteQueries(queryClient) });
}
export function useAllocateDebitNote() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, allocations }: { id: number; allocations: { invoiceId: number; amount: number }[] }) =>
allocateDebitNote(id, allocations),
onSuccess: () => invalidateDebitNoteQueries(queryClient),
});
}
+37
View File
@@ -0,0 +1,37 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createInvoice, fetchInvoice, fetchInvoices, postInvoice, voidInvoice } from '../api/apInvoices.api';
import type { BranchSelection } from '../store/branchStore';
export function useInvoices(branchScope: BranchSelection, supplierId?: number) {
return useQuery({ queryKey: ['apInvoices', branchScope, supplierId], queryFn: () => fetchInvoices(branchScope, supplierId) });
}
export function useInvoice(id: number | undefined) {
return useQuery({
queryKey: ['apInvoice', id],
queryFn: () => fetchInvoice(id as number),
enabled: id !== undefined,
});
}
function invalidateInvoiceQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['apInvoices'] });
queryClient.invalidateQueries({ queryKey: ['apInvoice'] });
queryClient.invalidateQueries({ queryKey: ['supplier'] });
queryClient.invalidateQueries({ queryKey: ['glAccounts'] });
}
export function useCreateInvoice() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: createInvoice, onSuccess: () => invalidateInvoiceQueries(queryClient) });
}
export function usePostInvoice() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: postInvoice, onSuccess: () => invalidateInvoiceQueries(queryClient) });
}
export function useVoidInvoice() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: voidInvoice, onSuccess: () => invalidateInvoiceQueries(queryClient) });
}
+48
View File
@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { allocatePayment, createPayment, fetchPayment, fetchPayments, postPayment, voidPayment } from '../api/apPayments.api';
import type { BranchSelection } from '../store/branchStore';
export function usePayments(branchScope: BranchSelection, supplierId?: number) {
return useQuery({ queryKey: ['apPayments', branchScope, supplierId], queryFn: () => fetchPayments(branchScope, supplierId) });
}
export function usePayment(id: number | undefined) {
return useQuery({
queryKey: ['apPayment', id],
queryFn: () => fetchPayment(id as number),
enabled: id !== undefined,
});
}
function invalidatePaymentQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['apPayments'] });
queryClient.invalidateQueries({ queryKey: ['apPayment'] });
queryClient.invalidateQueries({ queryKey: ['apInvoice'] });
queryClient.invalidateQueries({ queryKey: ['apInvoices'] });
queryClient.invalidateQueries({ queryKey: ['supplier'] });
queryClient.invalidateQueries({ queryKey: ['glAccounts'] });
}
export function useCreatePayment() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: createPayment, onSuccess: () => invalidatePaymentQueries(queryClient) });
}
export function usePostPayment() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: postPayment, onSuccess: () => invalidatePaymentQueries(queryClient) });
}
export function useVoidPayment() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: voidPayment, onSuccess: () => invalidatePaymentQueries(queryClient) });
}
export function useAllocatePayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, allocations }: { id: number; allocations: { invoiceId: number; amount: number }[] }) =>
allocatePayment(id, allocations),
onSuccess: () => invalidatePaymentQueries(queryClient),
});
}
+55
View File
@@ -0,0 +1,55 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
allocateCreditNote,
createCreditNote,
fetchCreditNote,
fetchCreditNotes,
postCreditNote,
voidCreditNote,
} from '../api/arCreditNotes.api';
import type { BranchSelection } from '../store/branchStore';
export function useCreditNotes(branchScope: BranchSelection, customerId?: number) {
return useQuery({ queryKey: ['arCreditNotes', branchScope, customerId], queryFn: () => fetchCreditNotes(branchScope, customerId) });
}
export function useCreditNote(id: number | undefined) {
return useQuery({
queryKey: ['arCreditNote', id],
queryFn: () => fetchCreditNote(id as number),
enabled: id !== undefined,
});
}
function invalidateCreditNoteQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['arCreditNotes'] });
queryClient.invalidateQueries({ queryKey: ['arCreditNote'] });
queryClient.invalidateQueries({ queryKey: ['arInvoice'] });
queryClient.invalidateQueries({ queryKey: ['arInvoices'] });
queryClient.invalidateQueries({ queryKey: ['customer'] });
queryClient.invalidateQueries({ queryKey: ['glAccounts'] });
}
export function useCreateCreditNote() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: createCreditNote, onSuccess: () => invalidateCreditNoteQueries(queryClient) });
}
export function usePostCreditNote() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: postCreditNote, onSuccess: () => invalidateCreditNoteQueries(queryClient) });
}
export function useVoidCreditNote() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: voidCreditNote, onSuccess: () => invalidateCreditNoteQueries(queryClient) });
}
export function useAllocateCreditNote() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, allocations }: { id: number; allocations: { invoiceId: number; amount: number }[] }) =>
allocateCreditNote(id, allocations),
onSuccess: () => invalidateCreditNoteQueries(queryClient),
});
}
+37
View File
@@ -0,0 +1,37 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createInvoice, fetchInvoice, fetchInvoices, postInvoice, voidInvoice } from '../api/arInvoices.api';
import type { BranchSelection } from '../store/branchStore';
export function useInvoices(branchScope: BranchSelection, customerId?: number) {
return useQuery({ queryKey: ['arInvoices', branchScope, customerId], queryFn: () => fetchInvoices(branchScope, customerId) });
}
export function useInvoice(id: number | undefined) {
return useQuery({
queryKey: ['arInvoice', id],
queryFn: () => fetchInvoice(id as number),
enabled: id !== undefined,
});
}
function invalidateInvoiceQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['arInvoices'] });
queryClient.invalidateQueries({ queryKey: ['arInvoice'] });
queryClient.invalidateQueries({ queryKey: ['customer'] });
queryClient.invalidateQueries({ queryKey: ['glAccounts'] });
}
export function useCreateInvoice() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: createInvoice, onSuccess: () => invalidateInvoiceQueries(queryClient) });
}
export function usePostInvoice() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: postInvoice, onSuccess: () => invalidateInvoiceQueries(queryClient) });
}
export function useVoidInvoice() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: voidInvoice, onSuccess: () => invalidateInvoiceQueries(queryClient) });
}
+48
View File
@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { allocateReceipt, createReceipt, fetchReceipt, fetchReceipts, postReceipt, voidReceipt } from '../api/arReceipts.api';
import type { BranchSelection } from '../store/branchStore';
export function useReceipts(branchScope: BranchSelection, customerId?: number) {
return useQuery({ queryKey: ['arReceipts', branchScope, customerId], queryFn: () => fetchReceipts(branchScope, customerId) });
}
export function useReceipt(id: number | undefined) {
return useQuery({
queryKey: ['arReceipt', id],
queryFn: () => fetchReceipt(id as number),
enabled: id !== undefined,
});
}
function invalidateReceiptQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ['arReceipts'] });
queryClient.invalidateQueries({ queryKey: ['arReceipt'] });
queryClient.invalidateQueries({ queryKey: ['arInvoice'] });
queryClient.invalidateQueries({ queryKey: ['arInvoices'] });
queryClient.invalidateQueries({ queryKey: ['customer'] });
queryClient.invalidateQueries({ queryKey: ['glAccounts'] });
}
export function useCreateReceipt() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: createReceipt, onSuccess: () => invalidateReceiptQueries(queryClient) });
}
export function usePostReceipt() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: postReceipt, onSuccess: () => invalidateReceiptQueries(queryClient) });
}
export function useVoidReceipt() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: voidReceipt, onSuccess: () => invalidateReceiptQueries(queryClient) });
}
export function useAllocateReceipt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, allocations }: { id: number; allocations: { invoiceId: number; amount: number }[] }) =>
allocateReceipt(id, allocations),
onSuccess: () => invalidateReceiptQueries(queryClient),
});
}
+22
View File
@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import { handoff as handoffRequest } from '../api/auth.api';
import { useAuthStore } from '../store/authStore';
export function useAuth() {
const { user, accessToken, setSession, clearSession, hasPermission } = useAuthStore();
const handoffMutation = useMutation({
mutationFn: (token: string) => handoffRequest(token),
onSuccess: (session) => setSession(session),
});
return {
user,
isAuthenticated: Boolean(accessToken && user),
handoff: handoffMutation.mutateAsync,
isHandingOff: handoffMutation.isPending,
handoffError: handoffMutation.error,
logout: clearSession,
hasPermission,
};
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { fetchBranchValues } from '../api/segments.api';
import { useAuthStore } from '../store/authStore';
import { useBranchStore } from '../store/branchStore';
export function useBranchScope() {
const { selected, setSelected } = useBranchStore();
const allowed = useAuthStore((state) => state.user?.allowedBranchSegmentValueIds ?? null);
const branchesQuery = useQuery({ queryKey: ['branches'], queryFn: fetchBranchValues });
const allBranches = branchesQuery.data ?? [];
const visibleBranches = allowed === null ? allBranches : allBranches.filter((branch) => allowed.includes(branch.id));
const canViewMain = allowed === null;
return {
selected,
setSelected,
branches: visibleBranches,
canViewMain,
isLoading: branchesQuery.isLoading,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createCustomer, fetchCustomer, fetchCustomers, updateCustomer, type CustomerInput } from '../api/customers.api';
import type { BranchSelection } from '../store/branchStore';
export function useCustomers(branchScope: BranchSelection) {
return useQuery({ queryKey: ['customers', branchScope], queryFn: () => fetchCustomers(branchScope) });
}
export function useCustomer(id: number | undefined) {
return useQuery({
queryKey: ['customer', id],
queryFn: () => fetchCustomer(id as number),
enabled: id !== undefined,
});
}
export function useCreateCustomer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createCustomer,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['customers'] }),
});
}
export function useUpdateCustomer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: Partial<CustomerInput> & { isActive?: boolean } }) => updateCustomer(id, input),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['customer', variables.id] });
},
});
}
+14
View File
@@ -0,0 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createFiscalYear, fetchFiscalYears } from '../api/fiscalPeriods.api';
export function useFiscalYears() {
return useQuery({ queryKey: ['fiscalYears'], queryFn: fetchFiscalYears });
}
export function useCreateFiscalYear() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createFiscalYear,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['fiscalYears'] }),
});
}
+19
View File
@@ -0,0 +1,19 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createGLAccount, fetchGLAccounts } from '../api/glAccounts.api';
import type { BranchSelection } from '../store/branchStore';
import type { GLAccountType } from '../types/api';
export function useGLAccounts(branchScope: BranchSelection, accountType?: GLAccountType) {
return useQuery({
queryKey: ['glAccounts', branchScope, accountType],
queryFn: () => fetchGLAccounts(branchScope, accountType),
});
}
export function useCreateGLAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createGLAccount,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['glAccounts'] }),
});
}
+37
View File
@@ -0,0 +1,37 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createManualEntry, fetchJournalEntries, fetchJournalEntry, reverseJournalEntry } from '../api/journalEntries.api';
import type { BranchSelection } from '../store/branchStore';
export function useJournalEntries(branchScope: BranchSelection) {
return useQuery({ queryKey: ['journalEntries', branchScope], queryFn: () => fetchJournalEntries(branchScope) });
}
export function useJournalEntry(id: number | undefined) {
return useQuery({
queryKey: ['journalEntry', id],
queryFn: () => fetchJournalEntry(id as number),
enabled: id !== undefined,
});
}
export function useCreateManualEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createManualEntry,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['journalEntries'] });
queryClient.invalidateQueries({ queryKey: ['glAccounts'] });
},
});
}
export function useReverseJournalEntry() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: number; reason?: string }) => reverseJournalEntry(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['journalEntries'] });
queryClient.invalidateQueries({ queryKey: ['journalEntry'] });
},
});
}
+14
View File
@@ -0,0 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createJournal, fetchJournals } from '../api/journals.api';
export function useJournals() {
return useQuery({ queryKey: ['journals'], queryFn: fetchJournals });
}
export function useCreateJournal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createJournal,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['journals'] }),
});
}
+19
View File
@@ -0,0 +1,19 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchPermissions, fetchRoles, setRolePermissions } from '../api/roles.api';
export function useRoles() {
return useQuery({ queryKey: ['roles'], queryFn: fetchRoles });
}
export function usePermissions() {
return useQuery({ queryKey: ['permissions'], queryFn: fetchPermissions });
}
export function useSetRolePermissions() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ roleId, permissionIds }: { roleId: number; permissionIds: number[] }) =>
setRolePermissions(roleId, permissionIds),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roles'] }),
});
}
+29
View File
@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createSegmentDefinition,
createSegmentValue,
fetchSegmentDefinitions,
} from '../api/segments.api';
export function useSegmentDefinitions() {
return useQuery({ queryKey: ['segmentDefinitions'], queryFn: fetchSegmentDefinitions });
}
export function useCreateSegmentDefinition() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createSegmentDefinition,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segmentDefinitions'] }),
});
}
export function useCreateSegmentValue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createSegmentValue,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['segmentDefinitions'] });
queryClient.invalidateQueries({ queryKey: ['branches'] });
},
});
}

Some files were not shown because too many files have changed in this diff Show More