feat(d35): client type (PACS / Store), classified by name

Adds client.client_type (SQLite migrate + PG migration 012), backfilled from the name:
a name ending in STORE/STORES = 'Store', everything else = 'PACS' (14 Stores of 273).
New clients default the same way; editable per client. Clients screen gains an
All types / PACS / Store filter and a Type column. typecheck + client tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent e8e9db33ea
commit a81034d4ed

@ -90,6 +90,8 @@ export interface Client {
ownerId?: string ownerId?: string
/** D18 WS-F support-access data; the DB password itself is reveal-only. */ /** D18 WS-F support-access data; the DB password itself is reveal-only. */
anydesk?: string; os?: string; district?: string; sector?: string anydesk?: string; os?: string; district?: string; sector?: string
/** D35: 'PACS' (co-op society) or 'Store' (retail). */
clientType: string
hasDbPassword: boolean hasDbPassword: boolean
} }
@ -232,12 +234,13 @@ export interface ClientWrite {
} }
export const getClients = ( export const getClients = (
q?: string, filters?: { district?: string; sector?: string }, q?: string, filters?: { district?: string; sector?: string; clientType?: string },
): Promise<Client[]> => { ): Promise<Client[]> => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (q !== undefined && q !== '') params.set('q', q) if (q !== undefined && q !== '') params.set('q', q)
if (filters?.district !== undefined && filters.district !== '') params.set('district', filters.district) if (filters?.district !== undefined && filters.district !== '') params.set('district', filters.district)
if (filters?.sector !== undefined && filters.sector !== '') params.set('sector', filters.sector) if (filters?.sector !== undefined && filters.sector !== '') params.set('sector', filters.sector)
if (filters?.clientType !== undefined && filters.clientType !== '') params.set('clientType', filters.clientType)
const qs = params.toString() const qs = params.toString()
return apiFetch<{ clients: Client[] }>(`/clients${qs !== '' ? `?${qs}` : ''}`).then((r) => r.clients) return apiFetch<{ clients: Client[] }>(`/clients${qs !== '' ? `?${qs}` : ''}`).then((r) => r.clients)
} }

