feat(hq-web): document view record header + composer line cards

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 08bb25abfc
commit 94019980bb

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Notice, PageHeader, Toolbar } from '@sims/ui'
import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, RecordHeader, Skeleton, Toolbar } from '@sims/ui'
import {
createShare, documentAction, downloadDocumentPdf, fetchPdfBlob, getClient, getDocumentFull,
revokeShare, type Client, type Doc, type Share,
@ -56,8 +56,8 @@ export function DocumentView() {
}
}, [id, doc?.docNo, doc?.status])
if (full.error !== undefined) return <Notice tone="err">{full.error}</Notice>
if (doc === undefined || full.data === undefined) return <EmptyState>Loading</EmptyState>
if (full.error !== undefined) return <div className="wf-page"><ErrorState message={full.error} onRetry={full.reload} /></div>
if (doc === undefined || full.data === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
/** POST an action; convert/credit-note navigate to the new document. */
const act = (action: string, body?: Record<string, unknown>, navigateToResult = false) => {
@ -87,11 +87,23 @@ export function DocumentView() {
return (
<div className="wf-page">
<PageHeader
title={`${TITLE[doc.docType]} ${doc.docNo ?? '(draft)'}`}
badge={doc.fy}
desc={`${client.data?.name ?? '…'} · ${doc.docDate} · Payable ${formatINR(doc.payablePaise)}`}
actions={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
<div className="wf-crumbs">
<Link to="/clients">Clients</Link><span>/</span>
{client.data !== undefined ? <Link to={`/clients/${doc.clientId}`}>{client.data.name}</Link> : <span></span>}
<span>/</span><span className="mono">{doc.docNo ?? 'draft'}</span>
</div>
<RecordHeader
name={TITLE[doc.docType]}
avatarSeed={client.data?.name ?? doc.clientId}
status={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
meta={
<>
<span className="mono">{doc.docNo ?? '(draft)'}</span>
<span>· {doc.fy}</span>
<span>· {doc.docDate}</span>
<span>· Payable <strong className="num">{formatINR(doc.payablePaise)}</strong></span>
</>
}
/>
<Toolbar>
@ -160,36 +172,42 @@ export function DocumentView() {
/>
)}
<h3 style={{ marginTop: 20 }}>Timeline</h3>
{full.data.events.length === 0 ? <EmptyState>No events.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'kind', label: 'Event' }, { key: 'meta', label: 'Detail' },
]}
rows={full.data.events.map((ev) => ({
at: new Date(ev.atWall).toLocaleString(),
kind: <Badge tone="accent">{ev.kind}</Badge>,
meta: Object.keys(ev.meta).length > 0 ? JSON.stringify(ev.meta) : '—',
}))}
/>
)}
<div className="dash-grid">
<section>
<h3 style={{ marginTop: 20 }}>Timeline</h3>
{full.data.events.length === 0 ? <EmptyState>No events.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'kind', label: 'Event' }, { key: 'meta', label: 'Detail' },
]}
rows={full.data.events.map((ev) => ({
at: new Date(ev.atWall).toLocaleString(),
kind: <Badge tone="accent">{ev.kind}</Badge>,
meta: Object.keys(ev.meta).length > 0 ? JSON.stringify(ev.meta) : '—',
}))}
/>
)}
</section>
<h3 style={{ marginTop: 20 }}>Email log</h3>
{full.data.emails.length === 0 ? <EmptyState>Not emailed yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'to', label: 'To' },
{ key: 'subject', label: 'Subject' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' },
]}
rows={full.data.emails.map((e) => ({
at: new Date(e.at_wall).toLocaleString(),
to: e.to_addr, subject: e.subject,
status: <Badge tone={e.status === 'sent' ? 'ok' : 'err'}>{e.status}</Badge>,
error: e.error ?? '—',
}))}
/>
)}
<section>
<h3 style={{ marginTop: 20 }}>Email log</h3>
{full.data.emails.length === 0 ? <EmptyState>Not emailed yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'at', label: 'When' }, { key: 'to', label: 'To' },
{ key: 'subject', label: 'Subject' }, { key: 'status', label: 'Status' },
{ key: 'error', label: 'Error' },
]}
rows={full.data.emails.map((e) => ({
at: new Date(e.at_wall).toLocaleString(),
to: e.to_addr, subject: e.subject,
status: <Badge tone={e.status === 'sent' ? 'ok' : 'err'}>{e.status}</Badge>,
error: e.error ?? '—',
}))}
/>
)}
</section>
</div>
</div>
)
}

@ -169,7 +169,7 @@ export function NewDocument() {
<div style={{ position: 'relative', maxWidth: 340 }}>
<input className="wf" placeholder="Type to search clients…" value={q} autoFocus onChange={(e) => setQ(e.target.value)} />
{hits.length > 0 && (
<div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 8 }}>
<div style={{ position: 'absolute', zIndex: 5, top: '100%', left: 0, right: 0, background: 'var(--bg-raised)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)' }}>
{hits.slice(0, 8).map((c) => (
<button key={c.id} type="button" className="wf" style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none' }}
onClick={() => { setClient(c); setHits([]); commit() }}>
@ -200,7 +200,7 @@ export function NewDocument() {
const editions = editionsOf(prices)
const priceGap = warnings.some((w) => mod !== undefined && w.includes(mod.code))
return (
<div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<div key={i} className="wf-card" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Module">
<select className="wf" value={l.moduleId}
onChange={(e) => {

Loading…
Cancel
Save