From 18e47eb448ec976d3745ef205842e2c28dc1ea57 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sat, 18 Jul 2026 21:13:00 +0530 Subject: [PATCH] =?UTF-8?q?feat(d26):=20functional-feature=20UIs=20?= =?UTF-8?q?=E2=80=94=20GST=20report,=20doc=20search,=20statement,=20bulk?= =?UTF-8?q?=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/hq-web/src/api.ts | 40 +++++++- apps/hq-web/src/app.css | 51 ++++++++++ apps/hq-web/src/pages/ClientDetail.tsx | 116 +++++++++++++++++++++- apps/hq-web/src/pages/Clients.tsx | 132 ++++++++++++++++++++++++- apps/hq-web/src/pages/Documents.tsx | 14 ++- apps/hq-web/src/pages/Projects.tsx | 60 ++++++++++- apps/hq-web/src/pages/Reports.tsx | 69 ++++++++++++- apps/hq-web/src/statement.ts | 78 +++++++++++++++ apps/hq-web/test/statement.test.ts | 63 ++++++++++++ 9 files changed, 610 insertions(+), 13 deletions(-) create mode 100644 apps/hq-web/src/statement.ts create mode 100644 apps/hq-web/test/statement.test.ts diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 4d5f923..5a8918e 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -254,6 +254,15 @@ export const patchClient = (id: string, body: ClientWrite): Promise => /** Assign/clear the account owner — owner/manager only; audited server-side. `null` clears. */ export const setClientOwner = (id: string, ownerId: string | null): Promise => apiFetch<{ client: Client }>(`/clients/${id}/owner`, { method: 'PATCH', body: JSON.stringify({ ownerId }) }).then((r) => r.client) +/** + * Owner-only (server re-checks): apply the same patch to many clients in one audited + * transaction (D26 cleanup at scale). Returns how many rows changed. `patch` reuses the + * ClientWrite shape (district / sector / stateCode / status are the useful bulk fields). + */ +export const bulkUpdateClients = (ids: string[], patch: ClientWrite): Promise<{ updated: number }> => + apiFetch<{ ok: boolean; updated: number }>('/clients/bulk', { + method: 'POST', body: JSON.stringify({ ids, patch }), + }).then((r) => ({ updated: r.updated })) export const getModules = (): Promise => apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules) @@ -331,6 +340,18 @@ export interface PendingProject { } export const getPendingProjects = (key: string): Promise => apiFetch<{ projects: PendingProject[] }>(`/projects/pending/${encodeURIComponent(key)}`).then((r) => r.projects) +/** + * Owner-only (server re-checks): tick/untick one milestone across many projects in a + * single audited transaction (D26). Only projects that actually have the milestone are + * touched. `done=true` stamps `doneOn` (given date or today). Returns how many changed. + */ +export const bulkSetMilestone = ( + clientModuleIds: string[], key: string, done: boolean, doneOn?: string, +): Promise<{ updated: number }> => + apiFetch<{ ok: boolean; updated: number }>('/projects/bulk-milestone', { + method: 'POST', + body: JSON.stringify({ clientModuleIds, key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), + }).then((r) => ({ updated: r.updated })) // ---------- ticket desk + client branches (D20 APEX parity) ---------- @@ -400,14 +421,16 @@ export const getBillingSettings = (): Promise<{ paymentTermsDays: number }> => /** One Documents-list row: the document plus the server-joined client name (no N+1). */ export type DocListRow = Doc & { clientName: string } export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number } -/** Server-paginated document list with an honest total (rule 8); filters compose. */ +/** Server-paginated document list with an honest total (rule 8); filters compose. + * `q` free-text-matches the document number or client name (D26 register search). */ export const getDocuments = ( - opts: { type?: DocType; status?: DocStatus; clientId?: string; page?: number; pageSize?: number } = {}, + opts: { type?: DocType; status?: DocStatus; clientId?: string; q?: string; page?: number; pageSize?: number } = {}, ): Promise => { const p = new URLSearchParams() if (opts.type !== undefined) p.set('type', opts.type) if (opts.status !== undefined) p.set('status', opts.status) if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId) + if (opts.q !== undefined && opts.q !== '') p.set('q', opts.q) if (opts.page !== undefined) p.set('page', String(opts.page)) if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) const q = p.toString() @@ -665,6 +688,14 @@ export interface ProfitabilityRow { billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number } export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number } +/** One GST-filing summary row (D26). `period` is 'YYYY-MM' per month, or 'TOTAL' on the + * footer row. Invoices add, credit notes subtract — all amounts integer paise. */ +export interface GstSummaryRow { + period: string + invoiceCount: number; creditNoteCount: number + taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number + taxPaise: number; totalPaise: number +} export interface AwsUsageRow { id: string; clientId: string; month: string storageGb: number; transferGb: number; costPaise: number; source: 'auto' | 'manual'; updatedAt: string @@ -686,6 +717,11 @@ export const getModuleRevenue = (from?: string, to?: string): Promise(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows) export const getProfitability = (from?: string, to?: string): Promise => apiFetch<{ rows: ProfitabilityRow[] }>(`/reports/profitability${rangeQs(from, to)}`).then((r) => r.rows) +/** GST filing summary per month over [from, to] (inclusive doc-date bounds), plus a + * TOTAL row. `from`/`to` are full 'YYYY-MM-DD' dates (the server compares doc_date). */ +export const getGstSummary = (from?: string, to?: string): Promise<{ rows: GstSummaryRow[]; total: GstSummaryRow }> => + apiFetch<{ ok: boolean; rows: GstSummaryRow[]; total: GstSummaryRow }>(`/reports/gst-summary${rangeQs(from, to)}`) + .then((r) => ({ rows: r.rows, total: r.total })) export const getAwsRanking = (month?: string): Promise<{ month: string; total: number; rows: CostRankRow[] }> => apiFetch<{ month: string; total: number; rows: CostRankRow[] }>(`/aws/ranking${month !== undefined && month !== '' ? `?month=${month}` : ''}`) export const getClientAwsUsage = (clientId: string): Promise => diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index 42df51a..f44dd45 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -320,3 +320,54 @@ table.wf tbody tr[role='button']:focus-visible td:first-child { box-shadow: inse .wf-page .composer-line { flex-direction: column; align-items: stretch; } .wf-page .composer-line > * { width: 100%; } } + +/* ============================================================================ + Client account statement (D26) — a printable overlay. On screen it reads like + paper (white card) in either theme; @media print isolates .statement-print so + only the statement, not the app chrome, reaches the page. + ============================================================================ */ +.statement-overlay { + position: fixed; inset: 0; z-index: 60; overflow: auto; + background: var(--bg-inset); padding: 24px 16px 48px; +} +.statement-toolbar { display: flex; gap: 8px; justify-content: flex-end; max-width: 820px; margin: 0 auto 16px; } +.statement-print { + max-width: 820px; margin: 0 auto; background: #fff; color: #14130f; + border: 1px solid var(--border); border-radius: 10px; padding: 34px 40px; + box-shadow: var(--shadow-lg); +} +.statement-head { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; } +.statement-co { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; } +.statement-co-meta { font-size: 12px; color: #55524a; margin-top: 2px; } +.statement-title { font-size: 15px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; } +.statement-client { + display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 18px 0 6px; + padding: 12px 0; border-top: 1px solid #e5e2da; border-bottom: 1px solid #e5e2da; font-size: 13.5px; +} +.statement-lbl { + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: #7a766c; margin-right: 6px; +} +.statement-table { width: 100%; border-collapse: collapse; margin: 16px 0 8px; font-size: 13px; } +.statement-table th, .statement-table td { padding: 7px 10px; border-bottom: 1px solid #ece9e1; text-align: left; } +.statement-table th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #55524a; } +.statement-table .num { text-align: right; font-variant-numeric: tabular-nums; } +.statement-table tfoot td { border-top: 2px solid #ddd9d0; border-bottom: none; } +.statement-table .mono { font-family: var(--mono); } +.statement-summary { + display: flex; justify-content: flex-end; gap: 32px; margin-top: 12px; font-size: 14px; +} +.statement-summary .statement-lbl { display: block; margin-bottom: 2px; } +.statement-note { margin-top: 22px; font-size: 11px; color: #7a766c; } + +@media print { + /* Isolate the statement: hide the whole app (keeps layout, drops ink), then re-show + only the statement and pin it to the top-left of the page. */ + body * { visibility: hidden; } + .statement-print, .statement-print * { visibility: visible; } + .statement-overlay { position: static; padding: 0; background: #fff; overflow: visible; } + .statement-print { + position: absolute; left: 0; top: 0; width: 100%; max-width: none; + margin: 0; padding: 0 4px; border: none; border-radius: 0; box-shadow: none; + } + .no-print { display: none !important; } +} diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index d5e0449..08e5fa9 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -13,13 +13,14 @@ import { getRecurringPlans, deactivateRecurringPlan, getAmc, deactivateAmc, generateAmcRenewalInvoice, getInteractions, getInteractionTypes, updateInteraction, - getClientAwsUsage, + getClientAwsUsage, getCompanyProfile, getBranches, createBranch, patchBranch, getTickets, getMilestones, setMilestone, addMilestone, CLIENT_STATUSES, KIND_LABEL, type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule, - type ClientStatus, type FieldDef, type Interaction, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail, + type ClientStatus, type FieldDef, type Interaction, type Ledger, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail, } from '../api' +import { buildStatement } from '../statement' import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients' import { NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE, @@ -71,6 +72,7 @@ export function ClientDetail() { const [planDialog, setPlanDialog] = useState<{ initial?: RecurringPlan } | undefined>() const [interactionDialog, setInteractionDialog] = useState<{ initial?: Interaction } | undefined>() const [confirm, setConfirm] = useState() + const [statementOpen, setStatementOpen] = useState(false) const rawTab = sp.get('tab') ?? 'overview' const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview' @@ -167,6 +169,7 @@ export function ClientDetail() { + } @@ -583,6 +586,115 @@ export function ClientDetail() { {...(confirm.tone !== undefined ? { tone: confirm.tone } : {})} /> )} + {statementOpen && ( + setStatementOpen(false)} /> + )} + + ) +} + +/** + * Printable account statement (D26). A full-screen overlay that reads like paper on + * screen in either theme; a `@media print` block (app.css) isolates `.statement-print` + * so only the statement — not the app chrome — hits the page. The ledger of invoices, + * credit notes and payments is ordered by date with a running balance; the closing + * balance and the unallocated advance both surface. Company header from the profile. + */ +function ClientStatement(props: { client: Client; ledger?: Ledger; onClose: () => void }) { + const company = useData(getCompanyProfile, []) + const co = company.data + const coName = co?.['company.name'] ?? 'SiMS HQ' + const coAddr = co?.['company.address'] ?? '' + const coGstin = co?.['company.gstin'] ?? '' + const led = props.ledger + const statement = led !== undefined ? buildStatement(led.documents, led.payments) : undefined + const printedOn = new Date().toISOString().slice(0, 10) + const balanceLabel = (paise: number): string => + paise > 0 ? `${formatINR(paise)} Dr` : paise < 0 ? `${formatINR(-paise)} Cr` : formatINR(0) + + return ( +
+
+ + +
+
+
+
+
{coName}
+ {coAddr !== '' && ( +
{coAddr}
+ )} + {coGstin !== '' && ( +
GSTIN {coGstin}
+ )} +
+
+
Statement of Account
+
As on {printedOn}
+
+
+ +
+
Client {props.client.name}
+
Code {props.client.code}
+ {props.client.gstin !== undefined && props.client.gstin !== '' && ( +
GSTIN {props.client.gstin}
+ )} +
+ + {statement === undefined ? + : statement.rows.length === 0 ? No invoices, credit notes or payments on record. + : ( + + + + + + + + + {statement.rows.map((r, i) => ( + + + + + + + + + ))} + + + + + + + + + +
DateParticularsReferenceDebitCreditBalance
{r.date}{r.particulars}{r.ref !== '' ? r.ref : '—'}{r.debitPaise > 0 ? formatINR(r.debitPaise) : ''}{r.creditPaise > 0 ? formatINR(r.creditPaise) : ''}{balanceLabel(r.balancePaise)}
Totals{formatINR(statement.totalDebitPaise)}{formatINR(statement.totalCreditPaise)}{balanceLabel(statement.closingPaise)}
+ )} + + {statement !== undefined && ( +
+
+ Closing balance + {balanceLabel(statement.closingPaise)} +
+ {led !== undefined && ( +
+ Advance on account + {formatINR(led.advancePaise)} +
+ )} +
+ )} +
+ Dr = amount owed to us · Cr = client in credit. Reflects issued invoices, credit + notes and recorded payments. This is a computer-generated statement. +
+
) } diff --git a/apps/hq-web/src/pages/Clients.tsx b/apps/hq-web/src/pages/Clients.tsx index 3c69a5d..e57e04f 100644 --- a/apps/hq-web/src/pages/Clients.tsx +++ b/apps/hq-web/src/pages/Clients.tsx @@ -1,7 +1,13 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' -import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar, type BadgeTone } from '@sims/ui' -import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus, type DocType } from '../api' +import { + Badge, Button, DataTable, Dialog, EmptyState, ErrorState, FilterChips, FormField, FormGrid, + Notice, PageHeader, Skeleton, Toolbar, useToast, type BadgeTone, +} from '@sims/ui' +import { + getClients, bulkUpdateClients, role, CLIENT_STATUSES, + type ClientStatus, type ClientWrite, type DocStatus, type DocType, +} from '../api' import { ClientFormDialog } from '../components/ClientFormDialog' /** @@ -94,14 +100,36 @@ export function Clients() { () => getClients(qDebounced, { district, sector }), [qDebounced, district, sector], ) const [sp, setSp] = useSearchParams() + const toast = useToast() + const isOwner = role() === 'owner' const [creating, setCreating] = useState(sp.get('new') === '1') const [statusFilter, setStatusFilter] = useState('all') + // Bulk edit (D26, owner-only): opt-in selection mode → set one field across the picked set. + const [selecting, setSelecting] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [bulkOpen, setBulkOpen] = useState(false) + const [bulkBusy, setBulkBusy] = useState(false) useEffect(() => { if (sp.get('new') === '1') { setCreating(true); setSp({}, { replace: true }) } }, [sp, setSp]) const counts = new Map() for (const c of data ?? []) counts.set(c.status, (counts.get(c.status) ?? 0) + 1) const shown = (data ?? []).filter((c) => statusFilter === 'all' || c.status === statusFilter) + + const toggleOne = (cid: string) => setSelected((prev) => { + const n = new Set(prev) + if (n.has(cid)) n.delete(cid); else n.add(cid) + return n + }) + const exitSelect = () => { setSelecting(false); setSelected(new Set()) } + const applyBulk = (patch: ClientWrite) => { + if (bulkBusy || selected.size === 0) return + setBulkBusy(true) + bulkUpdateClients([...selected], patch) + .then((r) => { toast.ok(`Updated ${r.updated} client(s)`); setBulkOpen(false); exitSelect(); reload() }) + .catch((e: Error) => toast.err(e.message)) + .finally(() => setBulkBusy(false)) + } const why = [ q !== '' ? ` matching “${q}”` : '', statusFilter !== 'all' ? ` with status ${statusFilter}` : '', @@ -137,6 +165,16 @@ export function Clients() { ]} /> + {isOwner && (selecting ? ( + <> + + + + + + ) : ( + + ))} {shown.length} shown No clients{why !== '' ? why : ' yet — add the first one'}. : ( nav(`/clients/${shown[i]!.id}`)} rows={shown.map((c) => ({ + ...(selecting ? { + sel: ( + e.stopPropagation()} onChange={() => toggleOne(c.id)} + /> + ), + } : {}), code: c.code, name: c.name, status: {c.status}, state: c.stateCode, @@ -162,6 +209,87 @@ export function Clients() { }))} /> )} + { if (!bulkBusy) setBulkOpen(false) }} + onApply={applyBulk} + /> ) } + +/** + * Owner-only bulk edit (D26): pick ONE field and a value to stamp across the selected + * clients. The dialog itself is the confirmation step (it names the field, value and + * count); text fields refuse an empty value so a stray Apply can't blank a column. + */ +function BulkEditDialog(props: { + open: boolean; count: number; busy: boolean + onClose: () => void; onApply: (patch: ClientWrite) => void +}) { + const [field, setField] = useState<'status' | 'district' | 'sector' | 'state'>('status') + const [status, setStatus] = useState('active') + const [text, setText] = useState('') + + useEffect(() => { + if (props.open) { setField('status'); setStatus('active'); setText('') } + }, [props.open]) + + const trimmed = text.trim() + const textEmpty = field !== 'status' && trimmed === '' + const apply = () => { + if (props.busy || textEmpty) return + if (field === 'status') props.onApply({ status }) + else if (field === 'district') props.onApply({ district: trimmed }) + else if (field === 'sector') props.onApply({ sector: trimmed }) + else props.onApply({ stateCode: trimmed }) + } + const label = field === 'state' ? 'state code' : field + + return ( + + + + + } + > + + + + + + {field === 'status' + ? ( + + ) + : ( + setText(e.target.value)} + placeholder={field === 'state' ? 'e.g. 27' : ''} + /> + )} + + + + Sets {label} to “{field === 'status' ? status : trimmed}” for all {props.count} selected + client{props.count === 1 ? '' : 's'}. This cannot be undone in bulk. + + + ) +} diff --git a/apps/hq-web/src/pages/Documents.tsx b/apps/hq-web/src/pages/Documents.tsx index d486b11..64019b0 100644 --- a/apps/hq-web/src/pages/Documents.tsx +++ b/apps/hq-web/src/pages/Documents.tsx @@ -7,7 +7,7 @@ import { } from '@sims/ui' import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api' import { Pager } from '../components/Pager' -import { useData, DOC_TONE, DOC_TYPE_TONE } from './Clients' +import { useData, useDebounced, DOC_TONE, DOC_TYPE_TONE } from './Clients' const PAGE_SIZE = 50 @@ -34,7 +34,10 @@ export function Documents() { const toast = useToast() const [type, setType] = useState('all') const [status, setStatus] = useState('') + const [q, setQ] = useState('') const [page, setPage] = useState(1) + // Debounced so typing in the search box fires one server request ~300ms after typing stops. + const qDebounced = useDebounced(q) // D18 WS-G: one-click proforma → invoice & send, straight from the register. const [converting, setConverting] = useState<{ id: string; docNo: string } | undefined>() @@ -42,9 +45,10 @@ export function Documents() { () => getDocuments({ ...(type !== 'all' ? { type: type as DocType } : {}), ...(status !== '' ? { status: status as DocStatus } : {}), + ...(qDebounced !== '' ? { q: qDebounced } : {}), page, pageSize: PAGE_SIZE, }), - [type, status, page], + [type, status, qDebounced, page], ) const data = list.data @@ -52,6 +56,7 @@ export function Documents() { const why = [ type !== 'all' ? ` of type ${TYPE_LABEL[type as DocType].toLowerCase()}` : '', status !== '' ? ` with status ${status}` : '', + qDebounced !== '' ? ` matching “${qDebounced}”` : '', ].filter((x) => x !== '').join('') return ( @@ -66,6 +71,11 @@ export function Documents() { active={type} onChange={(k) => { setType(k); setPage(1) }} /> + { setQ(e.target.value); setPage(1) }} + /> togglePick(p.clientModuleId)} + /> + ), + } : {}), client: {p.clientName}, module: <>{p.moduleCode} · {p.moduleName}, }))} /> )} + {confirmOpen && selected !== undefined && ( + setConfirmOpen(false)} + onConfirm={markDone} + title="Mark milestone done" + body={`Mark “${selected.label}” done for ${picked.size} selected project${picked.size === 1 ? '' : 's'}? This stamps today's date and clears them from this list.`} + confirmLabel="Mark done" + /> + )} )} diff --git a/apps/hq-web/src/pages/Reports.tsx b/apps/hq-web/src/pages/Reports.tsx index 98b8142..564a510 100644 --- a/apps/hq-web/src/pages/Reports.tsx +++ b/apps/hq-web/src/pages/Reports.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { formatINR } from '@sims/domain' import { DataTable, EmptyState, ErrorState, Field, FilterChips, Skeleton, PageHeader } from '@sims/ui' -import { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking } from '../api' +import { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking, getGstSummary } from '../api' import { useData } from './Clients' const inr = (p: number) => formatINR(p, { symbol: false }) @@ -11,14 +11,35 @@ const prevMonth = (): string => { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1)).toISOString().slice(0, 7) } +/** Current Indian financial year (Apr–Mar) as { from, to } month strings ('YYYY-MM'). */ +const currentFy = (): { from: string; to: string } => { + const d = new Date() + const y = d.getUTCFullYear() + const startYear = d.getUTCMonth() >= 3 ? y : y - 1 // month index 3 = April + return { from: `${startYear}-04`, to: `${startYear + 1}-03` } +} +/** 'YYYY-MM' → first day 'YYYY-MM-01'. */ +const monthStart = (m: string): string => `${m}-01` +/** 'YYYY-MM' → last calendar day 'YYYY-MM-DD' (so a `doc_date <= to` bound spans the month). */ +const monthEnd = (m: string): string => { + const [y, mo] = m.split('-').map(Number) + const last = new Date(Date.UTC(y!, mo!, 0)).getUTCDate() + return `${m}-${String(last).padStart(2, '0')}` +} + /** Reports — dues aging, module revenue, client profitability, and the AWS cost chart (spec §5–§6). */ export function Reports() { const nav = useNavigate() const [month, setMonth] = useState(prevMonth()) - const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'aws'>('dues') + const fy = currentFy() + // GST-summary range: defaults to the current financial year, both ends editable. + const [gstFrom, setGstFrom] = useState(fy.from) + const [gstTo, setGstTo] = useState(fy.to) + const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'gst' | 'aws'>('dues') const dues = useData(getDuesAging, []) const revenue = useData(() => getModuleRevenue(), []) const profit = useData(() => getProfitability(), []) + const gst = useData(() => getGstSummary(monthStart(gstFrom), monthEnd(gstTo)), [gstFrom, gstTo]) const ranking = useData(() => getAwsRanking(month), [month]) return ( @@ -32,6 +53,7 @@ export function Reports() { { key: 'dues', label: 'Dues aging' }, { key: 'revenue', label: 'Module revenue' }, { key: 'profit', label: 'Profitability' }, + { key: 'gst', label: 'GST summary' }, { key: 'aws', label: 'AWS costs' }, ]} /> @@ -91,6 +113,49 @@ export function Reports() { ) )} + {report === 'gst' && ( + <> +
+ setGstFrom(e.target.value)} /> + setGstTo(e.target.value)} /> +
+ {gst.error !== undefined ? + : gst.data === undefined ? + : gst.data.rows.length === 0 ? No invoices or credit notes in this range. + : ( + { + const isTotal = i === gst.data!.rows.length + const cell = (v: string) => (isTotal ? {v} : v) + return { + period: cell(r.period === 'TOTAL' ? 'Total' : r.period), + inv: cell(String(r.invoiceCount)), + cn: cell(String(r.creditNoteCount)), + taxable: cell(inr(r.taxablePaise)), + cgst: cell(inr(r.cgstPaise)), + sgst: cell(inr(r.sgstPaise)), + igst: cell(inr(r.igstPaise)), + tax: cell(inr(r.taxPaise)), + total: cell(inr(r.totalPaise)), + } + })} + /> + )} + + )} + {report === 'aws' && ( <> setMonth(e.target.value)} /> diff --git a/apps/hq-web/src/statement.ts b/apps/hq-web/src/statement.ts new file mode 100644 index 0000000..117c9b3 --- /dev/null +++ b/apps/hq-web/src/statement.ts @@ -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 } + 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 } +} diff --git a/apps/hq-web/test/statement.test.ts b/apps/hq-web/test/statement.test.ts new file mode 100644 index 0000000..f58f81a --- /dev/null +++ b/apps/hq-web/test/statement.test.ts @@ -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 => ({ + 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 => ({ + 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 + }) +})