|
|
import { useState } from 'react'
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
import { formatINR } from '@sims/domain'
|
|
|
import {
|
|
|
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, FilterChips, PageHeader,
|
|
|
Skeleton, Toolbar, useToast,
|
|
|
} from '@sims/ui'
|
|
|
import { documentAction, getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api'
|
|
|
import { useData, DOC_TONE } from './Clients'
|
|
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
|
|
const TYPE_LABEL: Record<DocType, string> = {
|
|
|
QUOTATION: 'Quotation', PROFORMA: 'Proforma', INVOICE: 'Invoice',
|
|
|
CREDIT_NOTE: 'Credit note', RECEIPT: 'Receipt',
|
|
|
}
|
|
|
const TYPE_CHIPS: { key: string; label: string }[] = [
|
|
|
{ key: 'all', label: 'All' },
|
|
|
{ key: 'QUOTATION', label: 'Quotations' },
|
|
|
{ key: 'PROFORMA', label: 'Proformas' },
|
|
|
{ key: 'INVOICE', label: 'Invoices' },
|
|
|
{ key: 'CREDIT_NOTE', label: 'Credit notes' },
|
|
|
{ key: 'RECEIPT', label: 'Receipts' },
|
|
|
]
|
|
|
|
|
|
/**
|
|
|
* Documents register (go-live cluster WS-D): every document across all clients,
|
|
|
* newest first, server-paginated with an honest total (rule 8). Type chips and
|
|
|
* the status filter compose server-side; a row opens the document view.
|
|
|
*/
|
|
|
export function Documents() {
|
|
|
const nav = useNavigate()
|
|
|
const toast = useToast()
|
|
|
const [type, setType] = useState('all')
|
|
|
const [status, setStatus] = useState('')
|
|
|
const [page, setPage] = useState(1)
|
|
|
// D18 WS-G: one-click proforma → invoice & send, straight from the register.
|
|
|
const [converting, setConverting] = useState<{ id: string; docNo: string } | undefined>()
|
|
|
|
|
|
const list = useData(
|
|
|
() => getDocuments({
|
|
|
...(type !== 'all' ? { type: type as DocType } : {}),
|
|
|
...(status !== '' ? { status: status as DocStatus } : {}),
|
|
|
page, pageSize: PAGE_SIZE,
|
|
|
}),
|
|
|
[type, status, page],
|
|
|
)
|
|
|
|
|
|
const data = list.data
|
|
|
const docs = data?.documents
|
|
|
const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
|
|
|
const from = data !== undefined && data.total > 0 ? (data.page - 1) * data.pageSize + 1 : 0
|
|
|
const to = data !== undefined ? Math.min(data.page * data.pageSize, data.total) : 0
|
|
|
const why = [
|
|
|
type !== 'all' ? ` of type ${TYPE_LABEL[type as DocType].toLowerCase()}` : '',
|
|
|
status !== '' ? ` with status ${status}` : '',
|
|
|
].filter((x) => x !== '').join('')
|
|
|
|
|
|
return (
|
|
|
<div className="wf-page">
|
|
|
<PageHeader
|
|
|
title="Documents"
|
|
|
desc="Every quotation, proforma, invoice, credit note and receipt — newest first. Click a row to open it."
|
|
|
/>
|
|
|
<Toolbar>
|
|
|
<FilterChips
|
|
|
chips={TYPE_CHIPS}
|
|
|
active={type}
|
|
|
onChange={(k) => { setType(k); setPage(1) }}
|
|
|
/>
|
|
|
<select
|
|
|
className="wf" style={{ maxWidth: 180 }} value={status} aria-label="Status"
|
|
|
onChange={(e) => { setStatus(e.target.value); setPage(1) }}
|
|
|
>
|
|
|
<option value="">Status: any</option>
|
|
|
{DOC_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
|
</select>
|
|
|
<span style={{ flex: 1 }} />
|
|
|
{data !== undefined && <Badge>{data.total} total</Badge>}
|
|
|
</Toolbar>
|
|
|
{list.error !== undefined && <ErrorState message={list.error} onRetry={list.reload} />}
|
|
|
{list.error !== undefined ? null
|
|
|
: docs === undefined ? <Skeleton rows={6} />
|
|
|
: docs.length === 0 ? <EmptyState>No documents{why !== '' ? why : ' yet'}.</EmptyState> : (
|
|
|
<>
|
|
|
<DataTable
|
|
|
columns={[
|
|
|
{ key: 'no', label: 'No.', mono: true },
|
|
|
{ key: 'type', label: 'Type' },
|
|
|
{ key: 'client', label: 'Client' },
|
|
|
{ key: 'date', label: 'Date' },
|
|
|
{ key: 'status', label: 'Status' },
|
|
|
{ key: 'amount', label: 'Amount', numeric: true },
|
|
|
{ key: 'actions', label: '' },
|
|
|
]}
|
|
|
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
|
|
|
rows={docs.map((d) => ({
|
|
|
no: d.docNo ?? '— draft —',
|
|
|
type: TYPE_LABEL[d.docType],
|
|
|
client: d.clientName,
|
|
|
date: d.docDate,
|
|
|
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
|
|
|
amount: formatINR(d.payablePaise),
|
|
|
actions: d.docType === 'PROFORMA' && d.docNo !== null
|
|
|
&& d.status !== 'cancelled' && d.status !== 'invoiced'
|
|
|
? (
|
|
|
// The whole <tr> navigates on click — without this guard the button
|
|
|
// click bubbles up, the page unmounts and the confirm never shows.
|
|
|
<span onClick={(e) => e.stopPropagation()}>
|
|
|
<Button
|
|
|
tone="primary"
|
|
|
onClick={() => setConverting({ id: d.id, docNo: d.docNo! })}
|
|
|
>
|
|
|
Convert & send
|
|
|
</Button>
|
|
|
</span>
|
|
|
)
|
|
|
: null,
|
|
|
}))}
|
|
|
/>
|
|
|
{converting !== undefined && (
|
|
|
<ConfirmDialog
|
|
|
open
|
|
|
onClose={() => setConverting(undefined)}
|
|
|
onConfirm={() => documentAction(converting.id, 'convert-and-send')
|
|
|
.then((doc) => { toast.ok('Invoice issued & emailed'); nav(`/documents/${doc.id}`) })
|
|
|
.catch((e: Error) => { toast.err(e.message); setConverting(undefined) })}
|
|
|
title="Convert to invoice & send"
|
|
|
body={`Convert ${converting.docNo} to a tax invoice, assign its number and email it to the client?`}
|
|
|
confirmLabel="Convert & send"
|
|
|
/>
|
|
|
)}
|
|
|
<Toolbar>
|
|
|
<Badge>Showing {from}–{to} of {data!.total}</Badge>
|
|
|
<span style={{ flex: 1 }} />
|
|
|
<button
|
|
|
type="button" className="wf" disabled={data!.page <= 1}
|
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
|
>
|
|
|
‹ Prev
|
|
|
</button>
|
|
|
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data!.page} / {totalPages}</span>
|
|
|
<button
|
|
|
type="button" className="wf" disabled={data!.page >= totalPages}
|
|
|
onClick={() => setPage((p) => p + 1)}
|
|
|
>
|
|
|
Next ›
|
|
|
</button>
|
|
|
</Toolbar>
|
|
|
</>
|
|
|
)}
|
|
|
</div>
|
|
|
)
|
|
|
}
|