You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/Documents.tsx

155 lines
6.2 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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>
)
}