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:
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
coverage/
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
.idea/
|
||||
keys/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL="http://localhost:5000/api"
|
||||
VITE_MANAGER_URL="http://localhost:5174"
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+2964
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user