@ -94,10 +94,12 @@ export function Clients() {
// D18 WS-F: the old book's two working filters, applied server-side. // D18 WS-F: the old book's two working filters, applied server-side.
const [district, setDistrict] = useState('') const [district, setDistrict] = useState('')
const [sector, setSector] = useState('') const [sector, setSector] = useState('')
const [typeFilter, setTypeFilter] = useState('all') // D35: PACS / Store
// Debounced so typing in the search box doesn't fire a server request per keystroke. // Debounced so typing in the search box doesn't fire a server request per keystroke.
const qDebounced = useDebounced(q) const qDebounced = useDebounced(q)
const { data, error, reload } = useData( const { data, error, reload } = useData(
() => getClients(qDebounced, { district, sector }), [qDebounced, district, sector], () => getClients(qDebounced, { district, sector, ...(typeFilter !== 'all' ? { clientType: typeFilter } : {}) }),
[qDebounced, district, sector, typeFilter],
) )
const [sp, setSp] = useSearchParams() const [sp, setSp] = useSearchParams()
const toast = useToast() const toast = useToast()
@ -164,6 +166,15 @@ export function Clients() {
...CLIENT_STATUSES.map((s) => ({ key: s, label: s, count: counts.get(s) ?? 0 })), ...CLIENT_STATUSES.map((s) => ({ key: s, label: s, count: counts.get(s) ?? 0 })),
]} ]}
/> />
<FilterChips
active={typeFilter}
onChange={setTypeFilter}
chips={[
{ key: 'all', label: 'All types' },
{ key: 'PACS', label: 'PACS' },
{ key: 'Store', label: 'Store' },
]}
/>
<span className="spacer" /> <span className="spacer" />
{isOwner && (selecting ? ( {isOwner && (selecting ? (
<> <>
@ -189,6 +200,7 @@ export function Clients() {
columns={[ columns={[
...(selecting ? [{ key: 'sel', label: '' }] : []), ...(selecting ? [{ key: 'sel', label: '' }] : []),
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' }, { key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
{ key: 'type', label: 'Type' },
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' }, { key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
{ key: 'contact', label: 'Contact' }, { key: 'contact', label: 'Contact' },
]} ]}
@ -203,6 +215,7 @@ export function Clients() {
), ),
} : {}), } : {}),
code: c.code, name: c.name, code: c.code, name: c.name,
type: <Badge tone={c.clientType === 'Store' ? 'violet' : undefined}>{c.clientType}</Badge>,
status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>, status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
state: c.stateCode, state: c.stateCode,
contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—', contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',

@ -260,6 +260,7 @@ export function apiRouter(
const filters = { const filters = {
...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}), ...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}),
...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}), ...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}),
...(typeof req.query['clientType'] === 'string' ? { clientType: req.query['clientType'] } : {}),
} }
res.json({ ok: true, clients: await listClients(db, q, filters) }) res.json({ ok: true, clients: await listClients(db, q, filters) })
} catch (err) { } catch (err) {
@ -840,7 +841,7 @@ export function apiRouter(
const client: Client = picked ?? { const client: Client = picked ?? {
id: '', code: '—', name: '— pick a client —', id: '', code: '—', name: '— pick a client —',
stateCode: company['company.state_code'] ?? '32', stateCode: company['company.state_code'] ?? '32',
address: '', contacts: [], status: 'lead', notes: '', hasDbPassword: false, address: '', contacts: [], status: 'lead', notes: '', clientType: 'PACS', hasDbPassword: false,
} }
const t = prepared.totals const t = prepared.totals
const doc: Doc = { const doc: Doc = {

@ -168,6 +168,8 @@ CREATE TABLE IF NOT EXISTS client (
-- D18 WS-F: support-access data from the APEX book. db_password_enc is AES-256-GCM -- D18 WS-F: support-access data from the APEX book. db_password_enc is AES-256-GCM
-- (HQ_SECRET_KEY) and never appears in list/get payloads reveal-only, audited. -- (HQ_SECRET_KEY) and never appears in list/get payloads reveal-only, audited.
anydesk TEXT, os TEXT, district TEXT, sector TEXT, db_password_enc TEXT, anydesk TEXT, os TEXT, district TEXT, sector TEXT, db_password_enc TEXT,
-- D35: 'PACS' (co-op society) vs 'Store' (retail); backfilled by name, editable per client.
client_type TEXT NOT NULL DEFAULT 'PACS',
notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS module ( CREATE TABLE IF NOT EXISTS module (
@ -443,6 +445,12 @@ function migrate(db: SqliteRaw): void {
db.exec(`ALTER TABLE client ADD COLUMN ${col} TEXT`) db.exec(`ALTER TABLE client ADD COLUMN ${col} TEXT`)
} }
} }
// D35: client type — 'PACS' (co-op society) vs 'Store' (retail). Backfilled by name:
// a name ending in STORE/STORES is a Store, everything else a PACS. Editable per client.
if (!clientCols.some((c) => c.name === 'client_type')) {
db.exec(`ALTER TABLE client ADD COLUMN client_type TEXT NOT NULL DEFAULT 'PACS'`)
db.exec(`UPDATE client SET client_type='Store' WHERE UPPER(TRIM(name)) LIKE '%STORE' OR UPPER(TRIM(name)) LIKE '%STORES'`)
}
// …and the same fields on the import staging table so the cutover carries them. // …and the same fields on the import staging table so the cutover carries them.
const stgClientCols = db.prepare(`PRAGMA table_info(stg_client)`).all() as { name: string }[] const stgClientCols = db.prepare(`PRAGMA table_info(stg_client)`).all() as { name: string }[]
for (const col of ['anydesk', 'os', 'district', 'sector']) { for (const col of ['anydesk', 'os', 'district', 'sector']) {

@ -312,4 +312,13 @@ CREATE TABLE IF NOT EXISTS sms_balance_check (
id: '011-staff-must-change-password', id: '011-staff-must-change-password',
sql: `ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS must_change_password integer NOT NULL DEFAULT 0;`, sql: `ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS must_change_password integer NOT NULL DEFAULT 0;`,
}, },
{
// D35: client type (PACS vs Store), backfilled from the name.
id: '012-client-type',
sql: `
ALTER TABLE client ADD COLUMN IF NOT EXISTS client_type text NOT NULL DEFAULT 'PACS';
UPDATE client SET client_type='Store'
WHERE UPPER(TRIM(name)) LIKE '%STORE' OR UPPER(TRIM(name)) LIKE '%STORES';
`,
},
] ]

@ -16,6 +16,8 @@ export interface Client {
/** D18 WS-F support-access data (APEX parity). The DB password is NOT here /** D18 WS-F support-access data (APEX parity). The DB password is NOT here
* it is encrypted at rest and readable only via revealClientDbPassword (audited). */ * it is encrypted at rest and readable only via revealClientDbPassword (audited). */
anydesk?: string; os?: string; district?: string; sector?: string anydesk?: string; os?: string; district?: string; sector?: string
/** D35: 'PACS' (co-op society) or 'Store' (retail). */
clientType: string
/** True when an encrypted DB password is stored (the UI shows •••• + Reveal). */ /** True when an encrypted DB password is stored (the UI shows •••• + Reveal). */
hasDbPassword: boolean hasDbPassword: boolean
} }
@ -24,6 +26,7 @@ interface ClientRow {
id: string; code: string; name: string; gstin: string | null; state_code: string id: string; code: string; name: string; gstin: string | null; state_code: string
address: string; contacts: string; status: string; owner_id: string | null; notes: string address: string; contacts: string; status: string; owner_id: string | null; notes: string
anydesk: string | null; os: string | null; district: string | null; sector: string | null anydesk: string | null; os: string | null; district: string | null; sector: string | null
client_type: string | null
db_password_enc: string | null db_password_enc: string | null
source: string; created_at: string source: string; created_at: string
} }
@ -40,6 +43,7 @@ function toClient(r: ClientRow): Client {
...(r.os !== null ? { os: r.os } : {}), ...(r.os !== null ? { os: r.os } : {}),
...(r.district !== null ? { district: r.district } : {}), ...(r.district !== null ? { district: r.district } : {}),
...(r.sector !== null ? { sector: r.sector } : {}), ...(r.sector !== null ? { sector: r.sector } : {}),
clientType: r.client_type ?? 'PACS',
hasDbPassword: r.db_password_enc !== null, // the ciphertext itself never leaves the repo hasDbPassword: r.db_password_enc !== null, // the ciphertext itself never leaves the repo
} }
} }
@ -50,7 +54,7 @@ function assertGstin(gstin: string): void {
} }
export async function listClients( export async function listClients(
db: DB, q?: string, filters: { district?: string; sector?: string } = {}, db: DB, q?: string, filters: { district?: string; sector?: string; clientType?: string } = {},
): Promise<Client[]> { ): Promise<Client[]> {
let where = ' WHERE 1=1' let where = ' WHERE 1=1'
const args: unknown[] = [] const args: unknown[] = []
@ -70,6 +74,9 @@ export async function listClients(
if (filters.sector !== undefined && filters.sector !== '') { if (filters.sector !== undefined && filters.sector !== '') {
where += ` AND LOWER(sector) LIKE LOWER(?)`; args.push(`%${filters.sector}%`) where += ` AND LOWER(sector) LIKE LOWER(?)`; args.push(`%${filters.sector}%`)
} }
if (filters.clientType !== undefined && filters.clientType !== '') {
where += ` AND client_type=?`; args.push(filters.clientType)
}
const rows = await db.all<ClientRow>(`SELECT * FROM client${where} ORDER BY name`, ...args) const rows = await db.all<ClientRow>(`SELECT * FROM client${where} ORDER BY name`, ...args)
return rows.map(toClient) return rows.map(toClient)
} }
@ -82,7 +89,12 @@ export async function getClient(db: DB, id: string): Promise<Client | null> {
export interface ClientInput { export interface ClientInput {
code?: string; name: string; gstin?: string; stateCode: string; address?: string code?: string; name: string; gstin?: string; stateCode: string; address?: string
contacts?: ClientContact[]; status?: Client['status']; notes?: string contacts?: ClientContact[]; status?: Client['status']; notes?: string
anydesk?: string; os?: string; district?: string; sector?: string anydesk?: string; os?: string; district?: string; sector?: string; clientType?: string
}
/** New clients default their type from the name (…STORE → Store), like the backfill. */
function typeFromName(name: string): string {
return /store[s]?$/i.test(name.trim()) ? 'Store' : 'PACS'
} }
export async function createClient(db: DB, userId: string, input: ClientInput): Promise<Client> { export async function createClient(db: DB, userId: string, input: ClientInput): Promise<Client> {
@ -92,12 +104,13 @@ export async function createClient(db: DB, userId: string, input: ClientInput):
const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}` const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}`
await db.run( await db.run(
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, notes, created_at) anydesk, os, district, sector, client_type, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, code, input.name, input.gstin ?? null, input.stateCode, id, code, input.name, input.gstin ?? null, input.stateCode,
input.address ?? '', JSON.stringify(input.contacts ?? []), input.address ?? '', JSON.stringify(input.contacts ?? []),
input.status ?? 'active', input.status ?? 'active',
input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null, input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null,
input.clientType ?? typeFromName(input.name),
input.notes ?? '', new Date().toISOString(), input.notes ?? '', new Date().toISOString(),
) )
const client = (await getClient(db, id))! const client = (await getClient(db, id))!
@ -110,6 +123,7 @@ export interface ClientPatch {
contacts?: ClientContact[]; status?: Client['status']; notes?: string contacts?: ClientContact[]; status?: Client['status']; notes?: string
/** '' clears the field (stored NULL). */ /** '' clears the field (stored NULL). */
anydesk?: string; os?: string; district?: string; sector?: string anydesk?: string; os?: string; district?: string; sector?: string
clientType?: string
} }
export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise<Client> { export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise<Client> {
@ -125,6 +139,7 @@ export async function updateClient(db: DB, userId: string, id: string, patch: Cl
if (patch.contacts !== undefined) { sets.push('contacts=?'); args.push(JSON.stringify(patch.contacts)) } if (patch.contacts !== undefined) { sets.push('contacts=?'); args.push(JSON.stringify(patch.contacts)) }
if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) } if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) }
if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) } if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) }
if (patch.clientType !== undefined) { sets.push('client_type=?'); args.push(patch.clientType) }
const optText = (col: string, v: string | undefined) => { const optText = (col: string, v: string | undefined) => {
if (v !== undefined) { sets.push(`${col}=?`); args.push(v.trim() === '' ? null : v.trim()) } if (v !== undefined) { sets.push(`${col}=?`); args.push(v.trim() === '' ? null : v.trim()) }
} }

@ -413,7 +413,7 @@ export function documentHtmlSample(company: Record<string, string>, opts: { cont
const client: Client = { const client: Client = {
id: 'sample', code: 'SAMPLE', name: 'Sample Client Pvt Ltd', gstin: '32ABCDE1234F1Z5', id: 'sample', code: 'SAMPLE', name: 'Sample Client Pvt Ltd', gstin: '32ABCDE1234F1Z5',
stateCode, address: '1st Floor, MG Road, Kochi', contacts: [], status: 'active', notes: '', stateCode, address: '1st Floor, MG Road, Kochi', contacts: [], status: 'active', notes: '',
hasDbPassword: false, clientType: 'PACS', hasDbPassword: false,
} }
const line: BillLine = { const line: BillLine = {
itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS', itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS',

Loading…
Cancel
Save