feat(d37): society category (Kerala co-op directory taxonomy) on clients

Adds client.category (SQLite migrate + PG migration 014), derived from the name to match
the official Kerala co-op classification: Service Bank / Urban Bank / Vanitha / Housing /
Employees / Agricultural / Marketing / Society (other) / Store / Other. Backfilled by a
portable CASE (most-specific-first); new/edited clients derive it via categoryFromName.
Clients screen gains a Category filter dropdown + column. Live backfill: 135 Service
Bank, 38 Employees, 15 Vanitha, 14 Store, 12 Agricultural, 11 Urban, … typecheck + full
suite (416) green.

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

@ -105,6 +105,8 @@ export interface Client {
anydesk?: string; os?: string; district?: string; sector?: string
/** D35: 'PACS' (co-op society) or 'Store' (retail). */
clientType: string
/** D37: society category (Service Bank / Vanitha / Housing / Employees / …). */
category: string
hasDbPassword: boolean
}
@ -247,13 +249,14 @@ export interface ClientWrite {
}
export const getClients = (
q?: string, filters?: { district?: string; sector?: string; clientType?: string },
q?: string, filters?: { district?: string; sector?: string; clientType?: string; category?: 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)
if (filters?.category !== undefined && filters.category !== '') params.set('category', filters.category)
const qs = params.toString()
return apiFetch<{ clients: Client[] }>(`/clients${qs !== '' ? `?${qs}` : ''}`).then((r) => r.clients)
}

