diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 134249c..82ce4fb 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -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 => { 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) } diff --git a/apps/hq-web/src/pages/Clients.tsx b/apps/hq-web/src/pages/Clients.tsx index e57e04f..646253d 100644 --- a/apps/hq-web/src/pages/Clients.tsx +++ b/apps/hq-web/src/pages/Clients.tsx @@ -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 })), ]} /> + {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: {c.clientType}, 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 d14eccb..03077a2 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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 = { diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 1d4b8df..c606173 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -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']) { diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index c6e88ef..4fdfdfa 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -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'; +`, + }, ] diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts index ee2df2d..6432350 100644 --- a/apps/hq/src/repos-clients.ts +++ b/apps/hq/src/repos-clients.ts @@ -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 { 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(`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 { 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 { @@ -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 { @@ -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()) } } diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index 39a978d..3e329af 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: '', - hasDbPassword: false, + clientType: 'PACS', hasDbPassword: false, } const line: BillLine = { itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS',