You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
904 lines
47 KiB
TypeScript
904 lines
47 KiB
TypeScript
/** 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<T>(path: string, opts?: RequestInit): Promise<T> {
|
|
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 }> {
|
|
const out = await apiFetch<{ token: string; staff: { id: string; displayName: string; role: string } }>(
|
|
'/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 }
|
|
}
|
|
|
|
export interface EmailStatus { connected: boolean; address?: string; dead: boolean }
|
|
export const getEmailStatus = (): Promise<EmailStatus> => apiFetch<EmailStatus>('/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
|
|
hasDbPassword: boolean
|
|
}
|
|
|
|
export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage'
|
|
export const KIND_LABEL: Record<Kind, string> = {
|
|
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<string, string>
|
|
/** 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<string, unknown>
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
export interface Ledger {
|
|
documents: Doc[]; payments: Payment[]; advancePaise: number
|
|
modulePaid: { moduleId: string; billedPaise: number; settledPaise: 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 },
|
|
): 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)
|
|
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<string> =>
|
|
apiFetch<{ password: string }>(`/clients/${id}/reveal-db-password`, { method: 'POST', body: '{}' })
|
|
.then((r) => r.password)
|
|
export const getClient = (id: string): Promise<Client> =>
|
|
apiFetch<{ client: Client }>(`/clients/${id}`).then((r) => r.client)
|
|
export const createClient = (body: ClientWrite): Promise<Client> =>
|
|
apiFetch<{ client: Client }>('/clients', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.client)
|
|
export const patchClient = (id: string, body: ClientWrite): Promise<Client> =>
|
|
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<Client> =>
|
|
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 }))
|
|
|
|
export const getModules = (): Promise<Module[]> =>
|
|
apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules)
|
|
export const createModule = (body: Record<string, unknown>): Promise<Module> =>
|
|
apiFetch<{ module: Module }>('/modules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.module)
|
|
export const patchModule = (id: string, body: Record<string, unknown>): Promise<Module> =>
|
|
apiFetch<{ module: Module }>(`/modules/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.module)
|
|
export const getPrices = (moduleId: string): Promise<ModulePrice[]> =>
|
|
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`).then((r) => r.prices)
|
|
export const addPrice = (moduleId: string, body: Record<string, unknown>): Promise<ModulePrice[]> =>
|
|
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`, { method: 'POST', body: JSON.stringify(body) })
|
|
.then((r) => r.prices)
|
|
|
|
export const getClientModules = (clientId: string): Promise<ClientModule[]> =>
|
|
apiFetch<{ clientModules: ClientModule[] }>(`/clients/${clientId}/modules`).then((r) => r.clientModules)
|
|
export const assignClientModule = (clientId: string, body: Record<string, unknown>): Promise<ClientModule> =>
|
|
apiFetch<{ clientModule: ClientModule }>(`/clients/${clientId}/modules`, { method: 'POST', body: JSON.stringify(body) })
|
|
.then((r) => r.clientModule)
|
|
export const patchClientModule = (id: string, body: ClientModulePatch): Promise<ClientModule> =>
|
|
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<string> =>
|
|
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<string, string>): Promise<ClientModule> =>
|
|
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<ClientModule> =>
|
|
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<string> =>
|
|
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<Milestone[]> =>
|
|
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<Milestone[]> =>
|
|
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<Milestone[]> =>
|
|
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<MilestoneBoardRow[]> =>
|
|
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<PendingProject[]> =>
|
|
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<string, number>; kinds: string[]
|
|
}
|
|
|
|
/** 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
|
|
q?: string; page?: number; pageSize?: number
|
|
} = {},
|
|
): Promise<TicketsPage> => {
|
|
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.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<TicketsPage>(`/tickets${q === '' ? '' : `?${q}`}`)
|
|
}
|
|
export const createTicket = (body: {
|
|
clientId: string; branchId?: string; moduleCode?: string; kind?: string
|
|
description?: string; onlineOffline?: string; assignedTo?: string
|
|
}): Promise<Ticket> =>
|
|
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<Ticket> =>
|
|
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<Branch[]> =>
|
|
apiFetch<{ branches: Branch[] }>(`/clients/${clientId}/branches`).then((r) => r.branches)
|
|
export const createBranch = (clientId: string, body: { name: string; code?: string }): Promise<Branch> =>
|
|
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<Branch> =>
|
|
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<DocumentsPage> => {
|
|
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<DocumentsPage>(`/documents${q === '' ? '' : `?${q}`}`)
|
|
}
|
|
export const createDocument = (body: Record<string, unknown>): Promise<Doc> =>
|
|
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<string, unknown>): Promise<Doc> =>
|
|
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<Payment> =>
|
|
apiFetch<{ payment: Payment }>('/payments', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.payment)
|
|
export const getLedger = (clientId: string): Promise<Ledger> =>
|
|
apiFetch<Ledger>(`/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
|
|
}
|
|
|
|
/** 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<Employee> =>
|
|
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<Employee> =>
|
|
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<Employee> =>
|
|
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<Employee> =>
|
|
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<void> =>
|
|
apiFetch<{ ok: boolean }>('/me/password', {
|
|
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
|
|
}).then(() => undefined)
|
|
export const deactivateEmployee = (id: string): Promise<Employee> =>
|
|
apiFetch<{ employee: Employee }>(`/employees/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee)
|
|
export const reactivateEmployee = (id: string): Promise<Employee> =>
|
|
apiFetch<{ employee: Employee }>(`/employees/${id}/reactivate`, { method: 'POST', body: '{}' }).then((r) => r.employee)
|
|
export const resetEmployeePassword = (id: string, password: string): Promise<Employee> =>
|
|
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<PipelinePage> => {
|
|
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<PipelinePage>(`/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<DashboardView> =>
|
|
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<RemindersPage> => {
|
|
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<RemindersPage>(`/reminders${q === '' ? '' : `?${q}`}`)
|
|
}
|
|
export const sendReminder = (id: string): Promise<Reminder> =>
|
|
apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder)
|
|
export const dismissReminder = (id: string): Promise<Reminder> =>
|
|
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<RecurringPlan[]> =>
|
|
apiFetch<{ plans: RecurringPlan[] }>(`/recurring?clientId=${clientId}`).then((r) => r.plans)
|
|
export const createRecurringPlan = (clientId: string, body: RecurringPlanWrite): Promise<RecurringPlan> =>
|
|
apiFetch<{ plan: RecurringPlan }>(`/clients/${clientId}/recurring`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.plan)
|
|
export const updateRecurringPlan = (id: string, body: RecurringPlanWrite): Promise<RecurringPlan> =>
|
|
apiFetch<{ plan: RecurringPlan }>(`/recurring/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.plan)
|
|
export const deactivateRecurringPlan = (id: string): Promise<RecurringPlan> =>
|
|
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<AmcContract[]> =>
|
|
apiFetch<{ contracts: AmcContract[] }>(`/clients/${clientId}/amc`).then((r) => r.contracts)
|
|
export const createAmc = (clientId: string, body: AmcWrite): Promise<AmcContract> =>
|
|
apiFetch<{ contract: AmcContract }>(`/clients/${clientId}/amc`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.contract)
|
|
export const updateAmc = (id: string, body: AmcWrite): Promise<AmcContract> =>
|
|
apiFetch<{ contract: AmcContract }>(`/amc/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.contract)
|
|
export const deactivateAmc = (id: string): Promise<AmcContract> =>
|
|
apiFetch<{ contract: AmcContract }>(`/amc/${id}/deactivate`, { method: 'POST', body: '{}' }).then((r) => r.contract)
|
|
export const generateAmcRenewalInvoice = (id: string): Promise<Doc> =>
|
|
apiFetch<{ document: Doc }>(`/amc/${id}/renewal-invoice`, { method: 'POST', body: '{}' }).then((r) => r.document)
|
|
|
|
export const getInteractionTypes = (): Promise<InteractionType[]> =>
|
|
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<Interaction[]> =>
|
|
apiFetch<{ interactions: Interaction[] }>(`/clients/${clientId}/interactions`).then((r) => r.interactions)
|
|
export const createInteraction = (clientId: string, body: InteractionWrite): Promise<Interaction> =>
|
|
apiFetch<{ interaction: Interaction }>(`/clients/${clientId}/interactions`, { method: 'POST', body: JSON.stringify(body) }).then((r) => r.interaction)
|
|
export const updateInteraction = (id: string, body: InteractionWrite): Promise<Interaction> =>
|
|
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<DuesAgingRow[]> =>
|
|
apiFetch<{ rows: DuesAgingRow[] }>('/reports/dues-aging').then((r) => r.rows)
|
|
export const getModuleRevenue = (from?: string, to?: string): Promise<ModuleRevenueRow[]> =>
|
|
apiFetch<{ rows: ModuleRevenueRow[] }>(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows)
|
|
export const getProfitability = (from?: string, to?: string): Promise<ProfitabilityRow[]> =>
|
|
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<AwsUsageRow[]> =>
|
|
apiFetch<{ usage: AwsUsageRow[] }>(`/clients/${clientId}/aws-usage`).then((r) => r.usage)
|
|
export const upsertAwsUsage = (body: Record<string, unknown>): Promise<AwsUsageRow> =>
|
|
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<Record<string, string>> =>
|
|
apiFetch<{ company: Record<string, string> }>('/settings/company').then((r) => r.company)
|
|
export const putCompanyProfile = (body: Record<string, string>): Promise<Record<string, string>> =>
|
|
apiFetch<{ company: Record<string, string> }>('/settings/company', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.company)
|
|
|
|
export const getTemplateSettings = (): Promise<Record<string, string>> =>
|
|
apiFetch<{ settings: Record<string, string> }>('/settings/template').then((r) => r.settings)
|
|
export const putTemplateSettings = (
|
|
body: { company?: Record<string, string>; template?: Record<string, string>; titles?: Record<string, string> },
|
|
): Promise<Record<string, string>> =>
|
|
apiFetch<{ settings: Record<string, string> }>('/settings/template', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.settings)
|
|
export const uploadTemplateLogo = (dataUri: string): Promise<string> =>
|
|
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<ReminderSettings> => apiFetch('/settings/reminders')
|
|
export const putReminderSettings = (body: { overdueDays?: number; renewalDays?: number }): Promise<void> =>
|
|
apiFetch('/settings/reminders', { method: 'PUT', body: JSON.stringify(body) })
|
|
export const createSchedule = (body: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): Promise<ScheduleRow> =>
|
|
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<void> =>
|
|
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<ModuleClientsPage> =>
|
|
apiFetch<ModuleClientsPage & { ok: boolean }>(`/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 <a href> can't carry — blob + transient anchor. */
|
|
export async function downloadModuleClientsCsv(moduleId: string, moduleCode: string): Promise<void> {
|
|
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<ImportStatus> => apiFetch<ImportStatus>('/import/status')
|
|
export const stageApexCsv = (body: { clientsCsv?: string; invoicesCsv?: string }): Promise<ImportReport> =>
|
|
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 <iframe src> can't carry — fetch a blob instead. */
|
|
export async function fetchPdfBlob(documentId: string): Promise<Blob> {
|
|
const token = localStorage.getItem(TOKEN_KEY) ?? ''
|
|
const res = await fetch(`/api/documents/${documentId}/pdf`, {
|
|
headers: token !== '' ? { authorization: `Bearer ${token}` } : {},
|
|
})
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
throw new Error(body.error ?? `PDF failed: HTTP ${res.status}`)
|
|
}
|
|
return res.blob()
|
|
}
|
|
|
|
/**
|
|
* Download the document PDF as a file. Hits `?download=1` (attachment disposition) but,
|
|
* because the route needs the bearer header an <a href> can't carry, fetches a blob and
|
|
* saves it via a transient object-URL anchor named after the doc-no (slugged, `/` → `-`).
|
|
*/
|
|
export async function downloadDocumentPdf(documentId: string, docNo: string | null): Promise<void> {
|
|
const token = localStorage.getItem(TOKEN_KEY) ?? ''
|
|
const res = await fetch(`/api/documents/${documentId}/pdf?download=1`, {
|
|
headers: token !== '' ? { authorization: `Bearer ${token}` } : {},
|
|
})
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
throw new Error(body.error ?? `PDF failed: HTTP ${res.status}`)
|
|
}
|
|
const blob = await res.blob()
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${(docNo ?? 'draft').replaceAll('/', '-')}.pdf`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
export interface DocTotals {
|
|
taxablePaise: number; cgstPaise: number; sgstPaise: number
|
|
igstPaise: number; roundOffPaise: number; payablePaise: number
|
|
}
|
|
export interface DocPreview { html: string; totals: DocTotals; warnings: string[] }
|
|
|
|
/**
|
|
* Dedicated preview POST (mirrors fetchPdfBlob): carries the bearer token and the
|
|
* AbortSignal, and — unlike apiFetch — lets an aborted fetch reject as AbortError
|
|
* so LivePreview can silently drop superseded requests instead of surfacing a
|
|
* spurious "server unreachable".
|
|
*/
|
|
async function postPreview<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
|
const token = localStorage.getItem(TOKEN_KEY) ?? ''
|
|
const res = await fetch(`/api${path}`, {
|
|
method: 'POST', signal,
|
|
headers: { 'content-type': 'application/json', ...(token !== '' ? { authorization: `Bearer ${token}` } : {}) },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const json = (await res.json().catch(() => ({}))) as T & { error?: string }
|
|
if (!res.ok) throw new Error((json as { error?: string }).error ?? `Preview failed: HTTP ${res.status}`)
|
|
return json
|
|
}
|
|
|
|
export const previewDocument = (body: unknown, signal?: AbortSignal): Promise<DocPreview> =>
|
|
postPreview<DocPreview>('/documents/preview', body, signal)
|
|
export const previewSample = (body: unknown, signal?: AbortSignal): Promise<{ html: string }> =>
|
|
postPreview<{ html: string }>('/previews/sample', body, signal)
|
|
export const getReminderPreview = (id: string): Promise<{ subject: string; body: string }> =>
|
|
apiFetch<{ subject: string; body: string }>(`/reminders/${id}/preview`)
|
|
|
|
// D27: renewals command center.
|
|
export interface RenewalRow {
|
|
kind: 'module' | 'amc'
|
|
id: string; clientId: string; clientName: string
|
|
label: string; dueOn: string; daysUntil: number; amountPaise: number | null
|
|
}
|
|
export const getRenewals = (from?: string, to?: string): Promise<RenewalRow[]> => {
|
|
const p = new URLSearchParams()
|
|
if (from !== undefined) p.set('from', from)
|
|
if (to !== undefined) p.set('to', to)
|
|
const qs = p.toString()
|
|
return apiFetch<{ rows: RenewalRow[] }>(`/renewals${qs !== '' ? `?${qs}` : ''}`).then((r) => r.rows)
|
|
}
|
|
export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> =>
|
|
apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document)
|