From 9cb5127faf763da154ab5d8d598965b8889ddb60 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 22:28:30 +0530 Subject: [PATCH] feat(d37): society category (Kerala co-op directory taxonomy) on clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq-web/src/api.ts | 5 ++++- apps/hq-web/src/pages/Clients.tsx | 23 +++++++++++++++++--- apps/hq/src/api.ts | 3 ++- apps/hq/src/db.ts | 28 ++++++++++++++++++++++++ apps/hq/src/migrations-pg.ts | 20 +++++++++++++++++ apps/hq/src/repos-clients.ts | 36 +++++++++++++++++++++++++------ apps/hq/src/templates.ts | 2 +- 7 files changed, 105 insertions(+), 12 deletions(-) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 9698f42..37f543c 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -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 => { 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) } diff --git a/apps/hq-web/src/pages/Clients.tsx b/apps/hq-web/src/pages/Clients.tsx index 646253d..5589325 100644 --- a/apps/hq-web/src/pages/Clients.tsx +++ b/apps/hq-web/src/pages/Clients.tsx @@ -87,6 +87,12 @@ export const DOC_TYPE_LABEL: Record = { 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' }, ]} /> + {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: {c.clientType}, + category: {c.category}, status: {c.status}, state: c.stateCode, contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—', diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 19c6136..9c20d8e 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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 = { diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 7f3503c..cd0cef8 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -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']) { diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 1fcfd69..36756e3 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -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; `, }, ] diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts index 6432350..85810d8 100644 --- a/apps/hq/src/repos-clients.ts +++ b/apps/hq/src/repos-clients.ts @@ -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 { 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(`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 { 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 { @@ -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()) } } diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 3e329af..decddc7 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -413,7 +413,7 @@ export function documentHtmlSample(company: Record, 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',