|
|
|
|
@ -1,4 +1,4 @@
|
|
|
|
|
import { Fragment, useState } from 'react'
|
|
|
|
|
import { Fragment, useState, type CSSProperties, type ReactNode } from 'react'
|
|
|
|
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|
|
|
|
import { formatINR } from '@sims/domain'
|
|
|
|
|
import {
|
|
|
|
|
@ -20,7 +20,7 @@ import {
|
|
|
|
|
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
|
|
|
|
|
type ClientStatus, type FieldDef, type Interaction, type Milestone, type Outcome, type RecurringPlan, type ServiceDetail,
|
|
|
|
|
} from '../api'
|
|
|
|
|
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
|
|
|
|
|
import { CLIENT_TONE, DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
|
|
|
|
|
import {
|
|
|
|
|
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
|
|
|
|
|
} from './Tickets'
|
|
|
|
|
@ -215,6 +215,7 @@ export function ClientDetail() {
|
|
|
|
|
<StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
|
|
|
|
|
<StatCard label="Last interaction" value={lastInteraction ?? '—'} />
|
|
|
|
|
</Stats>
|
|
|
|
|
<ClientDetailsCard client={c} />
|
|
|
|
|
<PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} />
|
|
|
|
|
<h3 style={{ marginTop: 18 }}>Recent documents</h3>
|
|
|
|
|
{docs.length === 0 ? <EmptyState>No documents yet.</EmptyState> : (
|
|
|
|
|
@ -226,7 +227,9 @@ export function ClientDetail() {
|
|
|
|
|
]}
|
|
|
|
|
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
|
|
|
|
|
rows={docs.slice(0, 5).map((d) => ({
|
|
|
|
|
no: d.docNo ?? 'draft', type: d.docType, date: d.docDate,
|
|
|
|
|
no: d.docNo ?? 'draft',
|
|
|
|
|
type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
|
|
|
|
|
date: d.docDate,
|
|
|
|
|
payable: inr(d.payablePaise),
|
|
|
|
|
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
|
|
|
|
|
}))}
|
|
|
|
|
@ -363,7 +366,8 @@ export function ClientDetail() {
|
|
|
|
|
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
|
|
|
|
|
rows={docs.map((d) => ({
|
|
|
|
|
no: d.docNo ?? <Badge tone="warn">draft</Badge>,
|
|
|
|
|
type: d.docType, date: d.docDate,
|
|
|
|
|
type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{DOC_TYPE_LABEL[d.docType]}</Badge>,
|
|
|
|
|
date: d.docDate,
|
|
|
|
|
payable: inr(d.payablePaise),
|
|
|
|
|
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
|
|
|
|
|
}))}
|
|
|
|
|
@ -583,6 +587,71 @@ export function ClientDetail() {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Contact-role → display label (spec §5 typed contacts). Office is the default for
|
|
|
|
|
* a blank/'office'/'primary' role; a custom role shows verbatim. */
|
|
|
|
|
function contactRoleLabel(role: string | undefined): string {
|
|
|
|
|
if (role === 'secretary') return 'Secretary'
|
|
|
|
|
if (role === 'whatsapp') return 'WhatsApp'
|
|
|
|
|
if (role === undefined || role === '' || role === 'office' || role === 'primary') return 'Office'
|
|
|
|
|
return role
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Client core-details card (Overview tab): identity, full address and a sectioned,
|
|
|
|
|
* role-labelled contact list — each with mailto:/tel: links — so the whole record
|
|
|
|
|
* reads at a glance instead of only contacts[0] inline in the header. Reuses the
|
|
|
|
|
* wf-card surface and the file's micro-label styling; no dedicated CSS.
|
|
|
|
|
*/
|
|
|
|
|
function ClientDetailsCard(props: { client: Client }) {
|
|
|
|
|
const c = props.client
|
|
|
|
|
const micro: CSSProperties = { fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }
|
|
|
|
|
const field = (label: string, value: ReactNode, mono = false) => (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 130 }}>
|
|
|
|
|
<span style={micro}>{label}</span>
|
|
|
|
|
<span className={mono ? 'mono' : undefined}>{value}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="wf-card">
|
|
|
|
|
<h3 style={{ margin: '0 0 12px' }}>Details</h3>
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '14px 28px' }}>
|
|
|
|
|
{field('Name', c.name)}
|
|
|
|
|
{field('Code', c.code, true)}
|
|
|
|
|
{c.gstin !== undefined && c.gstin !== '' && field('GSTIN', c.gstin, true)}
|
|
|
|
|
{field('Status', <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>)}
|
|
|
|
|
{field('State', c.stateCode)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{c.address !== '' && (
|
|
|
|
|
<div style={{ marginTop: 14 }}>
|
|
|
|
|
<span style={micro}>Address</span>
|
|
|
|
|
<div style={{ marginTop: 3, whiteSpace: 'pre-wrap' }}>{c.address}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{c.contacts.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', margin: '14px 0 12px' }} />
|
|
|
|
|
<span style={micro}>Contacts</span>
|
|
|
|
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
|
|
|
{c.contacts.map((ct, i) => (
|
|
|
|
|
<div key={i} style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: '4px 14px' }}>
|
|
|
|
|
<span style={{ ...micro, minWidth: 84 }}>{contactRoleLabel(ct.role)}</span>
|
|
|
|
|
{ct.name !== undefined && ct.name !== '' && <span style={{ fontWeight: 500 }}>{ct.name}</span>}
|
|
|
|
|
{ct.email !== undefined && ct.email !== '' && <a href={`mailto:${ct.email}`}>{ct.email}</a>}
|
|
|
|
|
{ct.phone !== undefined && ct.phone !== '' && <a href={`tel:${ct.phone}`} className="mono">{ct.phone}</a>}
|
|
|
|
|
{(ct.name === undefined || ct.name === '') && (ct.email === undefined || ct.email === '')
|
|
|
|
|
&& (ct.phone === undefined || ct.phone === '') && <span style={{ opacity: 0.6 }}>—</span>}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Support-access card (D18 WS-F): the fields the old APEX book carried on every
|
|
|
|
|
* client row — AnyDesk id (44× referenced there), OS, district/sector — plus the
|
|
|
|
|
|