feat(d25): bright doc type/status colours + sectioned client details + username UI

- Badge extended with green/blue/violet/gold/red tones (light+dark). Document
  TYPE badges: Quotation=gold, Proforma=violet, Invoice=teal, Credit note=red,
  Receipt=green (Documents, DocumentView, Client 360). STATUS brightened:
  paid/accepted=green, sent=blue, part-paid=amber, draft=grey, lost/cancelled=red.
- Client 360: new sectioned Details card (identity, full address, contacts
  labeled Office/Secretary/WhatsApp with mailto:/tel: links).
- Employees create form gains a required Username (login id) field + table
  column; Login field relabeled 'Username'.
typecheck + web build clean, web tests 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 921fd7e6d1
commit 9b920e50f9

@ -40,7 +40,7 @@ export function Login() {
{/* A real <form> so Enter submits from either field (not just password). */}
<form className="login-card" onSubmit={(e) => { e.preventDefault(); void submit() }}>
<h2 style={{ marginTop: 0 }}>Sign in</h2>
<Field label="Email or username">
<Field label="Username">
<input
className="wf" type="text" autoComplete="username" value={email} autoFocus
onChange={(e) => setEmail(e.target.value)}

@ -450,7 +450,7 @@ export type EmployeeRole = 'owner' | 'manager' | 'staff'
export const EMPLOYEE_ROLES: EmployeeRole[] = ['owner', 'manager', 'staff']
export interface Employee {
id: string; email: string; displayName: string; role: EmployeeRole; active: boolean
id: string; email: string; username: string; displayName: string; role: EmployeeRole; active: boolean
phone: string | null; title: string | null
}
@ -458,7 +458,7 @@ export interface Employee {
export const getEmployees = (): Promise<{ employees: Employee[]; total: number }> =>
apiFetch<{ employees: Employee[]; total: number }>('/employees')
export const createEmployee = (
body: { email: string; displayName: string; role: EmployeeRole; password: string },
body: { username: string; email: string; displayName: string; role: EmployeeRole; password: string },
): Promise<Employee> =>
apiFetch<{ employee: Employee }>('/employees', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.employee)
/** Email is the login id — immutable; name, role, phone and title may change. */

@ -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

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api'
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 { ClientFormDialog } from '../components/ClientFormDialog'
/**
@ -58,9 +58,27 @@ export const CLIENT_TONE: Record<ClientStatus, Tone> = {
lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err',
}
export const DOC_TONE: Record<DocStatus, Tone> = {
draft: 'warn', sent: 'accent', accepted: 'ok', invoiced: 'accent',
part_paid: 'warn', paid: 'ok', lost: 'err', cancelled: 'err',
/**
* Document STATUS badge tone. Brightened for at-a-glance state: paid/accepted green,
* part_paid amber (warn), sent blue, invoiced teal (accent), draft muted grey (undefined),
* lost bright red, cancelled muted red (err). `undefined` renders the default muted pill.
*/
export const DOC_TONE: Record<DocStatus, BadgeTone | undefined> = {
draft: undefined, sent: 'blue', accepted: 'green', invoiced: 'accent',
part_paid: 'warn', paid: 'green', lost: 'red', cancelled: 'err',
}
/**
* Document TYPE badge tone. Bright and mutually distinct so the type reads instantly:
* quotation gold, proforma violet, invoice teal (brand accent), credit note red, receipt green.
*/
export const DOC_TYPE_TONE: Record<DocType, BadgeTone> = {
QUOTATION: 'gold', PROFORMA: 'violet', INVOICE: 'accent', CREDIT_NOTE: 'red', RECEIPT: 'green',
}
/** Friendly one-word-ish label for a document type (shared by the register, 360° and doc view). */
export const DOC_TYPE_LABEL: Record<DocType, string> = {
QUOTATION: 'Quotation', PROFORMA: 'Proforma', INVOICE: 'Invoice', CREDIT_NOTE: 'Credit note', RECEIPT: 'Receipt',
}
/** Client registry — search, list, inline create; row click opens the Client 360°. */

@ -10,7 +10,7 @@ import {
revokeShare, type Client, type Doc, type Share,
} from '../api'
import { SendDialog } from '../components/SendDialog'
import { DOC_TONE, useData } from './Clients'
import { DOC_TONE, DOC_TYPE_TONE, DOC_TYPE_LABEL, useData } from './Clients'
import { PaymentForm } from './client-forms'
/** One pending confirmation — replaces the browser confirm() popup with the styled dialog. */
@ -99,7 +99,12 @@ export function DocumentView() {
<RecordHeader
name={TITLE[doc.docType]}
avatarSeed={client.data?.name ?? doc.clientId}
status={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
status={
<>
<Badge tone={DOC_TYPE_TONE[doc.docType]}>{DOC_TYPE_LABEL[doc.docType]}</Badge>
<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>
</>
}
meta={
<>
<span className="mono">{doc.docNo ?? '(draft)'}</span>

@ -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 } from './Clients'
import { useData, DOC_TONE, DOC_TYPE_TONE } from './Clients'
const PAGE_SIZE = 50
@ -94,7 +94,7 @@ export function Documents() {
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.map((d) => ({
no: d.docNo ?? '— draft —',
type: TYPE_LABEL[d.docType],
type: <Badge tone={DOC_TYPE_TONE[d.docType]}>{TYPE_LABEL[d.docType]}</Badge>,
client: d.clientName,
date: d.docDate,
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,

@ -17,9 +17,9 @@ type DialogState =
/**
* Employee management (owner-only page; the nav item is hidden for others and the
* server 403s every mutation anyway). Standard table per spec §6a: name, email,
* role, active, actions plus add / edit / reset-password dialogs. Email is the
* login id and is immutable once created.
* server 403s every mutation anyway). Standard table per spec §6a: name, username,
* email, role, active, actions plus add / edit / reset-password dialogs. Username
* is the login id; email is a separate contact field. Both are set at create time.
*/
export function Employees() {
const list = useData(getEmployees, [])
@ -54,13 +54,14 @@ export function Employees() {
<>
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'email', label: 'Email', mono: true },
{ key: 'name', label: 'Name' }, { key: 'username', label: 'Username', mono: true },
{ key: 'email', label: 'Email', mono: true },
{ key: 'phone', label: 'Phone', mono: true }, { key: 'title', label: 'Title' },
{ key: 'role', label: 'Role' }, { key: 'active', label: 'Active' },
{ key: 'actions', label: 'Actions' },
]}
rows={employees.map((e) => ({
name: e.displayName, email: e.email,
name: e.displayName, username: e.username, email: e.email,
phone: e.phone ?? '—', title: e.title ?? '—',
role: <Badge tone={e.role === 'owner' ? 'accent' : undefined}>{e.role}</Badge>,
active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>,
@ -112,7 +113,7 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
const kind = props.state?.kind
const employee = props.state?.kind === 'edit' || props.state?.kind === 'reset' ? props.state.employee : undefined
const [addF, setAddF] = useState({ email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
const [addF, setAddF] = useState({ username: '', email: '', displayName: '', role: 'staff' as EmployeeRole, password: '' })
const [editF, setEditF] = useState({ displayName: '', role: 'staff' as EmployeeRole, phone: '', title: '' })
const [password, setPassword] = useState('')
const [error, setError] = useState<string | undefined>()
@ -121,7 +122,7 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
useEffect(() => {
if (!open) return
setError(undefined)
setAddF({ email: '', displayName: '', role: 'staff', password: '' })
setAddF({ username: '', email: '', displayName: '', role: 'staff', password: '' })
setEditF({
displayName: employee?.displayName ?? '', role: employee?.role ?? 'staff',
phone: employee?.phone ?? '', title: employee?.title ?? '',
@ -134,11 +135,11 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
const save = () => {
if (saving) return
if (kind === 'add') {
if (addF.email.trim() === '' || addF.displayName.trim() === '') { setError('Email and name are required'); return }
if (addF.username.trim() === '' || addF.email.trim() === '' || addF.displayName.trim() === '') { setError('Username, email and name are required'); return }
if (addF.password.length < 8) { setError('Initial password must be at least 8 characters'); return }
setError(undefined)
setSaving(true)
createEmployee({ email: addF.email.trim(), displayName: addF.displayName.trim(), role: addF.role, password: addF.password })
createEmployee({ username: addF.username.trim(), email: addF.email.trim(), displayName: addF.displayName.trim(), role: addF.role, password: addF.password })
.then(() => { toast.ok('Employee created'); props.onDone(); props.onClose() })
.catch((e: Error) => setError(e.message))
.finally(() => setSaving(false))
@ -184,8 +185,11 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
>
{kind === 'add' && (
<FormGrid>
<FormField label="Email (login id)">
<input className="wf" type="email" autoFocus value={addF.email} onChange={(e) => setAddF((p) => ({ ...p, email: e.target.value }))} />
<FormField label="Username (login id)">
<input className="wf" autoFocus autoComplete="off" value={addF.username} onChange={(e) => setAddF((p) => ({ ...p, username: e.target.value }))} />
</FormField>
<FormField label="Email">
<input className="wf" type="email" value={addF.email} onChange={(e) => setAddF((p) => ({ ...p, email: e.target.value }))} />
</FormField>
<FormField label="Name">
<input className="wf" value={addF.displayName} onChange={(e) => setAddF((p) => ({ ...p, displayName: e.target.value }))} />

@ -58,7 +58,17 @@ export function Button(props: {
)
}
export function Badge(props: { children: ReactNode; tone?: 'ok' | 'warn' | 'err' | 'accent' }) {
/**
* Badge tones. `ok`/`warn`/`err`/`accent` are the state tones (accent follows the
* chosen accent colour); `green`/`blue`/`violet`/`gold`/`red` are fixed, bright,
* self-standing hues so a document TYPE or STATUS keeps its identity regardless of
* the configured accent. Each maps to a `.wf-badge-*` `.wf-badge.<tone>` class.
*/
export type BadgeTone =
| 'ok' | 'warn' | 'err' | 'accent'
| 'green' | 'blue' | 'violet' | 'gold' | 'red'
export function Badge(props: { children: ReactNode; tone?: BadgeTone }) {
return <span className={`wf-badge${props.tone !== undefined ? ` ${props.tone}` : ''}`}>{props.children}</span>
}

@ -201,6 +201,23 @@ table.wf tbody tr.tone-ok:hover td { filter: brightness(0.97); }
.wf-badge.accent { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
[data-theme='dark'] .wf-badge.accent { color: var(--accent); }
/* Bright, self-standing badge hues used to colour document TYPE and STATUS so each
reads at a glance (quotation gold, proforma violet, invoice teal/accent, credit note
red, receipt green; paid/accepted green, sent blue, lost red). Solid-tint pills:
coloured ink on a soft wash of the same hue, hue-matched border. Fixed values (not
the configurable --accent) so a type/status keeps its identity in any accent, and
tuned for contrast in both light and dark themes. */
.wf-badge.green { color: #047857; background: #e2f4eb; border-color: color-mix(in srgb, #047857 28%, transparent); }
.wf-badge.blue { color: #1d63d0; background: #e4eefc; border-color: color-mix(in srgb, #1d63d0 28%, transparent); }
.wf-badge.violet { color: #6f42c1; background: #efe8fb; border-color: color-mix(in srgb, #6f42c1 28%, transparent); }
.wf-badge.gold { color: #8a5a00; background: #fbeecb; border-color: color-mix(in srgb, #8a5a00 28%, transparent); }
.wf-badge.red { color: #c62828; background: #fce4e4; border-color: color-mix(in srgb, #c62828 28%, transparent); }
[data-theme='dark'] .wf-badge.green { color: #43c288; background: color-mix(in srgb, #43c288 16%, var(--bg-raised)); border-color: color-mix(in srgb, #43c288 34%, transparent); }
[data-theme='dark'] .wf-badge.blue { color: #66a3f2; background: color-mix(in srgb, #66a3f2 16%, var(--bg-raised)); border-color: color-mix(in srgb, #66a3f2 34%, transparent); }
[data-theme='dark'] .wf-badge.violet { color: #a97ff0; background: color-mix(in srgb, #a97ff0 16%, var(--bg-raised)); border-color: color-mix(in srgb, #a97ff0 34%, transparent); }
[data-theme='dark'] .wf-badge.gold { color: #d9a247; background: color-mix(in srgb, #d9a247 16%, var(--bg-raised)); border-color: color-mix(in srgb, #d9a247 34%, transparent); }
[data-theme='dark'] .wf-badge.red { color: #ef6a63; background: color-mix(in srgb, #ef6a63 16%, var(--bg-raised)); border-color: color-mix(in srgb, #ef6a63 34%, transparent); }
/* ================= stat cards ================= */
.wf-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin: 16px 0; }
.wf-stat {

Loading…
Cancel
Save