/** HQ web ↔ hq-server client; token survives reloads via localStorage['hq.token']. */ const TOKEN_KEY = 'hq.token' const NAME_KEY = 'hq.name' const ROLE_KEY = 'hq.role' const ID_KEY = 'hq.id' export function hasSession(): boolean { return (localStorage.getItem(TOKEN_KEY) ?? '') !== '' } export function displayName(): string { return localStorage.getItem(NAME_KEY) ?? '' } /** The signed-in employee's id ('' for sessions minted before it was stored). */ export function staffId(): string { return localStorage.getItem(ID_KEY) ?? '' } /** 'owner' | 'manager' | 'staff' — pages gate write actions on this. */ export function role(): string { return localStorage.getItem(ROLE_KEY) ?? 'staff' } /** True for the managerial roles (owner|manager); mirrors the server-side gate. */ export function isManagerial(): boolean { const r = role() return r === 'owner' || r === 'manager' } export function clearSession(): void { localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(NAME_KEY) localStorage.removeItem(ROLE_KEY) localStorage.removeItem(ID_KEY) } /** * Fetch `/api${path}` with the session bearer token. On 401 (outside the login * call itself) the session is cleared and the app redirects to /login. */ export async function apiFetch(path: string, opts?: RequestInit): Promise { const token = localStorage.getItem(TOKEN_KEY) ?? '' const res = await fetch(`/api${path}`, { ...opts, headers: { 'content-type': 'application/json', ...(token !== '' ? { authorization: `Bearer ${token}` } : {}), ...(opts?.headers ?? {}), }, }).catch(() => undefined) if (res === undefined) throw new Error('HQ server unreachable — is it running on port 5182?') if (res.status === 401 && !path.startsWith('/auth/')) { clearSession() window.location.hash = '#/login' throw new Error('Not signed in') } const body = (await res.json().catch(() => ({}))) as T & { error?: string } if (!res.ok) throw new Error(body.error ?? `Server error HTTP ${res.status}`) return body } export async function login(email: string, password: string): Promise<{ displayName: string; role: string; mustChangePassword: boolean }> { const out = await apiFetch<{ token: string; staff: { id: string; displayName: string; role: string }; mustChangePassword?: boolean }>( '/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }, ) localStorage.setItem(TOKEN_KEY, out.token) localStorage.setItem(NAME_KEY, out.staff.displayName) localStorage.setItem(ROLE_KEY, out.staff.role) localStorage.setItem(ID_KEY, out.staff.id) return { displayName: out.staff.displayName, role: out.staff.role, mustChangePassword: out.mustChangePassword === true } } // D36: login history (owner) — who logged in / failed, from where. export interface LoginEventRow { id: string; atWall: string; staffId: string | null; usernameTried: string outcome: 'success' | 'failed' | 'locked'; ip: string; userAgent: string; staffName: string | null } export interface LoginEventPage { rows: LoginEventRow[]; total: number; page: number; pageSize: number } export const getLoginEvents = (params: Record = {}): Promise => { const p = new URLSearchParams() for (const [k, v] of Object.entries(params)) if (v !== '' && v !== undefined) p.set(k, String(v)) const qs = p.toString() return apiFetch(`/login-events${qs !== '' ? `?${qs}` : ''}`) } export interface EmailStatus { connected: boolean; address?: string; dead: boolean } export const getEmailStatus = (): Promise => apiFetch('/email/status') // ---------- shared server shapes (mirror apps/hq repos — camelCase JSON) ---------- export interface ClientContact { name: string; phone?: string; email?: string; role?: string } export type ClientStatus = 'lead' | 'active' | 'dormant' | 'lost' export const CLIENT_STATUSES: ClientStatus[] = ['lead', 'active', 'dormant', 'lost'] export interface Client { id: string; code: string; name: string; gstin?: string; stateCode: string address: string; contacts: ClientContact[]; status: ClientStatus; notes: string /** Account/enquiry owner (→ employee id); absent = unassigned. */ 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 /** D37: society category (Service Bank / Vanitha / Housing / Employees / …). */ category: string hasDbPassword: boolean } export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage' export const KIND_LABEL: Record = { one_time: 'One-time', monthly: 'Monthly', yearly: 'Yearly', usage: 'Usage', } /** D21: a module-declared operational field. `type` decides the service-card input; * `secret` values are encrypted at rest and never returned in the module list. */ export type FieldType = 'text' | 'secret' | 'date' | 'number' | 'select' | 'boolean' export interface FieldDef { key: string; label: string; type: FieldType options?: string[] // for type 'select' required?: boolean; help?: string; sort?: number } export interface Module { id: string; code: string; name: string; sac: string allowedKinds: Kind[]; multiSubscription: boolean; active: boolean quoteContent: string[] /** D21: the operational fields this module declares — drives the service-card form. */ fieldSpec: FieldDef[] } export interface ModulePrice { id: string; moduleId: string; edition: string; kind: Kind pricePaise: number; effectiveFrom: string } export type ClientModuleStatus = | 'quoted' | 'ordered' | 'installing' | 'installed' | 'trained' | 'live' | 'expired' | 'cancelled' export const CLIENT_MODULE_STATUSES: ClientModuleStatus[] = [ 'quoted', 'ordered', 'installing', 'installed', 'trained', 'live', 'expired', 'cancelled', ] /** One labeled service-data row on a client module (free vocabulary, D20). */ export interface ServiceDetail { label: string; value: string } export interface ClientModule { id: string; clientId: string; moduleId: string; status: ClientModuleStatus kind: Kind; edition: string installedOn: string | null; completedOn: string | null; trainedOn: string | null nextRenewal: string | null; active: boolean /** D20 per-service operational data; the portal password itself is reveal-only. */ provider: string | null; username: string | null details: ServiceDetail[]; remark: string | null hasPassword: boolean /** D21: non-secret values for the owning module's fieldSpec, keyed by field key. */ fieldValues: Record /** D21: which secret fields have a value stored — the value itself is reveal-only. */ secretKeys: string[] } export interface ClientModulePatch { status?: ClientModuleStatus; kind?: Kind; edition?: string installedOn?: string | null; completedOn?: string | null; trainedOn?: string | null nextRenewal?: string | null; active?: boolean /** D20 service data ('' clears the text fields). */ provider?: string; username?: string; remark?: string details?: ServiceDetail[] /** Owner/manager only (server 403s otherwise) — encrypted at rest, reveals audited. */ password?: string } export type DocType = 'QUOTATION' | 'PROFORMA' | 'INVOICE' | 'RECEIPT' | 'CREDIT_NOTE' export type DocStatus = | 'draft' | 'sent' | 'accepted' | 'invoiced' | 'part_paid' | 'paid' | 'lost' | 'cancelled' export const DOC_STATUSES: DocStatus[] = [ 'draft', 'sent', 'accepted', 'invoiced', 'part_paid', 'paid', 'lost', 'cancelled', ] export interface DocLine { itemId: string; name: string; hsn: string; qty: number; unitCode: string unitPricePaise: number; taxablePaise: number; lineTotalPaise: number } export interface Doc { id: string; docType: DocType; docNo: string | null; fy: string; clientId: string docDate: string; status: DocStatus; refDocId: string | null taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number roundOffPaise: number; payablePaise: number payload: { lines: DocLine[]; terms?: string; lineContents?: string[][] } source: string; createdBy: string; createdAt: string } export interface DocumentEvent { id: string; documentId: string; atWall: string; kind: string; meta: Record } /** A public share link for one document (mirrors apps/hq repos-shares — camelCase JSON). */ export interface Share { id: string; documentId: string; token: string; createdBy: string createdAt: string; expiresAt: string | null; revoked: boolean } /** email_log rows come straight off the table (snake_case). */ export interface EmailLogRow { id: string; document_id: string | null; to_addr: string; subject: string status: 'sent' | 'failed'; gmail_message_id: string | null; error: string | null; at_wall: string } export type PaymentMode = 'bank' | 'upi' | 'cheque' | 'cash' | 'other' export const PAYMENT_MODES: PaymentMode[] = ['bank', 'upi', 'cheque', 'cash', 'other'] export interface Payment { id: string; clientId: string; receivedOn: string; mode: PaymentMode reference: string; amountPaise: number; tdsPaise: number createdBy: string; createdAt: string allocations: { docNo: string; amountPaise: number }[] } export interface Ledger { documents: Doc[]; payments: Payment[]; advancePaise: number modulePaid: { moduleId: string; billedPaise: number; settledPaise: number }[] outstanding: { docId: string; docNo: string | null; docType: string; label: string; outstandingPaise: number }[] } // ---------- typed calls ---------- /** * Create/patch body for a client (shared by POST and PATCH). Every field is * optional — PATCH sends only what changed; POST omits blanks. Value types match * what the forms actually produce (kept permissive on purpose): `gstin: null` * clears on PATCH, `status`/`dbPassword` ride the inline quick-edits. */ export interface ClientWrite { name?: string gstin?: string | null stateCode?: string address?: string notes?: string contacts?: ClientContact[] status?: ClientStatus anydesk?: string os?: string district?: string sector?: string /** Set the encrypted DB password (owner/manager; server audits, ignores '' as a no-op). */ dbPassword?: string } export const getClients = ( 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) } /** Decrypt-and-return the client's DB password — owner/manager; every call is audited. */ export const revealDbPassword = (id: string): Promise => apiFetch<{ password: string }>(`/clients/${id}/reveal-db-password`, { method: 'POST', body: '{}' }) .then((r) => r.password) export const getClient = (id: string): Promise => apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client) export const createClient = (body: ClientWrite): Promise => apiFetch<{ client: Client }>('/clients', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.client) export const patchClient = (id: string, body: ClientWrite): Promise => apiFetch<{ client: Client }>(`/clients/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.client) /** Assign/clear the account owner — owner/manager only; audited server-side. `null` clears. */ export const setClientOwner = (id: string, ownerId: string | null): Promise => apiFetch<{ client: Client }>(`/clients/${id}/owner`, { method: 'PATCH', body: JSON.stringify({ ownerId }) }).then((r) => r.client) /** * Owner-only (server re-checks): apply the same patch to many clients in one audited * transaction (D26 cleanup at scale). Returns how many rows changed. `patch` reuses the * ClientWrite shape (district / sector / stateCode / status are the useful bulk fields). */ export const bulkUpdateClients = (ids: string[], patch: ClientWrite): Promise<{ updated: number }> => apiFetch<{ ok: boolean; updated: number }>('/clients/bulk', { method: 'POST', body: JSON.stringify({ ids, patch }), }).then((r) => ({ updated: r.updated })) // D28: module directory — every client on a module with its service fields. export interface ModuleDirectoryRow { cmId: string; clientId: string; clientName: string; clientCode: string status: string; provider: string | null; username: string | null; hasPassword: boolean kind: string; edition: string installedOn: string | null; completedOn: string | null; trainedOn: string | null; nextRenewal: string | null fieldValues: Record } export interface ModuleDirectory { moduleId: string; code: string; name: string; fieldSpec: FieldDef[] rows: ModuleDirectoryRow[]; total: number } export const getModuleDirectory = (moduleId: string): Promise => apiFetch(`/modules/${moduleId}/directory`) // D28: portals quick-list — every stored login/portal across all modules. export interface PortalRow { cmId: string; clientId: string; clientName: string; clientCode: string moduleCode: string; moduleName: string; provider: string | null username: string | null; hasPassword: boolean links: { label: string; url: string }[] } export const getPortals = (): Promise => apiFetch<{ portals: PortalRow[] }>('/reports/portals').then((r) => r.portals) // D28: global quick-search for the command palette — clients + documents + tickets. export interface SearchHit { type: 'client' | 'document' | 'ticket'; id: string; label: string; sub: string; to: string } export interface SearchResult { hits: SearchHit[]; capped: string[] } export const getSearch = (q: string): Promise => apiFetch(`/search?q=${encodeURIComponent(q)}`) export const getModules = (): Promise => apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules) export const createModule = (body: Record): Promise => apiFetch<{ module: Module }>('/modules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.module) export const patchModule = (id: string, body: Record): Promise => apiFetch<{ module: Module }>(`/modules/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.module) export const getPrices = (moduleId: string): Promise => apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`).then((r) => r.prices) export const addPrice = (moduleId: string, body: Record): Promise => apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) }) .then((r) => r.prices) export const getClientModules = (clientId: string): Promise => apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules) export const assignClientModule = (clientId: string, body: Record): Promise => apiFetch<{ clientModule: ClientModule }>(`/clients/${clientId}/modules`, { method: 'POST', body: JSON.stringify(body) }) .then((r) => r.clientModule) export const patchClientModule = (id: string, body: ClientModulePatch): Promise => apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) .then((r) => r.clientModule) /** Decrypt-and-return the module's portal password — owner/manager; every call is audited. */ export const revealModulePassword = (id: string): Promise => apiFetch<{ password: string }>(`/client-modules/${id}/reveal-password`, { method: 'POST', body: '{}' }) .then((r) => r.password) // ---------- D21: module-defined field values + secrets ---------- /** Write the non-secret module-field values in one shot (any signed-in user); * server validates keys/types against the owning module's fieldSpec, '' clears a value. */ export const setModuleFields = (id: string, values: Record): Promise => apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}/fields`, { method: 'PUT', body: JSON.stringify({ values }), }).then((r) => r.clientModule) /** Set/clear ONE secret field — owner/manager (server 403s otherwise); '' clears. */ export const setModuleSecret = (id: string, key: string, value: string): Promise => apiFetch<{ clientModule: ClientModule }>(`/client-modules/${id}/secrets`, { method: 'POST', body: JSON.stringify({ key, value }), }).then((r) => r.clientModule) /** Decrypt-and-return one secret field value — owner/manager; every call is audited. */ export const revealModuleSecret = (id: string, key: string): Promise => apiFetch<{ value: string }>(`/client-modules/${id}/reveal-secret`, { method: 'POST', body: JSON.stringify({ key }), }).then((r) => r.value) // ---------- onboarding milestones (D22) ---------- /** One checklist step on a project (client_module). Ticking stamps `doneOn`. */ export interface Milestone { key: string; label: string; done: boolean; doneOn: string | null; sort: number } /** The project's checklist; the server auto-seeds the standard template on first read. */ export const getMilestones = (clientModuleId: string): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`).then((r) => r.milestones) /** Tick/untick a step. done=true stamps `doneOn` (given date or today); done=false clears it. */ export const setMilestone = ( clientModuleId: string, key: string, done: boolean, doneOn?: string, ): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { method: 'POST', body: JSON.stringify({ key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), }).then((r) => r.milestones) /** Add a project-specific step beyond the standard template. */ export const addMilestone = (clientModuleId: string, label: string): Promise => apiFetch<{ milestones: Milestone[] }>(`/client-modules/${clientModuleId}/milestones`, { method: 'POST', body: JSON.stringify({ label }), }).then((r) => r.milestones) /** One row of the onboarding board: a milestone's completion across ALL active projects. */ export interface MilestoneBoardRow { key: string; label: string; total: number; done: number; pending: number } export const getProjectBoard = (): Promise => apiFetch<{ board: MilestoneBoardRow[] }>('/projects/board').then((r) => r.board) /** One active project still pending a given milestone — the board's drill-down. */ export interface PendingProject { clientModuleId: string; clientId: string; clientName: string; moduleCode: string; moduleName: string } export const getPendingProjects = (key: string): Promise => apiFetch<{ projects: PendingProject[] }>(`/projects/pending/${encodeURIComponent(key)}`).then((r) => r.projects) /** * Owner-only (server re-checks): tick/untick one milestone across many projects in a * single audited transaction (D26). Only projects that actually have the milestone are * touched. `done=true` stamps `doneOn` (given date or today). Returns how many changed. */ export const bulkSetMilestone = ( clientModuleIds: string[], key: string, done: boolean, doneOn?: string, ): Promise<{ updated: number }> => apiFetch<{ ok: boolean; updated: number }>('/projects/bulk-milestone', { method: 'POST', body: JSON.stringify({ clientModuleIds, key, done, ...(doneOn !== undefined ? { doneOn } : {}) }), }).then((r) => ({ updated: r.updated })) // ---------- ticket desk + client branches (D20 APEX parity) ---------- export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'closed' | 'dropped' export const TICKET_STATUSES: TicketStatus[] = ['open', 'in_progress', 'waiting', 'closed', 'dropped'] export interface Ticket { id: string; clientId: string; clientName: string branchId: string | null; branchName: string | null moduleCode: string | null; kind: string; description: string status: TicketStatus assignedTo: string | null; assignedName: string | null onlineOffline: string | null openedOn: string; closedOn: string | null createdBy: string; createdAt: string; source: string } export interface TicketsPage { tickets: Ticket[]; total: number; page: number; pageSize: number /** Whole-desk per-status counts (chips) + every kind seen so far (datalist suggestions). */ counts: Record; kinds: string[] /** SLA window (days) + count of still-open tickets past it — drives the Age tint + Overdue chip. */ slaDays: number; overdueCount: number } /** Server-paginated with an honest total (rule 8); mine=true narrows to my assignments. */ export const getTickets = ( opts: { status?: TicketStatus; mine?: boolean; clientId?: string; module?: string overdue?: boolean; q?: string; page?: number; pageSize?: number } = {}, ): Promise => { const p = new URLSearchParams() if (opts.status !== undefined) p.set('status', opts.status) if (opts.mine === true) p.set('mine', '1') if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId) if (opts.module !== undefined && opts.module !== '') p.set('module', opts.module) if (opts.overdue === true) p.set('overdue', '1') if (opts.q !== undefined && opts.q !== '') p.set('q', opts.q) if (opts.page !== undefined) p.set('page', String(opts.page)) if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) const q = p.toString() return apiFetch(`/tickets${q === '' ? '' : `?${q}`}`) } export const createTicket = (body: { clientId: string; branchId?: string; moduleCode?: string; kind?: string description?: string; onlineOffline?: string; assignedTo?: string }): Promise => apiFetch<{ ticket: Ticket }>('/tickets', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.ticket) /** assignedTo: null clears the assignment; closing stamps closedOn server-side. */ export const patchTicket = (id: string, body: { status?: TicketStatus; assignedTo?: string | null; kind?: string; description?: string moduleCode?: string | null; branchId?: string | null; onlineOffline?: string | null }): Promise => apiFetch<{ ticket: Ticket }>(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.ticket) export interface Branch { id: string; clientId: string; name: string; code: string | null; active: boolean } export const getBranches = (clientId: string): Promise => apiFetch<{ branches: Branch[] }>(`/clients/${clientId}/branches`).then((r) => r.branches) export const createBranch = (clientId: string, body: { name: string; code?: string }): Promise => apiFetch<{ branch: Branch }>(`/clients/${clientId}/branches`, { method: 'POST', body: JSON.stringify(body) }) .then((r) => r.branch) export const patchBranch = (id: string, body: { name?: string; code?: string; active?: boolean }): Promise => apiFetch<{ branch: Branch }>(`/branches/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.branch) export const getBillingSettings = (): Promise<{ paymentTermsDays: number }> => apiFetch<{ paymentTermsDays: number }>('/settings/billing') /** One Documents-list row: the document plus the server-joined client name (no N+1). */ export type DocListRow = Doc & { clientName: string } export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number } /** Server-paginated document list with an honest total (rule 8); filters compose. * `q` free-text-matches the document number or client name (D26 register search). */ export const getDocuments = ( opts: { type?: DocType; status?: DocStatus; clientId?: string; q?: string; page?: number; pageSize?: number } = {}, ): Promise => { const p = new URLSearchParams() if (opts.type !== undefined) p.set('type', opts.type) if (opts.status !== undefined) p.set('status', opts.status) if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId) if (opts.q !== undefined && opts.q !== '') p.set('q', opts.q) if (opts.page !== undefined) p.set('page', String(opts.page)) if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) const q = p.toString() return apiFetch(`/documents${q === '' ? '' : `?${q}`}`) } export const createDocument = (body: Record): Promise => apiFetch<{ document: Doc }>('/documents', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.document) export const getDocumentFull = (id: string): Promise<{ document: Doc; events: DocumentEvent[]; emails: EmailLogRow[]; shares: Share[] }> => apiFetch(`/documents/${id}`) /** issue / status / convert / cancel / credit-note / send — all POST → { document }. */ export const documentAction = (id: string, action: string, body?: Record): Promise => apiFetch<{ document: Doc }>(`/documents/${id}/${action}`, { method: 'POST', body: JSON.stringify(body ?? {}), }).then((r) => r.document) /** Mint a public share link for a document. `expiresDays: null` = never expires; omit for the +30d default. */ export const createShare = (id: string, expiresDays?: number | null): Promise<{ share: Share; shares: Share[] }> => apiFetch<{ share: Share; shares: Share[] }>(`/documents/${id}/share`, { method: 'POST', body: JSON.stringify(expiresDays !== undefined ? { expiresDays } : {}), }) /** Revoke a share (owner-gated, audited); returns the refreshed share list. */ export const revokeShare = (id: string, shareId: string): Promise<{ share: Share; shares: Share[] }> => apiFetch<{ share: Share; shares: Share[] }>(`/documents/${id}/share/${shareId}/revoke`, { method: 'POST', body: '{}', }) /** A payment against a client — amounts already converted to integer paise at the edge. */ export interface PaymentWrite { clientId: string; receivedOn: string; mode: PaymentMode reference?: string; amountPaise: number; tdsPaise?: number } export const recordPayment = (body: PaymentWrite): Promise => apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment) export const getLedger = (clientId: string): Promise => apiFetch(`/clients/${clientId}/ledger`) // ---------- employees (staff_user; D16) ---------- export type EmployeeRole = 'owner' | 'manager' | 'staff' export const EMPLOYEE_ROLES: EmployeeRole[] = ['owner', 'manager', 'staff'] export interface Employee { id: string; email: string; username: string; displayName: string; role: EmployeeRole; active: boolean phone: string | null; title: string | null mustChangePassword: boolean } /** Full list + server-echoed total (bounded set, no truncation) — any signed-in user. */ export const getEmployees = (): Promise<{ employees: Employee[]; total: number }> => apiFetch<{ employees: Employee[]; total: number }>('/employees') export const createEmployee = ( body: { username: string; email: string; displayName: string; role: EmployeeRole; password: string }, ): Promise => apiFetch<{ employee: Employee }>('/employees', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.employee) /** Email is the login id — immutable; name, role, phone and title may change. */ export const patchEmployee = ( id: string, body: { displayName?: string; role?: EmployeeRole; phone?: string; title?: string }, ): Promise => apiFetch<{ employee: Employee }>(`/employees/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.employee) // ---------- self-service profile (D18 WS-E) ---------- export const getMe = (): Promise => apiFetch<{ employee: Employee }>('/me').then((r) => r.employee) /** Self-scoped: only name + phone; role/email untouchable server-side. */ export const patchMe = (body: { displayName?: string; phone?: string }): Promise => apiFetch<{ employee: Employee }>('/me', { method: 'PATCH', body: JSON.stringify(body) }) .then((r) => { if (r.employee.displayName !== '') localStorage.setItem(NAME_KEY, r.employee.displayName); return r.employee }) export const changeMyPassword = (currentPassword: string, newPassword: string): Promise => apiFetch<{ ok: boolean }>('/me/password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }), }).then(() => undefined) export const deactivateEmployee = (id: string): Promise => apiFetch<{ employee: Employee }>(`/employees/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee) export const reactivateEmployee = (id: string): Promise => apiFetch<{ employee: Employee }>(`/employees/${id}/reactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee) export const resetEmployeePassword = (id: string, password: string): Promise => apiFetch<{ employee: Employee }>(`/employees/${id}/password`, { method: 'POST', body: JSON.stringify({ password }), }).then((r) => r.employee) // ---------- pipeline chase-list (D-PIPE; spec §6b/§9) ---------- export type PipelineStage = 'enquiry' | 'new_project' | 'quoted_waiting' | 'won' | 'lost' export type PipelineBand = 'green' | 'amber' | 'red' export type PipelineNextAction = | 'send_quote' | 'waiting' | 'chase' | 'nudge' | 'final_nudge' | 'convert_invoice' | 'none' export type PipelineFilter = 'all' | 'mine' | 'overdue' | 'lost' export interface PipelineRow { clientId: string; clientCode: string; clientName: string docId: string | null; docNo: string | null; docStatus: string | null amountPaise: number | null ownerId: string | null; ownerName: string | null stage: PipelineStage; ageDays: number | null nextAction: PipelineNextAction; band: PipelineBand | null } export interface PipelinePage { rows: PipelineRow[]; total: number; page: number; pageSize: number; dayOffsets: number[] } /** Server-paginated, role-gated (staff forced to own rows server-side). */ export const getPipeline = ( opts: { filter?: PipelineFilter; owner?: string; page?: number; pageSize?: number } = {}, ): Promise => { const p = new URLSearchParams() if (opts.filter !== undefined) p.set('filter', opts.filter) if (opts.owner !== undefined && opts.owner !== '') p.set('owner', opts.owner) if (opts.page !== undefined) p.set('page', String(opts.page)) if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) const q = p.toString() return apiFetch(`/pipeline${q === '' ? '' : `?${q}`}`) } // ---------- HQ-2 shapes ---------- export type Cadence = 'monthly' | 'yearly' export interface RecurringPlan { id: string; clientId: string; clientModuleId: string | null; cadence: Cadence amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean } export type AmcPaidStatus = 'paid' | 'unpaid' | 'unbilled' export interface AmcContract { id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null invoiceDocId: string | null; active: boolean; paidStatus: AmcPaidStatus } export interface InteractionType { code: string; label: string } export type Outcome = 'positive' | 'neutral' | 'negative' export interface Interaction { id: string; clientId: string; typeCode: string; onDate: string; staffId: string notes: string; outcome: Outcome | null; followUpOn: string | null; createdAt: string } export type ReminderRuleKind = | 'invoice_overdue' | 'renewal_due' | 'amc_expiring' | 'follow_up' | 'recurring_generated' | 'email_bounced' | 'quote_followup' export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed' export interface Reminder { id: string; ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string; clientId: string docId: string | null; status: ReminderStatus; policyApplied: 'auto' | 'manual' error: string | null; createdAt: string; sentAt: string | null } /** One queue row: the reminder + server-derived display fields (client name, doc no, owner, label). */ export type QueueItem = Reminder & { clientName: string; docNo: string | null; ownerId: string | null; label: string } export interface DashboardView { today: string overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[] dueThisWeek: { planId: string; clientId: string; clientName: string; nextRun: string; amountPaise: number | null }[] renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[] followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[] recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[] queue: QueueItem[] /** Honest queue size — `queue` is the first page only. */ queueTotal: number totals: { overduePaise: number; queued: number; failed: number } } // ---------- HQ-2 calls ---------- /** mine=true = My Day (owner/manager narrow to their own book; staff are always scoped). */ export const getDashboard = (mine = false): Promise => apiFetch<{ view: DashboardView }>(`/dashboard${mine ? '?mine=1' : ''}`).then((r) => r.view) export interface RemindersPage { reminders: QueueItem[]; total: number; page: number; pageSize: number } /** * Viewer-scoped, paginated queue slice + honest total (staff are server-forced to their * own rows). No `status` = the working set (queued|failed); `owner` narrows — managerial only. */ export const getReminders = ( opts: { status?: ReminderStatus; owner?: string; page?: number; pageSize?: number } = {}, ): Promise => { const p = new URLSearchParams() if (opts.status !== undefined) p.set('status', opts.status) if (opts.owner !== undefined && opts.owner !== '') p.set('owner', opts.owner) if (opts.page !== undefined) p.set('page', String(opts.page)) if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) const q = p.toString() return apiFetch(`/reminders${q === '' ? '' : `?${q}`}`) } export const sendReminder = (id: string): Promise => apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder) export const dismissReminder = (id: string): Promise => apiFetch<{ reminder: Reminder }>(`/reminders/${id}/dismiss`, { method: 'POST', body: '{}' }).then((r) => r.reminder) /** Recurring-plan create/patch body. `cadence`/`policy` carry the loose form-state * strings (valid: cadence 'monthly'|'yearly', policy 'auto'|'manual'); `amountPaise: * null` on PATCH reverts the override to the price book. */ export interface RecurringPlanWrite { clientModuleId?: string cadence?: string policy?: string nextRun?: string amountPaise?: number | null } export const getRecurringPlans = (clientId: string): Promise => apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans) export const createRecurringPlan = (clientId: string, body: RecurringPlanWrite): Promise => apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan) export const updateRecurringPlan = (id: string, body: RecurringPlanWrite): Promise => apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan) export const deactivateRecurringPlan = (id: string): Promise => apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.plan) /** AMC contract create/patch body (amounts already in integer paise). */ export interface AmcWrite { coverage?: string; periodFrom?: string; periodTo?: string amountPaise?: number; renewalReminderDays?: number } export const getAmc = (clientId: string): Promise => apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts) export const createAmc = (clientId: string, body: AmcWrite): Promise => apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract) export const updateAmc = (id: string, body: AmcWrite): Promise => apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract) export const deactivateAmc = (id: string): Promise => apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract) export const generateAmcRenewalInvoice = (id: string): Promise => apiFetch<{ document: Doc }>(`/amc/${id}/renewal-invoice`, { method: 'POST', body: '{}' }).then((r) => r.document) export const getInteractionTypes = (): Promise => apiFetch<{ types: InteractionType[] }>('/interaction-types').then((r) => r.types) /** Interaction create/patch body. PATCH can't change type/date (edit mode omits them); * `outcome`/`followUpOn: null` clears the column. `outcome` carries the loose * form-state string (valid: 'positive'|'neutral'|'negative'). */ export interface InteractionWrite { typeCode?: string onDate?: string notes?: string outcome?: string | null followUpOn?: string | null } export const getInteractions = (clientId: string): Promise => apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions) export const createInteraction = (clientId: string, body: InteractionWrite): Promise => apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction) export const updateInteraction = (id: string, body: InteractionWrite): Promise => apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction) // ---------- HQ-3a shapes ---------- export interface DuesAgingRow { clientId: string; clientName: string b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number } export interface ModuleRevenueRow { moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number } export interface ProfitabilityRow { clientId: string; clientName: string billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number } export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number } /** One GST-filing summary row (D26). `period` is 'YYYY-MM' per month, or 'TOTAL' on the * footer row. Invoices add, credit notes subtract — all amounts integer paise. */ export interface GstSummaryRow { period: string invoiceCount: number; creditNoteCount: number taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number taxPaise: number; totalPaise: number } export interface AwsUsageRow { id: string; clientId: string; month: string storageGb: number; transferGb: number; costPaise: number; source: 'auto' | 'manual'; updatedAt: string } // ---------- HQ-3a calls ---------- const rangeQs = (from?: string, to?: string): string => { const p = new URLSearchParams() if (from !== undefined && from !== '') p.set('from', from) if (to !== undefined && to !== '') p.set('to', to) const q = p.toString() return q === '' ? '' : `?${q}` } export const getDuesAging = (): Promise => apiFetch<{ rows: DuesAgingRow[] }>('/reports/dues-aging').then((r) => r.rows) export const getModuleRevenue = (from?: string, to?: string): Promise => apiFetch<{ rows: ModuleRevenueRow[] }>(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows) export const getProfitability = (from?: string, to?: string): Promise => apiFetch<{ rows: ProfitabilityRow[] }>(`/reports/profitability${rangeQs(from, to)}`).then((r) => r.rows) /** GST filing summary per month over [from, to] (inclusive doc-date bounds), plus a * TOTAL row. `from`/`to` are full 'YYYY-MM-DD' dates (the server compares doc_date). */ export const getGstSummary = (from?: string, to?: string): Promise<{ rows: GstSummaryRow[]; total: GstSummaryRow }> => apiFetch<{ ok: boolean; rows: GstSummaryRow[]; total: GstSummaryRow }>(`/reports/gst-summary${rangeQs(from, to)}`) .then((r) => ({ rows: r.rows, total: r.total })) export const getAwsRanking = (month?: string): Promise<{ month: string; total: number; rows: CostRankRow[] }> => apiFetch<{ month: string; total: number; rows: CostRankRow[] }>(`/aws/ranking${month !== undefined && month !== '' ? `?month=${month}` : ''}`) export const getClientAwsUsage = (clientId: string): Promise => apiFetch<{ usage: AwsUsageRow[] }>(`/clients/${clientId}/aws-usage`).then((r) => r.usage) export const upsertAwsUsage = (body: Record): Promise => apiFetch<{ usage: AwsUsageRow }>('/aws/usage', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.usage) export const pullAwsCosts = (month?: string): Promise<{ upserted: number; totalPaise: number; unknownTags: { tag: string; costPaise: number }[] }> => apiFetch('/aws/pull', { method: 'POST', body: JSON.stringify(month !== undefined ? { month } : {}) }) export const getCompanyProfile = (): Promise> => apiFetch<{ company: Record }>('/settings/company').then((r) => r.company) export const putCompanyProfile = (body: Record): Promise> => apiFetch<{ company: Record }>('/settings/company', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.company) export const getTemplateSettings = (): Promise> => apiFetch<{ settings: Record }>('/settings/template').then((r) => r.settings) export const putTemplateSettings = ( body: { company?: Record; template?: Record; titles?: Record }, ): Promise> => apiFetch<{ settings: Record }>('/settings/template', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.settings) export const uploadTemplateLogo = (dataUri: string): Promise => apiFetch<{ logo: string }>('/settings/template/logo', { method: 'POST', body: JSON.stringify({ dataUri }) }).then((r) => r.logo) export interface ScheduleRow { id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null dayOffsets: string; subject: string | null; body: string | null } export interface ReminderSettings { overdueDays: number; renewalDays: number; schedules: ScheduleRow[]; total: number } export const getReminderSettings = (): Promise => apiFetch('/settings/reminders') export const putReminderSettings = (body: { overdueDays?: number; renewalDays?: number }): Promise => apiFetch('/settings/reminders', { method: 'PUT', body: JSON.stringify(body) }) export const createSchedule = (body: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): Promise => apiFetch<{ schedule: ScheduleRow }>('/settings/schedules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.schedule) export const getSharingSettings = (): Promise<{ defaultExpiryDays: number | null }> => apiFetch('/settings/sharing') export const putSharingSettings = (defaultExpiryDays: number | null): Promise => apiFetch('/settings/sharing', { method: 'PUT', body: JSON.stringify({ defaultExpiryDays }) }) // ---------- module → client roster (spec §10) ---------- export interface ModuleClientRow { /** client_module id — handle for the roster's inline edit/unassign (WS-G). */ cmId: string clientId: string; clientName: string; clientCode: string status: ClientModuleStatus; kind: Kind; edition: string nextRenewal: string | null; pricePaise: number | null } export interface ModuleClientsPage { clients: ModuleClientRow[]; total: number; page: number; pageSize: number; totalPricePaise: number } export const getModuleClients = (moduleId: string, page = 1): Promise => apiFetch(`/modules/${moduleId}/clients?page=${page}`) export const notifyModuleClients = ( moduleId: string, body: { subject: string; body: string }, ): Promise<{ sent: number; total: number; failed: { client: string; error: string }[] }> => apiFetch(`/modules/${moduleId}/notify`, { method: 'POST', body: JSON.stringify(body) }) /** CSV export needs the bearer header an can't carry — blob + transient anchor. */ export async function downloadModuleClientsCsv(moduleId: string, moduleCode: string): Promise { const token = localStorage.getItem(TOKEN_KEY) ?? '' const res = await fetch(`/api/modules/${moduleId}/clients.csv`, { headers: token !== '' ? { authorization: `Bearer ${token}` } : {}, }) if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string } throw new Error(body.error ?? `CSV failed: HTTP ${res.status}`) } const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `module-${moduleCode}-clients.csv` document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } // ---------- APEX import (D18 WS-B; owner-only) ---------- export interface ImportReport { clients: { staged: number; problems: number } invoices: { staged: number; problems: number; totalPaise: number } samples: { firstClients: string[]; lastInvoices: string[] } } export interface ImportStatus { report: ImportReport problemClients: { row_no: number; code: string | null; name: string | null; problems: string }[] problemInvoices: { row_no: number; client_code: string | null; doc_no: string | null; problems: string }[] } export const getImportStatus = (): Promise => apiFetch('/import/status') export const stageApexCsv = (body: { clientsCsv?: string; invoicesCsv?: string }): Promise => apiFetch<{ report: ImportReport }>('/import/stage', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.report) export const commitApexImport = (): Promise<{ clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null }> => apiFetch<{ result: { clients: number; invoices: number; seeded: { fy: string; lastSeq: number } | null } }>( '/import/commit', { method: 'POST', body: '{}' }, ).then((r) => r.result) /** The PDF route needs the bearer header, which an