@ -87,6 +87,12 @@ export const DOC_TYPE_LABEL: Record<DocType, string> = {
QUOTATION: 'Quotation', PROFORMA: 'Proforma', INVOICE: 'Invoice', CREDIT_NOTE: 'Credit note', RECEIPT: 'Receipt',
}
/** Society categories (Kerala co-op directory taxonomy, D37) — the filter dropdown list. */
const CATEGORIES = [
'Service Bank', 'Urban Bank', 'Vanitha', 'Housing', 'Employees',
'Agricultural', 'Marketing', 'Bank (other)', 'Society (other)', 'Store', 'Other',
] as const
/** Client registry — search, list, inline create; row click opens the Client 360°. */
export function Clients() {
const nav = useNavigate()
@ -95,11 +101,16 @@ export function Clients() {
const [district, setDistrict] = useState('')
const [sector, setSector] = useState('')
const [typeFilter, setTypeFilter] = useState('all') // D35: PACS / Store
const [catFilter, setCatFilter] = useState('') // D37: society category
// 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, ...(typeFilter !== 'all' ? { clientType: typeFilter } : {}) }),
[qDebounced, district, sector, typeFilter],
() => getClients(qDebounced, {
district, sector,
...(typeFilter !== 'all' ? { clientType: typeFilter } : {}),
...(catFilter !== '' ? { category: catFilter } : {}),
}),
[qDebounced, district, sector, typeFilter, catFilter],
)
const [sp, setSp] = useSearchParams()
const toast = useToast()
@ -175,6 +186,11 @@ export function Clients() {
{ key: 'Store', label: 'Store' },
]}
/>
<select className="wf" style={{ maxWidth: 190 }} aria-label="Category"
value={catFilter} onChange={(e) => setCatFilter(e.target.value)}>
<option value="">All categories</option>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<span className="spacer" />
{isOwner && (selecting ? (
<>
@ -200,7 +216,7 @@ export function Clients() {
columns={[
...(selecting ? [{ key: 'sel', label: '' }] : []),
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
{ key: 'type', label: 'Type' },
{ key: 'type', label: 'Type' }, { key: 'category', label: 'Category' },
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
{ key: 'contact', label: 'Contact' },
]}
@ -216,6 +232,7 @@ export function Clients() {
} : {}),
code: c.code, name: c.name,
type: <Badge tone={c.clientType === 'Store' ? 'violet' : undefined}>{c.clientType}</Badge>,
category: <span style={{ fontSize: 12.5 }}>{c.category}</span>,
status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
state: c.stateCode,
contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',

@ -283,6 +283,7 @@ export function apiRouter(
...(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'] } : {}),
...(typeof req.query['category'] === 'string' ? { category: req.query['category'] } : {}),
}
res.json({ ok: true, clients: await listClients(db, q, filters) })
} catch (err) {
@ -863,7 +864,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: '', clientType: 'PACS', hasDbPassword: false,
address: '', contacts: [], status: 'lead', notes: '', clientType: 'PACS', category: 'Other', hasDbPassword: false,
}
const t = prepared.totals
const doc: Doc = {

@ -106,6 +106,26 @@ export class SqliteDb implements DB {
* the SQLite SCHEMA below and by the Postgres migration set. Columns chosen: foreign keys
* used in joins/filters, and the status/date/renewal columns the scans and boards read.
*/
/**
* D37: derive the society category from the client name, matching the Kerala co-op directory
* taxonomy. Order matters most specific first (a "VANITHA … SOCIETY" is Vanitha, not the
* generic Society). Portable SQL (UPPER + LIKE) so SQLite and Postgres backfill identically.
*/
export const CATEGORY_BACKFILL_SQL = `
UPDATE client SET category = CASE
WHEN client_type = 'Store' THEN 'Store'
WHEN UPPER(name) LIKE '%VANITHA%' THEN 'Vanitha'
WHEN UPPER(name) LIKE '%HOUSING%' THEN 'Housing'
WHEN UPPER(name) LIKE '%URBAN%' THEN 'Urban Bank'
WHEN UPPER(name) LIKE '%EMPLOYEE%' OR UPPER(name) LIKE '%TEACHER%' OR UPPER(name) LIKE '%STAFF%' THEN 'Employees'
WHEN UPPER(name) LIKE '%MARKETING%' THEN 'Marketing'
WHEN UPPER(name) LIKE '%AGRICULTUR%' OR UPPER(name) LIKE '%RURAL DEVELOPMENT%' OR UPPER(name) LIKE '%RUBBER%' THEN 'Agricultural'
WHEN UPPER(name) LIKE '%SERVICE CO-OPERATIVE%' OR UPPER(name) LIKE '%SERVICE BANK%' OR UPPER(name) LIKE '%SCB%' THEN 'Service Bank'
WHEN UPPER(name) LIKE '%BANK%' THEN 'Bank (other)'
WHEN UPPER(name) LIKE '%SOCIET%' OR UPPER(name) LIKE '%CO-OPERATIVE%' OR UPPER(name) LIKE '%AICOS%' THEN 'Society (other)'
ELSE 'Other'
END`
export const INDEX_DDL = `
CREATE INDEX IF NOT EXISTS ix_document_client ON document (client_id);
CREATE INDEX IF NOT EXISTS ix_document_type_status ON document (doc_type, status);
@ -179,6 +199,8 @@ CREATE TABLE IF NOT EXISTS client (
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',
-- D37: society category (Service Bank / Vanitha / Housing / Employees / ); derived from name.
category TEXT NOT NULL DEFAULT 'Other',
notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS module (
@ -460,6 +482,12 @@ function migrate(db: SqliteRaw): void {
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'`)
}
// D37: society category matching the Kerala co-op directory taxonomy — derived from the
// name (Service Bank / Urban Bank / Vanitha / Housing / Employees / Agricultural / …).
if (!clientCols.some((c) => c.name === 'category')) {
db.exec(`ALTER TABLE client ADD COLUMN category TEXT NOT NULL DEFAULT 'Other'`)
db.exec(CATEGORY_BACKFILL_SQL)
}
// …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']) {

@ -330,6 +330,26 @@ CREATE TABLE IF NOT EXISTS login_event (
staff_id text, username_tried text NOT NULL, outcome text NOT NULL,
ip text NOT NULL DEFAULT '', user_agent text NOT NULL DEFAULT ''
);
`,
},
{
// D37: society category (directory taxonomy), backfilled from the name.
id: '014-client-category',
sql: `
ALTER TABLE client ADD COLUMN IF NOT EXISTS category text NOT NULL DEFAULT 'Other';
UPDATE client SET category = CASE
WHEN client_type = 'Store' THEN 'Store'
WHEN UPPER(name) LIKE '%VANITHA%' THEN 'Vanitha'
WHEN UPPER(name) LIKE '%HOUSING%' THEN 'Housing'
WHEN UPPER(name) LIKE '%URBAN%' THEN 'Urban Bank'
WHEN UPPER(name) LIKE '%EMPLOYEE%' OR UPPER(name) LIKE '%TEACHER%' OR UPPER(name) LIKE '%STAFF%' THEN 'Employees'
WHEN UPPER(name) LIKE '%MARKETING%' THEN 'Marketing'
WHEN UPPER(name) LIKE '%AGRICULTUR%' OR UPPER(name) LIKE '%RURAL DEVELOPMENT%' OR UPPER(name) LIKE '%RUBBER%' THEN 'Agricultural'
WHEN UPPER(name) LIKE '%SERVICE CO-OPERATIVE%' OR UPPER(name) LIKE '%SERVICE BANK%' OR UPPER(name) LIKE '%SCB%' THEN 'Service Bank'
WHEN UPPER(name) LIKE '%BANK%' THEN 'Bank (other)'
WHEN UPPER(name) LIKE '%SOCIET%' OR UPPER(name) LIKE '%CO-OPERATIVE%' OR UPPER(name) LIKE '%AICOS%' THEN 'Society (other)'
ELSE 'Other'
END;
`,
},
]

@ -18,6 +18,8 @@ export interface Client {
anydesk?: string; os?: string; district?: string; sector?: string
/** D35: 'PACS' (co-op society) or 'Store' (retail). */
clientType: string
/** D37: society category (Service Bank / Vanitha / Housing / Employees / …). */
category: string
/** True when an encrypted DB password is stored (the UI shows •••• + Reveal). */
hasDbPassword: boolean
}
@ -26,11 +28,27 @@ 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
client_type: string | null; category: string | null
db_password_enc: string | null
source: string; created_at: string
}
/** Derive the society category from a name (same taxonomy as the DB backfill; most specific first). */
export function categoryFromName(name: string): string {
const n = name.toUpperCase()
if (/STORE[S]?$/.test(name.trim().toUpperCase())) return 'Store'
if (n.includes('VANITHA')) return 'Vanitha'
if (n.includes('HOUSING')) return 'Housing'
if (n.includes('URBAN')) return 'Urban Bank'
if (/EMPLOYEE|TEACHER|STAFF/.test(n)) return 'Employees'
if (n.includes('MARKETING')) return 'Marketing'
if (/AGRICULTUR|RURAL DEVELOPMENT|RUBBER/.test(n)) return 'Agricultural'
if (/SERVICE CO-OPERATIVE|SERVICE BANK|SCB/.test(n)) return 'Service Bank'
if (n.includes('BANK')) return 'Bank (other)'
if (/SOCIET|CO-OPERATIVE|AICOS/.test(n)) return 'Society (other)'
return 'Other'
}
function toClient(r: ClientRow): Client {
return {
id: r.id, code: r.code, name: r.name,
@ -44,6 +62,7 @@ function toClient(r: ClientRow): Client {
...(r.district !== null ? { district: r.district } : {}),
...(r.sector !== null ? { sector: r.sector } : {}),
clientType: r.client_type ?? 'PACS',
category: r.category ?? 'Other',
hasDbPassword: r.db_password_enc !== null, // the ciphertext itself never leaves the repo
}
}
@ -54,7 +73,7 @@ function assertGstin(gstin: string): void {
}
export async function listClients(
db: DB, q?: string, filters: { district?: string; sector?: string; clientType?: string } = {},
db: DB, q?: string, filters: { district?: string; sector?: string; clientType?: string; category?: string } = {},
): Promise<Client[]> {
let where = ' WHERE 1=1'
const args: unknown[] = []
@ -77,6 +96,9 @@ export async function listClients(
if (filters.clientType !== undefined && filters.clientType !== '') {
where += ` AND client_type=?`; args.push(filters.clientType)
}
if (filters.category !== undefined && filters.category !== '') {
where += ` AND category=?`; args.push(filters.category)
}
const rows = await db.all<ClientRow>(`SELECT * FROM client${where} ORDER BY name`, ...args)
return rows.map(toClient)
}
@ -89,7 +111,7 @@ 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; clientType?: string
anydesk?: string; os?: string; district?: string; sector?: string; clientType?: string; category?: string
}
/** New clients default their type from the name (…STORE → Store), like the backfill. */
@ -104,13 +126,14 @@ 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, client_type, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
anydesk, os, district, sector, client_type, category, 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.category ?? categoryFromName(input.name),
input.notes ?? '', new Date().toISOString(),
)
const client = (await getClient(db, id))!
@ -123,7 +146,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
clientType?: string; category?: string
}
export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise<Client> {
@ -140,6 +163,7 @@ export async function updateClient(db: DB, userId: string, id: string, patch: Cl
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) }
if (patch.category !== undefined) { sets.push('category=?'); args.push(patch.category) }
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: '',
clientType: 'PACS', hasDbPassword: false,
clientType: 'PACS', category: 'Other', hasDbPassword: false,
}
const line: BillLine = {
itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS',

Loading…
Cancel
Save