feat(d26): functional-feature UIs — GST report, doc search, statement, bulk ops
- Reports: GST summary tab (FY month range, per-month CGST/SGST/IGST + total, emphasized TOTAL row). - Documents: debounced free-text search feeding q. - Client 360: printable account statement (Dr/Cr ledger + running/closing balance + advance, @media print isolation); buildStatement + 4 tests. - Clients: owner-only bulk-edit selection -> set a field across many (POST /clients/bulk). Onboarding board: owner-only bulk 'mark done' on the pending drill-down (POST /projects/bulk-milestone). Both confirm + toast + refresh. - api.ts: getGstSummary, bulkUpdateClients, bulkSetMilestone, documents q. typecheck + web build clean, web tests 8/8, full suite 397 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
3247585e52
commit
18e47eb448
@ -0,0 +1,78 @@
|
|||||||
|
import type { Doc, Payment } from './api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of a client account statement: an invoice (debit), a credit note (credit)
|
||||||
|
* or a payment (credit), with the running balance AFTER the line is applied. Kept as a
|
||||||
|
* pure data shape so the running-balance maths can be unit-tested without the DOM.
|
||||||
|
*/
|
||||||
|
export interface StatementRow {
|
||||||
|
date: string
|
||||||
|
kind: 'invoice' | 'credit_note' | 'payment'
|
||||||
|
particulars: string
|
||||||
|
ref: string
|
||||||
|
/** Charge that raises what the client owes (0 when this row is a credit). */
|
||||||
|
debitPaise: number
|
||||||
|
/** Credit that lowers what the client owes (0 when this row is a debit). */
|
||||||
|
creditPaise: number
|
||||||
|
/** Cumulative balance after this row — positive = owed by client, negative = in credit. */
|
||||||
|
balancePaise: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Statement {
|
||||||
|
rows: StatementRow[]
|
||||||
|
totalDebitPaise: number
|
||||||
|
totalCreditPaise: number
|
||||||
|
/** Final running balance: > 0 the client owes us, < 0 they are in credit (advance). */
|
||||||
|
closingPaise: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a statement of account from the client's ledger. Only posted receivables move
|
||||||
|
* the balance: issued (numbered), non-cancelled INVOICEs are debits and CREDIT_NOTEs are
|
||||||
|
* credits; every payment is a credit (amount + TDS, matching how the ledger settles).
|
||||||
|
* Quotations, proformas, receipts and drafts are not account postings and are excluded.
|
||||||
|
* Rows are ordered by date, debits before credits on the same day (conventional).
|
||||||
|
*/
|
||||||
|
export function buildStatement(documents: Doc[], payments: Payment[]): Statement {
|
||||||
|
type Entry = { date: string; order: number; row: Omit<StatementRow, 'balancePaise'> }
|
||||||
|
const entries: Entry[] = []
|
||||||
|
|
||||||
|
for (const d of documents) {
|
||||||
|
if (d.docNo === null || d.status === 'cancelled') continue
|
||||||
|
if (d.docType === 'INVOICE') {
|
||||||
|
entries.push({
|
||||||
|
date: d.docDate, order: 0,
|
||||||
|
row: { date: d.docDate, kind: 'invoice', particulars: 'Invoice', ref: d.docNo, debitPaise: d.payablePaise, creditPaise: 0 },
|
||||||
|
})
|
||||||
|
} else if (d.docType === 'CREDIT_NOTE') {
|
||||||
|
entries.push({
|
||||||
|
date: d.docDate, order: 0,
|
||||||
|
row: { date: d.docDate, kind: 'credit_note', particulars: 'Credit note', ref: d.docNo, debitPaise: 0, creditPaise: d.payablePaise },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of payments) {
|
||||||
|
entries.push({
|
||||||
|
date: p.receivedOn, order: 1,
|
||||||
|
row: {
|
||||||
|
date: p.receivedOn, kind: 'payment',
|
||||||
|
particulars: `Payment (${p.mode})`, ref: p.reference,
|
||||||
|
debitPaise: 0, creditPaise: p.amountPaise + p.tdsPaise,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.order - b.order))
|
||||||
|
|
||||||
|
let balance = 0
|
||||||
|
let totalDebitPaise = 0
|
||||||
|
let totalCreditPaise = 0
|
||||||
|
const rows: StatementRow[] = entries.map((e) => {
|
||||||
|
balance += e.row.debitPaise - e.row.creditPaise
|
||||||
|
totalDebitPaise += e.row.debitPaise
|
||||||
|
totalCreditPaise += e.row.creditPaise
|
||||||
|
return { ...e.row, balancePaise: balance }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { rows, totalDebitPaise, totalCreditPaise, closingPaise: balance }
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildStatement } from '../src/statement'
|
||||||
|
import type { Doc, Payment } from '../src/api'
|
||||||
|
|
||||||
|
/** Minimal Doc factory — only the fields buildStatement reads matter. */
|
||||||
|
const doc = (over: Partial<Doc>): Doc => ({
|
||||||
|
id: 'd', docType: 'INVOICE', docNo: 'INV/1', fy: '2026-27', clientId: 'c',
|
||||||
|
docDate: '2026-04-01', status: 'sent', refDocId: null,
|
||||||
|
taxablePaise: 0, cgstPaise: 0, sgstPaise: 0, igstPaise: 0, roundOffPaise: 0, payablePaise: 0,
|
||||||
|
payload: { lines: [] }, source: 'manual', createdBy: 'u', createdAt: '', ...over,
|
||||||
|
})
|
||||||
|
const pay = (over: Partial<Payment>): Payment => ({
|
||||||
|
id: 'p', clientId: 'c', receivedOn: '2026-04-10', mode: 'bank', reference: '',
|
||||||
|
amountPaise: 0, tdsPaise: 0, createdBy: 'u', createdAt: '', ...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildStatement', () => {
|
||||||
|
it('runs a balance across invoice → payment → credit note', () => {
|
||||||
|
const s = buildStatement(
|
||||||
|
[
|
||||||
|
doc({ id: 'a', docType: 'INVOICE', docNo: 'INV/1', docDate: '2026-04-01', payablePaise: 100_00 }),
|
||||||
|
doc({ id: 'b', docType: 'CREDIT_NOTE', docNo: 'CN/1', docDate: '2026-04-20', payablePaise: 30_00 }),
|
||||||
|
],
|
||||||
|
[pay({ id: 'x', receivedOn: '2026-04-10', amountPaise: 40_00 })],
|
||||||
|
)
|
||||||
|
expect(s.rows.map((r) => r.balancePaise)).toEqual([100_00, 60_00, 30_00])
|
||||||
|
expect(s.closingPaise).toBe(30_00) // client still owes ₹30
|
||||||
|
expect(s.totalDebitPaise).toBe(100_00)
|
||||||
|
expect(s.totalCreditPaise).toBe(70_00)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes quotations, proformas, receipts, drafts and cancelled docs', () => {
|
||||||
|
const s = buildStatement(
|
||||||
|
[
|
||||||
|
doc({ id: 'q', docType: 'QUOTATION', docNo: 'QT/1', payablePaise: 500_00 }),
|
||||||
|
doc({ id: 'pf', docType: 'PROFORMA', docNo: 'PI/1', payablePaise: 500_00 }),
|
||||||
|
doc({ id: 'rc', docType: 'RECEIPT', docNo: 'RC/1', payablePaise: 500_00 }),
|
||||||
|
doc({ id: 'dr', docType: 'INVOICE', docNo: null, payablePaise: 500_00 }), // unissued draft
|
||||||
|
doc({ id: 'cx', docType: 'INVOICE', docNo: 'INV/9', status: 'cancelled', payablePaise: 500_00 }),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
expect(s.rows).toHaveLength(0)
|
||||||
|
expect(s.closingPaise).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('orders by date, debits before credits on the same day', () => {
|
||||||
|
const s = buildStatement(
|
||||||
|
[doc({ id: 'a', docType: 'INVOICE', docNo: 'INV/1', docDate: '2026-05-05', payablePaise: 10_00 })],
|
||||||
|
[pay({ id: 'x', receivedOn: '2026-05-05', amountPaise: 4_00 })],
|
||||||
|
)
|
||||||
|
expect(s.rows.map((r) => r.kind)).toEqual(['invoice', 'payment'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts TDS as part of the payment credit and can close in credit (advance)', () => {
|
||||||
|
const s = buildStatement(
|
||||||
|
[doc({ id: 'a', docType: 'INVOICE', docNo: 'INV/1', docDate: '2026-06-01', payablePaise: 100_00 })],
|
||||||
|
[pay({ id: 'x', receivedOn: '2026-06-02', amountPaise: 90_00, tdsPaise: 20_00 })],
|
||||||
|
)
|
||||||
|
expect(s.rows[1]!.creditPaise).toBe(110_00) // 90 + 20 TDS
|
||||||
|
expect(s.closingPaise).toBe(-10_00) // overpaid → client in credit
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue