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
/** D18 WS-F support-access data; the DB password itself is reveal-only. */
anydesk?: string; os?: string; district?: string; sector?: string
/** D35: 'PACS' (co-op society) or 'Store' (retail). */
clientType: string
hasDbPassword: boolean
}
@ -232,12 +234,13 @@ export interface ClientWrite {
}
export const getClients = (
q?: string, filters?: { district?: string; sector?: string },
q?: string, filters?: { district?: string; sector?: string; clientType?: string },
): Promise<Client[]> => {
const params = new URLSearchParams()
if (q !== undefined && q !== '') params.set('q', q)
if (filters?.district !== undefined && filters.district !== '') params.set('district', filters.district)
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()
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.
const [district, setDistrict] = 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.
const qDebounced = useDebounced(q)
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 toast = useToast()
@ -164,6 +166,15 @@ export function Clients() {
...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" />
{isOwner && (selecting ? (
<>
@ -189,6 +200,7 @@ export function Clients() {
columns={[
...(selecting ? [{ key: 'sel', label: '' }] : []),
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
{ key: 'type', label: 'Type' },
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
{ key: 'contact', label: 'Contact' },
]}
@ -203,6 +215,7 @@ export function Clients() {
),
} : {}),
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>,
state: c.stateCode,
contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',

@ -260,6 +260,7 @@ export function apiRouter(
const filters = {
...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}),
...(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) })
} catch (err) {
@ -840,7 +841,7 @@ export function apiRouter(
const client: Client = picked ?? {
id: '', code: '—', name: '— pick a client —',
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 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
-- (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,
-- 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
);
CREATE TABLE IF NOT EXISTS module (
@ -443,6 +445,12 @@ function migrate(db: SqliteRaw): void {
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.
const stgClientCols = db.prepare(`PRAGMA table_info(stg_client)`).all() as { name: string }[]
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',
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
* it is encrypted at rest and readable only via revealClientDbPassword (audited). */
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). */
hasDbPassword: boolean
}
@ -24,6 +26,7 @@ interface ClientRow {
id: string; code: string; name: string; gstin: string | null; state_code: 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
client_type: string | null
db_password_enc: string | null
source: string; created_at: string
}
@ -40,6 +43,7 @@ function toClient(r: ClientRow): Client {
...(r.os !== null ? { os: r.os } : {}),
...(r.district !== null ? { district: r.district } : {}),
...(r.sector !== null ? { sector: r.sector } : {}),
clientType: r.client_type ?? 'PACS',
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(
db: DB, q?: string, filters: { district?: string; sector?: string } = {},
db: DB, q?: string, filters: { district?: string; sector?: string; clientType?: string } = {},
): Promise<Client[]> {
let where = ' WHERE 1=1'
const args: unknown[] = []
@ -70,6 +74,9 @@ export async function listClients(
if (filters.sector !== undefined && 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)
return rows.map(toClient)
}
@ -82,7 +89,12 @@ export async function getClient(db: DB, id: string): Promise<Client | null> {
export interface ClientInput {
code?: string; name: string; gstin?: string; stateCode: string; address?: 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> {
@ -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')}`
await db.run(
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
anydesk, os, district, sector, client_type, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, code, input.name, input.gstin ?? null, input.stateCode,
input.address ?? '', JSON.stringify(input.contacts ?? []),
input.status ?? 'active',
input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null,
input.clientType ?? typeFromName(input.name),
input.notes ?? '', new Date().toISOString(),
)
const client = (await getClient(db, id))!
@ -110,6 +123,7 @@ export interface ClientPatch {
contacts?: ClientContact[]; status?: Client['status']; notes?: string
/** '' clears the field (stored NULL). */
anydesk?: string; os?: string; district?: string; sector?: string
clientType?: string
}
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.status !== undefined) { sets.push('status=?'); args.push(patch.status) }
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) => {
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 = {
id: 'sample', code: 'SAMPLE', name: 'Sample Client Pvt Ltd', gstin: '32ABCDE1234F1Z5',
stateCode, address: '1st Floor, MG Road, Kochi', contacts: [], status: 'active', notes: '',
hasDbPassword: false,
clientType: 'PACS', hasDbPassword: false,
}
const line: BillLine = {
itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS',

Loading…
Cancel
Save