From 9b920e50f94029dc0dfb22eae4f4cdcf5476f9d9 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sat, 18 Jul 2026 02:20:04 +0530 Subject: [PATCH] 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 --- apps/hq-web/src/Login.tsx | 2 +- apps/hq-web/src/api.ts | 4 +- apps/hq-web/src/pages/ClientDetail.tsx | 77 ++++++++++++++++++++++++-- apps/hq-web/src/pages/Clients.tsx | 28 ++++++++-- apps/hq-web/src/pages/DocumentView.tsx | 9 ++- apps/hq-web/src/pages/Documents.tsx | 4 +- apps/hq-web/src/pages/Employees.tsx | 26 +++++---- packages/ui/src/components.tsx | 12 +++- packages/ui/src/tokens.css | 17 ++++++ 9 files changed, 151 insertions(+), 28 deletions(-) diff --git a/apps/hq-web/src/Login.tsx b/apps/hq-web/src/Login.tsx index 8574faa..65557e6 100644 --- a/apps/hq-web/src/Login.tsx +++ b/apps/hq-web/src/Login.tsx @@ -40,7 +40,7 @@ export function Login() { {/* A real
so Enter submits from either field (not just password). */} { e.preventDefault(); void submit() }}>

Sign in

- + setEmail(e.target.value)} diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 6ef5185..4d5f923 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -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 => 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. */ diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 402c606..d5e0449 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -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() { +

Recent documents

{docs.length === 0 ? No documents yet. : ( @@ -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: {DOC_TYPE_LABEL[d.docType]}, + date: d.docDate, payable: inr(d.payablePaise), status: {d.status}, }))} @@ -363,7 +366,8 @@ export function ClientDetail() { onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)} rows={docs.map((d) => ({ no: d.docNo ?? draft, - type: d.docType, date: d.docDate, + type: {DOC_TYPE_LABEL[d.docType]}, + date: d.docDate, payable: inr(d.payablePaise), status: {d.status}, }))} @@ -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) => ( +
+ {label} + {value} +
+ ) + + return ( +
+

Details

+
+ {field('Name', c.name)} + {field('Code', c.code, true)} + {c.gstin !== undefined && c.gstin !== '' && field('GSTIN', c.gstin, true)} + {field('Status', {c.status})} + {field('State', c.stateCode)} +
+ + {c.address !== '' && ( +
+ Address +
{c.address}
+
+ )} + + {c.contacts.length > 0 && ( + <> +
+ Contacts +
+ {c.contacts.map((ct, i) => ( +
+ {contactRoleLabel(ct.role)} + {ct.name !== undefined && ct.name !== '' && {ct.name}} + {ct.email !== undefined && ct.email !== '' && {ct.email}} + {ct.phone !== undefined && ct.phone !== '' && {ct.phone}} + {(ct.name === undefined || ct.name === '') && (ct.email === undefined || ct.email === '') + && (ct.phone === undefined || ct.phone === '') && } +
+ ))} +
+ + )} +
+ ) +} + /** * 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 diff --git a/apps/hq-web/src/pages/Clients.tsx b/apps/hq-web/src/pages/Clients.tsx index f1e538f..3c69a5d 100644 --- a/apps/hq-web/src/pages/Clients.tsx +++ b/apps/hq-web/src/pages/Clients.tsx @@ -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 = { lead: 'accent', active: 'ok', dormant: 'warn', lost: 'err', } -export const DOC_TONE: Record = { - 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 = { + 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 = { + 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 = { + QUOTATION: 'Quotation', PROFORMA: 'Proforma', INVOICE: 'Invoice', CREDIT_NOTE: 'Credit note', RECEIPT: 'Receipt', } /** Client registry — search, list, inline create; row click opens the Client 360°. */ diff --git a/apps/hq-web/src/pages/DocumentView.tsx b/apps/hq-web/src/pages/DocumentView.tsx index c4e01ee..fb75d51 100644 --- a/apps/hq-web/src/pages/DocumentView.tsx +++ b/apps/hq-web/src/pages/DocumentView.tsx @@ -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() { {doc.status}} + status={ + <> + {DOC_TYPE_LABEL[doc.docType]} + {doc.status} + + } meta={ <> {doc.docNo ?? '(draft)'} diff --git a/apps/hq-web/src/pages/Documents.tsx b/apps/hq-web/src/pages/Documents.tsx index a185df7..d486b11 100644 --- a/apps/hq-web/src/pages/Documents.tsx +++ b/apps/hq-web/src/pages/Documents.tsx @@ -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: {TYPE_LABEL[d.docType]}, client: d.clientName, date: d.docDate, status: {d.status}, diff --git a/apps/hq-web/src/pages/Employees.tsx b/apps/hq-web/src/pages/Employees.tsx index cb40586..da6eda3 100644 --- a/apps/hq-web/src/pages/Employees.tsx +++ b/apps/hq-web/src/pages/Employees.tsx @@ -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() { <> ({ - name: e.displayName, email: e.email, + name: e.displayName, username: e.username, email: e.email, phone: e.phone ?? '—', title: e.title ?? '—', role: {e.role}, active: e.active ? active : inactive, @@ -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() @@ -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' && ( - - setAddF((p) => ({ ...p, email: e.target.value }))} /> + + setAddF((p) => ({ ...p, username: e.target.value }))} /> + + + setAddF((p) => ({ ...p, email: e.target.value }))} /> setAddF((p) => ({ ...p, displayName: e.target.value }))} /> diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 4dc4d07..3dc72f8 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -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.` class. + */ +export type BadgeTone = + | 'ok' | 'warn' | 'err' | 'accent' + | 'green' | 'blue' | 'violet' | 'gold' | 'red' + +export function Badge(props: { children: ReactNode; tone?: BadgeTone }) { return {props.children} } diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index c6965df..395ca0a 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -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 {