diff --git a/AGENTS.md b/AGENTS.md index ca6dc62..7fb1f17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,9 @@ Instructions for AI coding agents and developers. ## What this is -The **frontend** SDK for the MGPL Diagnostics backend (`createMgplApi()` from invoice-kit, or -`createReportApi()` from report-kit). It is **headless and has zero dependencies**: a typed `fetch` client, +The **frontend** SDK for the MGPL Diagnostics backend: the GraphQL API of **mgpl-diagnostics/api** (one +endpoint, `POST /graphql`, for everything) and the HTTP API of `createMgplApi()` (invoice-kit) / +`createReportApi()` (report-kit). It is **headless and has zero dependencies**: a typed `fetch` client, browser helpers, and optional **unstyled** React hooks. All rendering (reports, invoices, PDFs) happens on the backend. Apps build their own UI/UX on top. @@ -17,8 +18,12 @@ the backend. Apps build their own UI/UX on top. 3. **The contract is mirrored, not imported.** `src/types.ts` mirrors report-kit's `src/shared/api.ts` / `types.ts` and invoice-kit's `api.ts`. Never import from the backend kits (that would pull the PDF engine into browsers). report-studio's `contract-check.ts` type-checks both sides; update them together. -4. **Routes** are in `ROUTES` in `src/index.ts` and must match report-kit `src/shared/api.ts` + invoice-kit `INVOICE_ROUTES`. -5. `src/react.tsx` gets a `"use client"` banner from tsup (see `tsup.config.ts`); the main entry must not, +4. **GraphQL operations are plain strings** in `src/graphql/operations.ts` (no GraphQL library). Their + selections match `src/graphql/types.ts`. mgpl-diagnostics/api validates every operation against its + schema and checks that every root field has one; when the API adds or renames a field, update + `operations.ts`, `types.ts` and `client.ts` here, release, then bump the client ref in api's `kits.json`. +5. **Routes** are in `ROUTES` in `src/index.ts` and must match report-kit `src/shared/api.ts` + invoice-kit `INVOICE_ROUTES`. +6. `src/react.tsx` gets a `"use client"` banner from tsup (see `tsup.config.ts`); the main entry must not, so it stays usable from Server Components, workers and Node scripts. ## Map @@ -26,6 +31,12 @@ the backend. Apps build their own UI/UX on top. ``` src/types.ts contract types (Patient, ReportRequest, TemplateCatalog, InvoiceInput, InvoiceTotals, …) src/index.ts createMgplClient (reports.*, invoices.*, health, url), MgplApiError, ROUTES, downloadBlob/openBlob/printBlob +src/graphql/ + transport.ts createTransport (POST, Bearer token, onToken), MgplGraphQLError (code from extensions.code) + operations.ts FRAGMENTS + OPERATIONS (one per Query/Mutation field), withFragments + client.ts createMgplGraphQLClient (auth, users, organization, catalog, patients, … documents), documentBlob, csvBlob + types.ts the API's GraphQL types as selected by operations.ts, plus inputs + index.ts the "/graphql" entry (types flat); the main entry re-exports the client, types under `Gql` src/react.tsx useAsync, useTemplates, useReportHtml, useInvoiceHtml, useInvoiceTotals, usePdfUrl, HtmlFrame ``` diff --git a/README.md b/README.md index 004b81b..81fb419 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Your UI, your design system, your UX. The backend renders the reports and invoic [![React](https://img.shields.io/badge/React%20hooks-optional-C2410C?logo=react&logoColor=white)](#react-optional) [![License](https://img.shields.io/badge/license-All%20rights%20reserved-E9A23B)](LICENSE) -[Quick start](#quick-start) · [SDK](#the-sdk) · [React](#react-optional) · [Errors & auth](#errors-and-auth) · [Docs](docs/api.md) +[Quick start](#quick-start) · [GraphQL](#graphql-the-whole-backend) · [SDK](#the-sdk) · [React](#react-optional) · [Errors & auth](#errors-and-auth) · [Docs](docs/api.md) @@ -59,7 +59,59 @@ const pdf = await mgpl.reports.pdf({ downloadBlob(pdf, "MGPL1025.pdf"); ``` -## The SDK +## GraphQL: the whole backend + +[mgpl-diagnostics/api](https://github.com/mgpl-diagnostics/api) serves everything (sign-in, patients, +bookings, samples, results, billing, books, GST, receivables, settlements, cash and bank, documents) at +**one endpoint, `POST /graphql`**. `createMgplGraphQLClient` has a typed method for each of its 138 +operations, with no GraphQL library: the operations are plain strings, validated against the server's +schema by the api's contract test. + +```mermaid +flowchart LR + UI["Your UI"] --> G["createMgplGraphQLClient()"] + G -- "POST /graphql
Authorization: Bearer token" --> API["mgpl-diagnostics/api"] + API --> K["auth · db · catalog · report · invoice · tally · finance kits"] +``` + +```ts +import { createMgplGraphQLClient, documentBlob, openBlob, MgplGraphQLError } from "@mgpl-diagnostics/client"; + +const mgpl = createMgplGraphQLClient({ + url: "https://api.your-lab.in/graphql", + token: sessionStorage.getItem("mgpl-token"), // optional starting token + onToken: (t) => (t ? sessionStorage.setItem("mgpl-token", t) : sessionStorage.removeItem("mgpl-token")), +}); + +await mgpl.auth.login({ email: "desk@your-lab.in", password }); // keeps the token +const [hit] = await mgpl.catalog.search("cbc"); +const patient = await mgpl.patients.register({ branchId, name: "Kavita Sharma", sex: "female", ageYears: 32 }); +const { booking } = await mgpl.bookings.create({ branchId, patientId: patient.id, tests: [{ testCode: hit!.test.code! }] }); +const invoice = await mgpl.invoices.issue({ + branchId, patientId: patient.id, bookingId: booking.id, + items: [{ code: "CBC" }], // name and MRP from the catalog + payments: [{ amountPaise: 30000, mode: "upi" }], +}); +openBlob(documentBlob(await mgpl.documents.invoice(invoice.id))); // the PDF arrives as base64 + +try { + await mgpl.periods.setLockDate({ upTo: "2026-09-30" }); +} catch (e) { + if (e instanceof MgplGraphQLError && e.code === "FORBIDDEN") alert("Ask an accountant"); +} +``` + +Namespaces: `auth`, `users`, `organization`, `catalog`, `patients`, `doctors`, `bookings`, `samples`, +`reports`, `invoices`, `books`, `gst`, `receivables`, `settlements`, `cash`, `bank`, `periods`, `exports`, +`documents`, plus `request(query, variables)` for a custom selection. Money is integer paise; days are +`"YYYY-MM-DD"`. Full list: [docs/graphql.md](docs/graphql.md). The types are flat in +`@mgpl-diagnostics/client/graphql`, or under the `Gql` namespace in the main entry. + +## The SDK (report and invoice HTTP API) + +The REST client below talks to `createMgplApi()` (invoice-kit) or `createReportApi()` (report-kit) when an +app mounts them directly, as report-studio does. New apps should use the GraphQL client above. + | Call | Backend route | Returns | |---|---|---| @@ -118,7 +170,7 @@ export function Preview({ request }) { `HtmlFrame` isolates the document (its CSS can't touch yours), disables scripts, sizes itself to the content and exposes `print()`. -## Errors and auth +## Errors and auth (HTTP API) ```ts import { createMgplClient, MgplApiError } from "@mgpl-diagnostics/client"; @@ -143,11 +195,12 @@ For a backend on another origin, enable CORS there: `createMgplApi({ cors: "http ```bash npm install npm run typecheck -npm run build # dist/index.js + dist/react.js (the React entry is marked "use client") +npm run build # dist/index.js, dist/graphql.js, dist/react.js (the React entry is marked "use client") ``` -End-to-end tests of the SDK against the real backend live in invoice-kit (`npm run test:api`). -Docs: [docs/api.md](docs/api.md) · [AGENTS.md](AGENTS.md) · AI index [llms.txt](llms.txt) / [llms-full.txt](llms-full.txt). +End-to-end tests of the HTTP SDK against the real backend live in invoice-kit (`npm run test:api`). The +GraphQL operations are validated against the server's schema by mgpl-diagnostics/api (`npm test`). +Docs: [docs/graphql.md](docs/graphql.md) · [docs/api.md](docs/api.md) · [AGENTS.md](AGENTS.md) · AI index [llms.txt](llms.txt) / [llms-full.txt](llms-full.txt). ## Part of MGPL Diagnostics @@ -155,6 +208,7 @@ Docs: [docs/api.md](docs/api.md) · [AGENTS.md](AGENTS.md) · AI index [llms.txt |---|---|---| | [report-kit](https://github.com/mgpl-diagnostics/report-kit) | backend | reports, letterhead, templates | | [invoice-kit](https://github.com/mgpl-diagnostics/invoice-kit) | backend | patient invoices + combined API | +| [api](https://github.com/mgpl-diagnostics/api) | backend | the GraphQL server: one endpoint for everything | | **client** | frontend | headless SDK, zero dependencies | | [report-studio](https://github.com/mgpl-diagnostics/report-studio) | app | Next.js reference app | diff --git a/docs/api.md b/docs/api.md index ff8e745..85476ce 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,5 +1,8 @@ # API reference: @mgpl-diagnostics/client +This page covers the HTTP client (`createMgplClient`) for report-kit / invoice-kit's API. The GraphQL +client for mgpl-diagnostics/api is in [graphql.md](graphql.md). + ## createMgplClient(options?) ```ts diff --git a/docs/graphql.md b/docs/graphql.md new file mode 100644 index 0000000..2e52ec5 --- /dev/null +++ b/docs/graphql.md @@ -0,0 +1,320 @@ +# GraphQL client: @mgpl-diagnostics/client + +`createMgplGraphQLClient` talks to **mgpl-diagnostics/api**, the backend that serves everything at one +endpoint (`POST /graphql`). It has no dependencies: every operation is a plain string in +`src/graphql/operations.ts`, and the api's test suite validates each one against the server's schema. + +```mermaid +sequenceDiagram + participant App as Your app + participant C as createMgplGraphQLClient + participant API as api (POST /graphql) + App->>C: auth.login({ email, password }) + C->>API: mutation Login + API-->>C: { token, expiresAt, user } + C->>C: setToken(token), onToken(token) + App->>C: invoices.issue({...}) + C->>API: mutation IssueInvoice (Authorization: Bearer token) + API-->>C: data.issueInvoice + C-->>App: Invoice +``` + +## Options + +```ts +createMgplGraphQLClient({ + url?: string; // default "/graphql" + fetch?: typeof fetch; // tests, SSR, retry wrappers + token?: string | null; // starting session token + onToken?: (token: string | null) => void; // persist it where you want (or nowhere) + headers?: HeadersInit | (() => HeadersInit | Promise); + credentials?: RequestCredentials; +}); +``` + +The client keeps the token in memory. `auth.login` sets it, `auth.logout` ends the session on the server +and clears it, and `setToken` / `getToken` let the app manage it directly. + +## Conventions + +- **Money is integer paise** (`totalPaise: 35400` is Rs 354.00). Catalog `mrp` and `priceList().rate` are rupees. +- **DateTime** is an ISO string; **Day** is `"YYYY-MM-DD"` in IST. The financial year runs April to March (`"2026-27"`). +- **Lists** take `limit` / `offset`; the server caps `limit` at 200. +- **Documents** come back as `{ format, fileName, mimeType, byteLength, base64, html }`: a PDF in `base64`, + HTML (self-contained, fonts inlined) in `html`. `documentBlob(doc)` turns either into a Blob for + `downloadBlob`, `openBlob` or `printBlob`. CSV exports come as `{ fileName, mimeType, content }`; use `csvBlob(file)`. +- The organisation and the acting user always come from the session. No method takes an `orgId`. + +## Errors + +Every failure throws `MgplGraphQLError` with a stable `code`: + +| code | Meaning | +|---|---| +| `UNAUTHENTICATED` | No token, or it expired. Sign in again. | +| `FORBIDDEN` | The user lacks the permission (at that branch). | +| `NOT_FOUND` | The id does not exist in this organisation. | +| `BAD_USER_INPUT` | Validation failed; `message` says what. | +| `CONFLICT` | Duplicate or wrong state (e.g. releasing an unverified report). | +| `PERIOD_LOCKED` | The date is on or before a lock date. | +| `CREDIT_LIMIT_EXCEEDED` | A customer invoice would exceed the credit limit. | +| `INVALID_CREDENTIALS`, `ACCOUNT_DISABLED`, `INVITE_PENDING`, `ORG_REQUIRED`, `RATE_LIMITED` (with `extensions.retryAfterMs`), `WEAK_PASSWORD`, `INVALID_TOKEN` | auth-kit's codes, unchanged. | +| `NETWORK_ERROR` / `INTERNAL_SERVER_ERROR` | No response, or a server fault (details are masked). | + +`error.errors` holds every GraphQL error, `error.status` the HTTP status, `error.data` any partial data. + +## Custom selections + +The typed methods select a fixed set of fields. For anything else, send your own document: + +```ts +const data = await mgpl.request<{ patient: { name: string; invoices: { invoiceNo: string; duePaise: number }[] } }>( + `query ($id: ID!) { patient(id: $id) { name invoices(limit: 5) { invoiceNo duePaise } } }`, + { id }, +); +``` + +`OPERATIONS[name].document` and `FRAGMENTS` are exported too, for GraphQL tools that want the strings. + +## Methods + +Each method runs one operation and returns its root field. Permissions are listed in the api's +docs (`docs/04-api-reference.md`). + +### client (root) + +| Method | GraphQL | +|---|---| +| `health` | query `health` | +| `ping` | mutation `ping` | + +### auth + +| Method | GraphQL | +|---|---| +| `login` | mutation `login` | +| `logout` | mutation `logout` | +| `viewer` | query `viewer` | +| `can` | query `viewer { can }` | +| `sessions` | query `mySessions` | +| `logoutOtherSessions` | mutation `logoutOtherSessions` | +| `revokeSession` | mutation `revokeSession` | +| `changePassword` | mutation `changePassword` | +| `requestPasswordReset` | mutation `requestPasswordReset` | +| `resetPassword` | mutation `resetPassword` | + +### users + +| Method | GraphQL | +|---|---| +| `list` | query `users` | +| `get` | query `user` | +| `create` | mutation `createUser` | +| `setRoles` | mutation `setUserRoles` | +| `disable` | mutation `disableUser` | +| `enable` | mutation `enableUser` | +| `setPassword` | mutation `setUserPassword` | +| `roles` | query `roles` | +| `upsertRole` | mutation `upsertRole` | +| `permissions` | query `permissions` | + +### organization + +| Method | GraphQL | +|---|---| +| `get` | query `organization` | +| `update` | mutation `updateOrganization` | +| `branches` | query `branches` | +| `branch` | query `branch` | +| `createBranch` | mutation `createBranch` | +| `updateBranch` | mutation `updateBranch` | +| `auditLog` | query `auditLog` | + +### catalog + +| Method | GraphQL | +|---|---| +| `search` | query `catalogSearch` | +| `test` | query `catalogTest` | +| `tests` | query `catalogTests` | +| `departments` | query `catalogDepartments` | +| `sampleTypes` | query `catalogSampleTypes` | +| `priceQuote` | query `priceQuote` | + +### patients + +| Method | GraphQL | +|---|---| +| `search` | query `patients` | +| `get` | query `patient` | +| `register` | mutation `registerPatient` | +| `update` | mutation `updatePatient` | + +### doctors + +| Method | GraphQL | +|---|---| +| `list` | query `doctors` | +| `create` | mutation `createDoctor` | +| `update` | mutation `updateDoctor` | + +### bookings + +| Method | GraphQL | +|---|---| +| `list` | query `bookings` | +| `get` | query `booking` | +| `create` | mutation `createBooking` | +| `cancel` | mutation `cancelBooking` | + +### samples + +| Method | GraphQL | +|---|---| +| `get` | query `sample` | +| `byBarcode` | query `sampleByBarcode` | +| `collect` | mutation `collectSample` | +| `receive` | mutation `receiveSample` | +| `reject` | mutation `rejectSample` | + +### reports + +| Method | GraphQL | +|---|---| +| `get` | query `report` | +| `create` | mutation `createReport` | +| `saveResults` | mutation `saveResults` | +| `verify` | mutation `verifyReport` | +| `release` | mutation `releaseReport` | +| `amend` | mutation `amendReport` | + +### invoices + +| Method | GraphQL | +|---|---| +| `list` | query `invoices` | +| `get` | query `invoice` | +| `issue` | mutation `issueInvoice` | +| `recordPayment` | mutation `recordPayment` | +| `refund` | mutation `refundPayment` | +| `cancel` | mutation `cancelInvoice` | + +### books + +| Method | GraphQL | +|---|---| +| `groups` | query `accountGroups` | +| `ledgers` | query `ledgers` | +| `ledger` | query `ledger` | +| `voucher` | query `voucher` | +| `ledgerStatement` | query `ledgerStatement` | +| `cashBook` | query `cashBook` | +| `trialBalance` | query `trialBalance` | +| `profitAndLoss` | query `profitAndLoss` | +| `balanceSheet` | query `balanceSheet` | +| `dayBook` | query `dayBook` | +| `outstandingReceivables` | query `outstandingReceivables` | +| `gstSummary` | query `gstSummary` | +| `setupChartOfAccounts` | mutation `setupChartOfAccounts` | +| `createLedger` | mutation `createLedger` | +| `postVoucher` | mutation `postVoucher` | +| `cancelVoucher` | mutation `cancelVoucher` | + +### gst + +| Method | GraphQL | +|---|---| +| `taxRules` | query `taxRules` | +| `addTaxRule` | mutation `addTaxRule` | +| `seedTaxRules` | mutation `seedTaxRules` | +| `config` | query `financeConfig` | +| `verifyWithCA` | query `verifyWithCA` | +| `gstr1` | query `gstr1` | +| `gstr3b` | query `gstr3b` | + +### receivables + +| Method | GraphQL | +|---|---| +| `customers` | query `customers` | +| `customer` | query `customer` | +| `createCustomer` | mutation `createCustomer` | +| `updateCustomer` | mutation `updateCustomer` | +| `issueInvoice` | mutation `issueCustomerInvoice` | +| `statement` | query `customerStatement` | +| `summary` | query `receivablesSummary` | +| `creditNotes` | query `creditNotes` | +| `creditNote` | query `creditNote` | +| `issueCreditNote` | mutation `issueCreditNote` | + +### settlements + +| Method | GraphQL | +|---|---| +| `partners` | query `partners` | +| `partner` | query `partner` | +| `createPartner` | mutation `createPartner` | +| `updatePartner` | mutation `updatePartner` | +| `addAgreement` | mutation `addAgreement` | +| `preview` | query `settlementPreview` | +| `list` | query `settlements` | +| `get` | query `settlement` | +| `create` | mutation `createSettlement` | +| `accrue` | mutation `accrueSettlement` | +| `pay` | mutation `paySettlement` | +| `cancel` | mutation `cancelSettlement` | + +### cash + +| Method | GraphQL | +|---|---| +| `closings` | query `cashClosings` | +| `closeDay` | mutation `closeCashDay` | + +### bank + +| Method | GraphQL | +|---|---| +| `statements` | query `bankStatements` | +| `lines` | query `bankLines` | +| `import` | mutation `importBankStatement` | +| `reconcile` | mutation `reconcileBank` | +| `match` | mutation `matchBankLine` | +| `setStatus` | mutation `setBankLineStatus` | +| `reconciliation` | query `bankReconciliation` | + +### periods + +| Method | GraphQL | +|---|---| +| `lockDates` | query `lockDates` | +| `setLockDate` | mutation `setLockDate` | +| `closeFinancialYear` | mutation `closeFinancialYear` | + +### exports + +| Method | GraphQL | +|---|---| +| `trialBalance` | query `exportTrialBalance` | +| `profitAndLoss` | query `exportProfitAndLoss` | +| `balanceSheet` | query `exportBalanceSheet` | +| `dayBook` | query `exportDayBook` | +| `ledger` | query `exportLedger` | +| `gstr1` | query `exportGstr1` | +| `gstr3b` | query `exportGstr3b` | +| `settlement` | query `exportSettlement` | + +### documents + +| Method | GraphQL | +|---|---| +| `templates` | query `reportTemplates` | +| `priceList` | query `priceList` | +| `reportSamples` | query `documentSamples` | +| `invoiceSamples` | query `invoiceSamples` | +| `renderReport` | query `renderReport` | +| `renderInvoice` | query `renderInvoice` | +| `invoiceTotals` | query `invoiceTotals` | +| `letterhead` | query `letterhead` | +| `invoice` | query `invoiceDocument` | +| `bookingReports` | query `bookingReportDocument` | diff --git a/llms-full.txt b/llms-full.txt index f0661f9..c4a5c6c 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,15 +1,21 @@ # @mgpl-diagnostics/client — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 9 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 15 files. ## Contents - README.md - AGENTS.md - docs/api.md +- docs/graphql.md - llms.txt - package.json - scripts/llms-full.mjs +- src/graphql/client.ts +- src/graphql/index.ts +- src/graphql/operations.ts +- src/graphql/transport.ts +- src/graphql/types.ts - src/index.ts - src/react.tsx - src/types.ts @@ -35,7 +41,7 @@ Your UI, your design system, your UX. The backend renders the reports and invoic [![React](https://img.shields.io/badge/React%20hooks-optional-C2410C?logo=react&logoColor=white)](#react-optional) [![License](https://img.shields.io/badge/license-All%20rights%20reserved-E9A23B)](LICENSE) -[Quick start](#quick-start) · [SDK](#the-sdk) · [React](#react-optional) · [Errors & auth](#errors-and-auth) · [Docs](docs/api.md) +[Quick start](#quick-start) · [GraphQL](#graphql-the-whole-backend) · [SDK](#the-sdk) · [React](#react-optional) · [Errors & auth](#errors-and-auth) · [Docs](docs/api.md) @@ -80,7 +86,59 @@ const pdf = await mgpl.reports.pdf({ downloadBlob(pdf, "MGPL1025.pdf"); ``` -## The SDK +## GraphQL: the whole backend + +[mgpl-diagnostics/api](https://github.com/mgpl-diagnostics/api) serves everything (sign-in, patients, +bookings, samples, results, billing, books, GST, receivables, settlements, cash and bank, documents) at +**one endpoint, `POST /graphql`**. `createMgplGraphQLClient` has a typed method for each of its 138 +operations, with no GraphQL library: the operations are plain strings, validated against the server's +schema by the api's contract test. + +```mermaid +flowchart LR + UI["Your UI"] --> G["createMgplGraphQLClient()"] + G -- "POST /graphql
Authorization: Bearer token" --> API["mgpl-diagnostics/api"] + API --> K["auth · db · catalog · report · invoice · tally · finance kits"] +``` + +```ts +import { createMgplGraphQLClient, documentBlob, openBlob, MgplGraphQLError } from "@mgpl-diagnostics/client"; + +const mgpl = createMgplGraphQLClient({ + url: "https://api.your-lab.in/graphql", + token: sessionStorage.getItem("mgpl-token"), // optional starting token + onToken: (t) => (t ? sessionStorage.setItem("mgpl-token", t) : sessionStorage.removeItem("mgpl-token")), +}); + +await mgpl.auth.login({ email: "desk@your-lab.in", password }); // keeps the token +const [hit] = await mgpl.catalog.search("cbc"); +const patient = await mgpl.patients.register({ branchId, name: "Kavita Sharma", sex: "female", ageYears: 32 }); +const { booking } = await mgpl.bookings.create({ branchId, patientId: patient.id, tests: [{ testCode: hit!.test.code! }] }); +const invoice = await mgpl.invoices.issue({ + branchId, patientId: patient.id, bookingId: booking.id, + items: [{ code: "CBC" }], // name and MRP from the catalog + payments: [{ amountPaise: 30000, mode: "upi" }], +}); +openBlob(documentBlob(await mgpl.documents.invoice(invoice.id))); // the PDF arrives as base64 + +try { + await mgpl.periods.setLockDate({ upTo: "2026-09-30" }); +} catch (e) { + if (e instanceof MgplGraphQLError && e.code === "FORBIDDEN") alert("Ask an accountant"); +} +``` + +Namespaces: `auth`, `users`, `organization`, `catalog`, `patients`, `doctors`, `bookings`, `samples`, +`reports`, `invoices`, `books`, `gst`, `receivables`, `settlements`, `cash`, `bank`, `periods`, `exports`, +`documents`, plus `request(query, variables)` for a custom selection. Money is integer paise; days are +`"YYYY-MM-DD"`. Full list: [docs/graphql.md](docs/graphql.md). The types are flat in +`@mgpl-diagnostics/client/graphql`, or under the `Gql` namespace in the main entry. + +## The SDK (report and invoice HTTP API) + +The REST client below talks to `createMgplApi()` (invoice-kit) or `createReportApi()` (report-kit) when an +app mounts them directly, as report-studio does. New apps should use the GraphQL client above. + | Call | Backend route | Returns | |---|---|---| @@ -139,7 +197,7 @@ export function Preview({ request }) { `HtmlFrame` isolates the document (its CSS can't touch yours), disables scripts, sizes itself to the content and exposes `print()`. -## Errors and auth +## Errors and auth (HTTP API) ```ts import { createMgplClient, MgplApiError } from "@mgpl-diagnostics/client"; @@ -164,11 +222,12 @@ For a backend on another origin, enable CORS there: `createMgplApi({ cors: "http ```bash npm install npm run typecheck -npm run build # dist/index.js + dist/react.js (the React entry is marked "use client") +npm run build # dist/index.js, dist/graphql.js, dist/react.js (the React entry is marked "use client") ``` -End-to-end tests of the SDK against the real backend live in invoice-kit (`npm run test:api`). -Docs: [docs/api.md](docs/api.md) · [AGENTS.md](AGENTS.md) · AI index [llms.txt](llms.txt) / [llms-full.txt](llms-full.txt). +End-to-end tests of the HTTP SDK against the real backend live in invoice-kit (`npm run test:api`). The +GraphQL operations are validated against the server's schema by mgpl-diagnostics/api (`npm test`). +Docs: [docs/graphql.md](docs/graphql.md) · [docs/api.md](docs/api.md) · [AGENTS.md](AGENTS.md) · AI index [llms.txt](llms.txt) / [llms-full.txt](llms-full.txt). ## Part of MGPL Diagnostics @@ -176,6 +235,7 @@ Docs: [docs/api.md](docs/api.md) · [AGENTS.md](AGENTS.md) · AI index [llms.txt |---|---|---| | [report-kit](https://github.com/mgpl-diagnostics/report-kit) | backend | reports, letterhead, templates | | [invoice-kit](https://github.com/mgpl-diagnostics/invoice-kit) | backend | patient invoices + combined API | +| [api](https://github.com/mgpl-diagnostics/api) | backend | the GraphQL server: one endpoint for everything | | **client** | frontend | headless SDK, zero dependencies | | [report-studio](https://github.com/mgpl-diagnostics/report-studio) | app | Next.js reference app | @@ -192,8 +252,9 @@ Instructions for AI coding agents and developers. ## What this is -The **frontend** SDK for the MGPL Diagnostics backend (`createMgplApi()` from invoice-kit, or -`createReportApi()` from report-kit). It is **headless and has zero dependencies**: a typed `fetch` client, +The **frontend** SDK for the MGPL Diagnostics backend: the GraphQL API of **mgpl-diagnostics/api** (one +endpoint, `POST /graphql`, for everything) and the HTTP API of `createMgplApi()` (invoice-kit) / +`createReportApi()` (report-kit). It is **headless and has zero dependencies**: a typed `fetch` client, browser helpers, and optional **unstyled** React hooks. All rendering (reports, invoices, PDFs) happens on the backend. Apps build their own UI/UX on top. @@ -205,8 +266,12 @@ the backend. Apps build their own UI/UX on top. 3. **The contract is mirrored, not imported.** `src/types.ts` mirrors report-kit's `src/shared/api.ts` / `types.ts` and invoice-kit's `api.ts`. Never import from the backend kits (that would pull the PDF engine into browsers). report-studio's `contract-check.ts` type-checks both sides; update them together. -4. **Routes** are in `ROUTES` in `src/index.ts` and must match report-kit `src/shared/api.ts` + invoice-kit `INVOICE_ROUTES`. -5. `src/react.tsx` gets a `"use client"` banner from tsup (see `tsup.config.ts`); the main entry must not, +4. **GraphQL operations are plain strings** in `src/graphql/operations.ts` (no GraphQL library). Their + selections match `src/graphql/types.ts`. mgpl-diagnostics/api validates every operation against its + schema and checks that every root field has one; when the API adds or renames a field, update + `operations.ts`, `types.ts` and `client.ts` here, release, then bump the client ref in api's `kits.json`. +5. **Routes** are in `ROUTES` in `src/index.ts` and must match report-kit `src/shared/api.ts` + invoice-kit `INVOICE_ROUTES`. +6. `src/react.tsx` gets a `"use client"` banner from tsup (see `tsup.config.ts`); the main entry must not, so it stays usable from Server Components, workers and Node scripts. ## Map @@ -214,6 +279,12 @@ the backend. Apps build their own UI/UX on top. ``` src/types.ts contract types (Patient, ReportRequest, TemplateCatalog, InvoiceInput, InvoiceTotals, …) src/index.ts createMgplClient (reports.*, invoices.*, health, url), MgplApiError, ROUTES, downloadBlob/openBlob/printBlob +src/graphql/ + transport.ts createTransport (POST, Bearer token, onToken), MgplGraphQLError (code from extensions.code) + operations.ts FRAGMENTS + OPERATIONS (one per Query/Mutation field), withFragments + client.ts createMgplGraphQLClient (auth, users, organization, catalog, patients, … documents), documentBlob, csvBlob + types.ts the API's GraphQL types as selected by operations.ts, plus inputs + index.ts the "/graphql" entry (types flat); the main entry re-exports the client, types under `Gql` src/react.tsx useAsync, useTemplates, useReportHtml, useInvoiceHtml, useInvoiceTotals, usePdfUrl, HtmlFrame ``` @@ -238,6 +309,9 @@ All rights reserved (see LICENSE). # API reference: @mgpl-diagnostics/client +This page covers the HTTP client (`createMgplClient`) for report-kit / invoice-kit's API. The GraphQL +client for mgpl-diagnostics/api is in [graphql.md](graphql.md). + ## createMgplClient(options?) ```ts @@ -314,6 +388,332 @@ usePdfUrl(load | null, key): { …, url } ``` +--- + +## File: docs/graphql.md + +# GraphQL client: @mgpl-diagnostics/client + +`createMgplGraphQLClient` talks to **mgpl-diagnostics/api**, the backend that serves everything at one +endpoint (`POST /graphql`). It has no dependencies: every operation is a plain string in +`src/graphql/operations.ts`, and the api's test suite validates each one against the server's schema. + +```mermaid +sequenceDiagram + participant App as Your app + participant C as createMgplGraphQLClient + participant API as api (POST /graphql) + App->>C: auth.login({ email, password }) + C->>API: mutation Login + API-->>C: { token, expiresAt, user } + C->>C: setToken(token), onToken(token) + App->>C: invoices.issue({...}) + C->>API: mutation IssueInvoice (Authorization: Bearer token) + API-->>C: data.issueInvoice + C-->>App: Invoice +``` + +## Options + +```ts +createMgplGraphQLClient({ + url?: string; // default "/graphql" + fetch?: typeof fetch; // tests, SSR, retry wrappers + token?: string | null; // starting session token + onToken?: (token: string | null) => void; // persist it where you want (or nowhere) + headers?: HeadersInit | (() => HeadersInit | Promise); + credentials?: RequestCredentials; +}); +``` + +The client keeps the token in memory. `auth.login` sets it, `auth.logout` ends the session on the server +and clears it, and `setToken` / `getToken` let the app manage it directly. + +## Conventions + +- **Money is integer paise** (`totalPaise: 35400` is Rs 354.00). Catalog `mrp` and `priceList().rate` are rupees. +- **DateTime** is an ISO string; **Day** is `"YYYY-MM-DD"` in IST. The financial year runs April to March (`"2026-27"`). +- **Lists** take `limit` / `offset`; the server caps `limit` at 200. +- **Documents** come back as `{ format, fileName, mimeType, byteLength, base64, html }`: a PDF in `base64`, + HTML (self-contained, fonts inlined) in `html`. `documentBlob(doc)` turns either into a Blob for + `downloadBlob`, `openBlob` or `printBlob`. CSV exports come as `{ fileName, mimeType, content }`; use `csvBlob(file)`. +- The organisation and the acting user always come from the session. No method takes an `orgId`. + +## Errors + +Every failure throws `MgplGraphQLError` with a stable `code`: + +| code | Meaning | +|---|---| +| `UNAUTHENTICATED` | No token, or it expired. Sign in again. | +| `FORBIDDEN` | The user lacks the permission (at that branch). | +| `NOT_FOUND` | The id does not exist in this organisation. | +| `BAD_USER_INPUT` | Validation failed; `message` says what. | +| `CONFLICT` | Duplicate or wrong state (e.g. releasing an unverified report). | +| `PERIOD_LOCKED` | The date is on or before a lock date. | +| `CREDIT_LIMIT_EXCEEDED` | A customer invoice would exceed the credit limit. | +| `INVALID_CREDENTIALS`, `ACCOUNT_DISABLED`, `INVITE_PENDING`, `ORG_REQUIRED`, `RATE_LIMITED` (with `extensions.retryAfterMs`), `WEAK_PASSWORD`, `INVALID_TOKEN` | auth-kit's codes, unchanged. | +| `NETWORK_ERROR` / `INTERNAL_SERVER_ERROR` | No response, or a server fault (details are masked). | + +`error.errors` holds every GraphQL error, `error.status` the HTTP status, `error.data` any partial data. + +## Custom selections + +The typed methods select a fixed set of fields. For anything else, send your own document: + +```ts +const data = await mgpl.request<{ patient: { name: string; invoices: { invoiceNo: string; duePaise: number }[] } }>( + `query ($id: ID!) { patient(id: $id) { name invoices(limit: 5) { invoiceNo duePaise } } }`, + { id }, +); +``` + +`OPERATIONS[name].document` and `FRAGMENTS` are exported too, for GraphQL tools that want the strings. + +## Methods + +Each method runs one operation and returns its root field. Permissions are listed in the api's +docs (`docs/04-api-reference.md`). + +### client (root) + +| Method | GraphQL | +|---|---| +| `health` | query `health` | +| `ping` | mutation `ping` | + +### auth + +| Method | GraphQL | +|---|---| +| `login` | mutation `login` | +| `logout` | mutation `logout` | +| `viewer` | query `viewer` | +| `can` | query `viewer { can }` | +| `sessions` | query `mySessions` | +| `logoutOtherSessions` | mutation `logoutOtherSessions` | +| `revokeSession` | mutation `revokeSession` | +| `changePassword` | mutation `changePassword` | +| `requestPasswordReset` | mutation `requestPasswordReset` | +| `resetPassword` | mutation `resetPassword` | + +### users + +| Method | GraphQL | +|---|---| +| `list` | query `users` | +| `get` | query `user` | +| `create` | mutation `createUser` | +| `setRoles` | mutation `setUserRoles` | +| `disable` | mutation `disableUser` | +| `enable` | mutation `enableUser` | +| `setPassword` | mutation `setUserPassword` | +| `roles` | query `roles` | +| `upsertRole` | mutation `upsertRole` | +| `permissions` | query `permissions` | + +### organization + +| Method | GraphQL | +|---|---| +| `get` | query `organization` | +| `update` | mutation `updateOrganization` | +| `branches` | query `branches` | +| `branch` | query `branch` | +| `createBranch` | mutation `createBranch` | +| `updateBranch` | mutation `updateBranch` | +| `auditLog` | query `auditLog` | + +### catalog + +| Method | GraphQL | +|---|---| +| `search` | query `catalogSearch` | +| `test` | query `catalogTest` | +| `tests` | query `catalogTests` | +| `departments` | query `catalogDepartments` | +| `sampleTypes` | query `catalogSampleTypes` | +| `priceQuote` | query `priceQuote` | + +### patients + +| Method | GraphQL | +|---|---| +| `search` | query `patients` | +| `get` | query `patient` | +| `register` | mutation `registerPatient` | +| `update` | mutation `updatePatient` | + +### doctors + +| Method | GraphQL | +|---|---| +| `list` | query `doctors` | +| `create` | mutation `createDoctor` | +| `update` | mutation `updateDoctor` | + +### bookings + +| Method | GraphQL | +|---|---| +| `list` | query `bookings` | +| `get` | query `booking` | +| `create` | mutation `createBooking` | +| `cancel` | mutation `cancelBooking` | + +### samples + +| Method | GraphQL | +|---|---| +| `get` | query `sample` | +| `byBarcode` | query `sampleByBarcode` | +| `collect` | mutation `collectSample` | +| `receive` | mutation `receiveSample` | +| `reject` | mutation `rejectSample` | + +### reports + +| Method | GraphQL | +|---|---| +| `get` | query `report` | +| `create` | mutation `createReport` | +| `saveResults` | mutation `saveResults` | +| `verify` | mutation `verifyReport` | +| `release` | mutation `releaseReport` | +| `amend` | mutation `amendReport` | + +### invoices + +| Method | GraphQL | +|---|---| +| `list` | query `invoices` | +| `get` | query `invoice` | +| `issue` | mutation `issueInvoice` | +| `recordPayment` | mutation `recordPayment` | +| `refund` | mutation `refundPayment` | +| `cancel` | mutation `cancelInvoice` | + +### books + +| Method | GraphQL | +|---|---| +| `groups` | query `accountGroups` | +| `ledgers` | query `ledgers` | +| `ledger` | query `ledger` | +| `voucher` | query `voucher` | +| `ledgerStatement` | query `ledgerStatement` | +| `cashBook` | query `cashBook` | +| `trialBalance` | query `trialBalance` | +| `profitAndLoss` | query `profitAndLoss` | +| `balanceSheet` | query `balanceSheet` | +| `dayBook` | query `dayBook` | +| `outstandingReceivables` | query `outstandingReceivables` | +| `gstSummary` | query `gstSummary` | +| `setupChartOfAccounts` | mutation `setupChartOfAccounts` | +| `createLedger` | mutation `createLedger` | +| `postVoucher` | mutation `postVoucher` | +| `cancelVoucher` | mutation `cancelVoucher` | + +### gst + +| Method | GraphQL | +|---|---| +| `taxRules` | query `taxRules` | +| `addTaxRule` | mutation `addTaxRule` | +| `seedTaxRules` | mutation `seedTaxRules` | +| `config` | query `financeConfig` | +| `verifyWithCA` | query `verifyWithCA` | +| `gstr1` | query `gstr1` | +| `gstr3b` | query `gstr3b` | + +### receivables + +| Method | GraphQL | +|---|---| +| `customers` | query `customers` | +| `customer` | query `customer` | +| `createCustomer` | mutation `createCustomer` | +| `updateCustomer` | mutation `updateCustomer` | +| `issueInvoice` | mutation `issueCustomerInvoice` | +| `statement` | query `customerStatement` | +| `summary` | query `receivablesSummary` | +| `creditNotes` | query `creditNotes` | +| `creditNote` | query `creditNote` | +| `issueCreditNote` | mutation `issueCreditNote` | + +### settlements + +| Method | GraphQL | +|---|---| +| `partners` | query `partners` | +| `partner` | query `partner` | +| `createPartner` | mutation `createPartner` | +| `updatePartner` | mutation `updatePartner` | +| `addAgreement` | mutation `addAgreement` | +| `preview` | query `settlementPreview` | +| `list` | query `settlements` | +| `get` | query `settlement` | +| `create` | mutation `createSettlement` | +| `accrue` | mutation `accrueSettlement` | +| `pay` | mutation `paySettlement` | +| `cancel` | mutation `cancelSettlement` | + +### cash + +| Method | GraphQL | +|---|---| +| `closings` | query `cashClosings` | +| `closeDay` | mutation `closeCashDay` | + +### bank + +| Method | GraphQL | +|---|---| +| `statements` | query `bankStatements` | +| `lines` | query `bankLines` | +| `import` | mutation `importBankStatement` | +| `reconcile` | mutation `reconcileBank` | +| `match` | mutation `matchBankLine` | +| `setStatus` | mutation `setBankLineStatus` | +| `reconciliation` | query `bankReconciliation` | + +### periods + +| Method | GraphQL | +|---|---| +| `lockDates` | query `lockDates` | +| `setLockDate` | mutation `setLockDate` | +| `closeFinancialYear` | mutation `closeFinancialYear` | + +### exports + +| Method | GraphQL | +|---|---| +| `trialBalance` | query `exportTrialBalance` | +| `profitAndLoss` | query `exportProfitAndLoss` | +| `balanceSheet` | query `exportBalanceSheet` | +| `dayBook` | query `exportDayBook` | +| `ledger` | query `exportLedger` | +| `gstr1` | query `exportGstr1` | +| `gstr3b` | query `exportGstr3b` | +| `settlement` | query `exportSettlement` | + +### documents + +| Method | GraphQL | +|---|---| +| `templates` | query `reportTemplates` | +| `priceList` | query `priceList` | +| `reportSamples` | query `documentSamples` | +| `invoiceSamples` | query `invoiceSamples` | +| `renderReport` | query `renderReport` | +| `renderInvoice` | query `renderInvoice` | +| `invoiceTotals` | query `invoiceTotals` | +| `letterhead` | query `letterhead` | +| `invoice` | query `invoiceDocument` | +| `bookingReports` | query `bookingReportDocument` | + + --- ## File: llms.txt @@ -323,6 +723,7 @@ usePdfUrl(load | null, key): { …, url } > Headless, zero-dependency TypeScript SDK for browsers (or any fetch runtime) that talks to the MGPL Diagnostics backend (`createMgplApi` / `createReportApi`). It requests lab reports and invoices as PDF Blobs, HTML strings or JSON, and fetches the test catalog and price list. It has no UI and no CSS; apps build their own UI/UX. Optional unstyled React hooks live in "@mgpl-diagnostics/client/react". Key facts for agents: +- GraphQL (mgpl-diagnostics/api, one endpoint for everything): `const mgpl = createMgplGraphQLClient({ url: "/graphql", token?, onToken? })`; `await mgpl.auth.login({ email, password })` keeps the token. Namespaces: auth, users, organization, catalog, patients, doctors, bookings, samples, reports, invoices, books, gst, receivables, settlements, cash, bank, periods, exports, documents; `request(query, vars)` for custom selections. Errors throw `MgplGraphQLError` with `code`. Money in paise; documents are base64 PDFs or HTML (`documentBlob`). Types flat in "@mgpl-diagnostics/client/graphql" or under `Gql`. - `const mgpl = createMgplClient({ baseUrl: "/api/mgpl", headers?, credentials?, fetch? })`. - Reports: `mgpl.reports.templates()`, `.pdf(req)`, `.html(req)`, `.letterheadPdf()`, `.samples()`. Invoices: `mgpl.invoices.totals(invoice)`, `.pdf(req)`, `.html(req)`, `.priceList()`. - Build entry forms from `templates()`: each template lists `fields` (key, label, unit, range); send `{ patient, tests: [{ template, results: { [key]: value } }] }`. @@ -333,6 +734,7 @@ Key facts for agents: ## Docs - [README](README.md): why, quick start, SDK table, React hooks, errors and auth +- [GraphQL client](docs/graphql.md): every method, the operation it runs, errors, conventions - [API reference](docs/api.md): every method, option and type - [AGENTS.md](AGENTS.md): rules (no deps, no UI, mirrored contract) - [llms-full.txt](llms-full.txt): everything above plus the source @@ -340,6 +742,9 @@ Key facts for agents: ## Source - [src/index.ts](src/index.ts): createMgplClient, helpers +- [src/graphql/client.ts](src/graphql/client.ts): createMgplGraphQLClient, documentBlob, csvBlob +- [src/graphql/operations.ts](src/graphql/operations.ts): fragments and operations +- [src/graphql/types.ts](src/graphql/types.ts): GraphQL result and input types - [src/react.tsx](src/react.tsx): hooks and HtmlFrame - [src/types.ts](src/types.ts): contract types @@ -351,8 +756,8 @@ Key facts for agents: ```json { "name": "@mgpl-diagnostics/client", - "version": "1.0.0", - "description": "Headless browser SDK for the MGPL Diagnostics backend (reports + invoices). Zero dependencies, no UI — build your own.", + "version": "1.1.0", + "description": "Headless SDK for the MGPL Diagnostics backend: the GraphQL API (everything) and the report/invoice HTTP API. Zero dependencies, no UI \u2014 build your own.", "type": "module", "license": "SEE LICENSE IN LICENSE", "repository": "github:mgpl-diagnostics/client", @@ -364,6 +769,10 @@ Key facts for agents: "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./graphql": { + "types": "./dist/graphql.d.ts", + "import": "./dist/graphql.js" + }, "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.js" @@ -471,6 +880,1076 @@ console.log(`llms-full.txt: ${ordered.length} files, ${(out.length / 1024).toFix ```` +--- + +## File: src/graphql/client.ts + +```ts +// createMgplGraphQLClient: one typed method per operation of the backend's single GraphQL endpoint +// (mgpl-diagnostics/api, POST /graphql). Zero dependencies: operations are plain strings. +import type { InvoiceRequest, InvoiceTotals, ReportRequest } from "../types"; +import { OPERATIONS, type Operation } from "./operations"; +import { createTransport, type GraphQLClientOptions, type Variables } from "./transport"; +import type * as T from "./types"; + +type Page = { limit?: number; offset?: number }; +type Range = { from: T.DateTime; to: T.DateTime }; +type Csv = { bom?: boolean }; + +export function createMgplGraphQLClient(opts: GraphQLClientOptions = {}) { + const transport = createTransport(opts); + + /** Runs one operation and returns its root field. */ + async function run(operation: Operation, variables?: Variables, signal?: AbortSignal): Promise { + const data = await transport.request>(operation.document, variables, { signal }); + return data[operation.field] as R; + } + const O = OPERATIONS; + + return { + url: transport.url, + /** Any query or mutation, for what the typed methods don't cover (e.g. a custom selection). */ + request: transport.request, + getToken: transport.getToken, + setToken: transport.setToken, + + health: () => run(O.health), + ping: () => run(O.ping), + + auth: { + /** Signs in and keeps the session token for every later call (see `onToken` to persist it). */ + async login(input: T.LoginInput): Promise { + const result = await run(O.login, { input }); + transport.setToken(result.token); + return result; + }, + /** Ends this session on the server and forgets the token. */ + async logout(): Promise { + try { + if (transport.getToken()) await run(O.logout); + } finally { + transport.setToken(null); + } + }, + /** The signed-in user, organisation and permissions; null when the token is missing or expired. */ + viewer: () => run(O.viewer), + /** Whether the signed-in user may do `permission` (at `branchId`, when given). */ + can: async (permission: string, branchId?: T.ID) => + (await run<{ can: boolean } | null>(O.can, { permission, branchId }))?.can ?? false, + sessions: () => run(O.mySessions), + logoutOtherSessions: () => run(O.logoutOtherSessions), + revokeSession: (id: T.ID) => run(O.revokeSession, { id }), + changePassword: (currentPassword: string, newPassword: string) => + run(O.changePassword, { currentPassword, newPassword }), + /** Always resolves true; the server emails the link if the account exists. */ + requestPasswordReset: (email: string, orgCode?: string) => run(O.requestPasswordReset, { email, orgCode }), + resetPassword: (token: string, newPassword: string) => run(O.resetPassword, { token, newPassword }), + }, + + users: { + list: () => run(O.users), + get: (id: T.ID) => run(O.user, { id }), + create: (input: T.CreateUserInput) => run(O.createUser, { input }), + setRoles: (userId: T.ID, roles: T.RoleAssignmentInput[]) => run(O.setUserRoles, { userId, roles }), + disable: (userId: T.ID) => run(O.disableUser, { userId }), + enable: (userId: T.ID) => run(O.enableUser, { userId }), + setPassword: (userId: T.ID, newPassword: string) => run(O.setUserPassword, { userId, newPassword }), + roles: () => run(O.roles), + upsertRole: (input: T.RoleInput) => run(O.upsertRole, { input }), + /** Every permission name the API checks. */ + permissions: () => run(O.permissions), + }, + + organization: { + get: () => run(O.organization), + update: (input: T.OrganizationInput) => run(O.updateOrganization, { input }), + branches: () => run(O.branches), + branch: (id: T.ID) => run(O.branch, { id }), + createBranch: (input: T.BranchInput) => run(O.createBranch, { input }), + updateBranch: (id: T.ID, input: T.BranchPatch) => run(O.updateBranch, { id, input }), + auditLog: (filter: { entity?: string; entityId?: T.ID } & Page = {}) => run(O.auditLog, filter), + }, + + /** The test catalog. No sign-in needed. */ + catalog: { + search: (query: string, o: { department?: string; sampleType?: string; limit?: number; fuzzy?: boolean } = {}) => + run(O.catalogSearch, { query, ...o }), + /** By numeric id or code ("CBC", "LFT"); `sex` picks the reference range. */ + test: (idOrCode: number | string, sex?: T.Sex) => + run(O.catalogTest, typeof idOrCode === "number" ? { id: idOrCode, sex } : { code: idOrCode, sex }), + tests: (filter: { department?: string; sampleType?: string } & Page = {}) => run(O.catalogTests, filter), + departments: () => run(O.catalogDepartments), + sampleTypes: () => run(O.catalogSampleTypes), + priceQuote: (tests: string[]) => run(O.priceQuote, { tests }), + }, + + patients: { + /** Name, phone or UHID. */ + search: (search: string, limit?: number) => run(O.patients, { search, limit }), + get: (id: T.ID) => run(O.patient, { id }), + register: (input: T.RegisterPatientInput) => run(O.registerPatient, { input }), + update: (id: T.ID, input: T.UpdatePatientInput) => run(O.updatePatient, { id, input }), + }, + + doctors: { + list: (activeOnly?: boolean) => run(O.doctors, { activeOnly }), + create: (input: T.DoctorInput) => run(O.createDoctor, { input }), + update: (id: T.ID, input: T.DoctorPatch) => run(O.updateDoctor, { id, input }), + }, + + bookings: { + list: (filter: { branchId?: T.ID; patientId?: T.ID; status?: T.BookingStatus } & Page = {}) => + run(O.bookings, filter), + get: (id: T.ID) => run(O.booking, { id }), + /** Creates the booking and one sample per sample type. */ + create: (input: T.CreateBookingInput) => run(O.createBooking, { input }), + cancel: (id: T.ID) => run(O.cancelBooking, { id }), + }, + + samples: { + get: (id: T.ID) => run(O.sample, { id }), + byBarcode: (barcode: string) => run(O.sampleByBarcode, { barcode }), + collect: (id: T.ID) => run(O.collectSample, { id }), + receive: (id: T.ID) => run(O.receiveSample, { id }), + reject: (id: T.ID, reason: string) => run(O.rejectSample, { id, reason }), + }, + + reports: { + get: (id: T.ID) => run(O.report, { id }), + create: (bookingId: T.ID, testCode: string, testName?: string) => + run(O.createReport, { bookingId, testCode, testName }), + /** Unit, range and flag are filled from the catalog when omitted. */ + saveResults: (reportId: T.ID, results: T.ResultInput[]) => run(O.saveResults, { reportId, results }), + verify: (id: T.ID) => run(O.verifyReport, { id }), + release: (id: T.ID) => run(O.releaseReport, { id }), + amend: (id: T.ID) => run(O.amendReport, { id }), + }, + + invoices: { + list: (filter: { branchId?: T.ID; patientId?: T.ID; customerId?: T.ID; status?: T.InvoiceStatus } & Page = {}) => + run(O.invoices, filter), + get: (id: T.ID) => run(O.invoice, { id }), + /** Items can be catalog codes alone; name and MRP come from the catalog. Posts to the books. */ + issue: (input: T.IssueInvoiceInput) => run(O.issueInvoice, { input }), + recordPayment: (invoiceId: T.ID, payment: T.PaymentInput) => run(O.recordPayment, { invoiceId, payment }), + refund: (invoiceId: T.ID, payment: T.PaymentInput) => run(O.refundPayment, { invoiceId, payment }), + cancel: (id: T.ID, reason: string) => run(O.cancelInvoice, { id, reason }), + }, + + books: { + groups: () => run(O.accountGroups), + ledgers: () => run(O.ledgers), + ledger: (id: T.ID) => run(O.ledger, { id }), + voucher: (id: T.ID) => run(O.voucher, { id }), + ledgerStatement: (ledgerId: T.ID, range: Range, branchId?: T.ID) => + run(O.ledgerStatement, { ledgerId, ...range, branchId }), + cashBook: (range: Range, branchId?: T.ID) => run(O.cashBook, { ...range, branchId }), + trialBalance: (o: { asOf?: T.DateTime; branchId?: T.ID } = {}) => run(O.trialBalance, o), + profitAndLoss: (range: Range, branchId?: T.ID) => run(O.profitAndLoss, { ...range, branchId }), + balanceSheet: (asOf: T.DateTime, branchId?: T.ID) => run(O.balanceSheet, { asOf, branchId }), + dayBook: (range: Range, o: { branchId?: T.ID; type?: T.VoucherType } = {}) => run(O.dayBook, { ...range, ...o }), + outstandingReceivables: (o: { asOf?: T.DateTime; branchId?: T.ID } = {}) => run(O.outstandingReceivables, o), + gstSummary: (range: Range, branchId?: T.ID) => run(O.gstSummary, { ...range, branchId }), + /** Seeds the standard chart and finance ledgers. Safe to repeat. */ + setupChartOfAccounts: () => run(O.setupChartOfAccounts), + createLedger: (input: T.LedgerInput) => run(O.createLedger, { input }), + postVoucher: (input: T.PostVoucherInput) => run(O.postVoucher, { input }), + cancelVoucher: (id: T.ID) => run(O.cancelVoucher, { id }), + }, + + gst: { + taxRules: () => run(O.taxRules), + addTaxRule: (input: T.TaxRuleInput) => run(O.addTaxRule, { input }), + /** finance-kit's starting rules. Every rate must be verified with the lab's CA. */ + seedTaxRules: () => run(O.seedTaxRules), + config: () => run(O.financeConfig), + verifyWithCA: () => run(O.verifyWithCA), + gstr1: (range: Range, gstin?: string) => run(O.gstr1, { ...range, gstin }), + gstr3b: (range: Range, gstin?: string) => run(O.gstr3b, { ...range, gstin }), + }, + + receivables: { + customers: (activeOnly?: boolean) => run(O.customers, { activeOnly }), + customer: (id: T.ID) => run(O.customer, { id }), + createCustomer: (input: T.CustomerInput) => run(O.createCustomer, { input }), + updateCustomer: (id: T.ID, input: T.CustomerPatch) => run(O.updateCustomer, { id, input }), + /** A credit invoice to a hospital, insurer, TPA or corporate (checks the credit limit). */ + issueInvoice: (input: T.CustomerInvoiceInput) => run(O.issueCustomerInvoice, { input }), + statement: (customerId: T.ID, range: Range) => run(O.customerStatement, { customerId, ...range }), + summary: (asOf: T.DateTime) => run(O.receivablesSummary, { asOf }), + creditNotes: (filter: { customerId?: T.ID; invoiceId?: T.ID } & Page = {}) => run(O.creditNotes, filter), + creditNote: (id: T.ID) => run(O.creditNote, { id }), + issueCreditNote: (input: T.CreditNoteInput) => run(O.issueCreditNote, { input }), + }, + + settlements: { + partners: (branchId?: T.ID) => run(O.partners, { branchId }), + partner: (id: T.ID) => run(O.partner, { id }), + createPartner: (input: T.PartnerInput) => run(O.createPartner, { input }), + updatePartner: (id: T.ID, input: T.PartnerPatch) => run(O.updatePartner, { id, input }), + addAgreement: (input: T.AgreementInput) => run(O.addAgreement, { input }), + /** period: "YYYY-MM". Computes without saving. */ + preview: (partnerId: T.ID, period: string, deductions?: T.SettlementDeductionInput[]) => + run(O.settlementPreview, { partnerId, period, deductions }), + list: (filter: { branchId?: T.ID; partnerId?: T.ID; status?: T.SettlementStatus } & Page = {}) => + run(O.settlements, filter), + get: (id: T.ID) => run(O.settlement, { id }), + create: (partnerId: T.ID, period: string, deductions?: T.SettlementDeductionInput[]) => + run(O.createSettlement, { partnerId, period, deductions }), + accrue: (id: T.ID) => run(O.accrueSettlement, { id }), + pay: (input: T.PaySettlementInput) => run(O.paySettlement, { input }), + cancel: (id: T.ID) => run(O.cancelSettlement, { id }), + }, + + cash: { + closings: (branchId: T.ID, page: Page = {}) => run(O.cashClosings, { branchId, ...page }), + closeDay: (input: T.CloseCashDayInput) => run(O.closeCashDay, { input }), + }, + + bank: { + statements: (filter: { ledgerId?: T.ID } & Page = {}) => run(O.bankStatements, filter), + lines: (filter: { ledgerId?: T.ID; statementId?: T.ID; status?: T.BankLineStatus } & Page = {}) => + run(O.bankLines, filter), + import: (input: T.BankImportInput) => run(O.importBankStatement, { input }), + reconcile: (ledgerId: T.ID, statementId?: T.ID) => run(O.reconcileBank, { ledgerId, statementId }), + match: (lineId: T.ID, voucherIds: T.ID[]) => run(O.matchBankLine, { lineId, voucherIds }), + setStatus: (lineId: T.ID, status: "ignored" | "unmatched") => run(O.setBankLineStatus, { lineId, status }), + reconciliation: (ledgerId: T.ID, asOf: T.DateTime) => run(O.bankReconciliation, { ledgerId, asOf }), + }, + + periods: { + lockDates: () => run(O.lockDates), + /** upTo: "YYYY-MM-DD", or null to unlock. Without branchId it locks the whole organisation. */ + setLockDate: (input: T.LockDateInput) => run(O.setLockDate, { input }), + /** fy: "2026-27". Posts closing vouchers on 31 March and locks the year. */ + closeFinancialYear: (fy: string) => run(O.closeFinancialYear, { fy }), + }, + + /** CSV files ending with TOTAL rows. Save one with `csvBlob(file)` + downloadBlob. */ + exports: { + trialBalance: (o: { asOf?: T.DateTime; branchId?: T.ID } & Csv = {}) => run(O.exportTrialBalance, o), + profitAndLoss: (range: Range, o: { branchId?: T.ID } & Csv = {}) => run(O.exportProfitAndLoss, { ...range, ...o }), + balanceSheet: (asOf: T.DateTime, o: { branchId?: T.ID } & Csv = {}) => run(O.exportBalanceSheet, { asOf, ...o }), + dayBook: (range: Range, o: { branchId?: T.ID } & Csv = {}) => run(O.exportDayBook, { ...range, ...o }), + ledger: (ledgerId: T.ID, range: Range, o: { branchId?: T.ID } & Csv = {}) => + run(O.exportLedger, { ledgerId, ...range, ...o }), + /** One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs. */ + gstr1: (range: Range, o: { gstin?: string } & Csv = {}) => run(O.exportGstr1, { ...range, ...o }), + gstr3b: (range: Range, o: { gstin?: string } & Csv = {}) => run(O.exportGstr3b, { ...range, ...o }), + settlement: (settlementId: T.ID, o: Csv = {}) => run(O.exportSettlement, { settlementId, ...o }), + }, + + /** Reports and invoices rendered by the server (report-kit / invoice-kit). PDFs come as base64. */ + documents: { + templates: () => run(O.reportTemplates), + priceList: () => run(O.priceList), + reportSamples: () => run(O.documentSamples), + invoiceSamples: () => run(O.invoiceSamples), + /** Any report request (patient + tests | profile | report | sample), as with the REST client. */ + renderReport: (request: ReportRequest, o: T.RenderOptions = {}) => run(O.renderReport, { request, ...o }), + /** Any invoice request ({ invoice } or { sample }). */ + renderInvoice: (request: InvoiceRequest, o: Omit = {}) => + run(O.renderInvoice, { request, ...o }), + /** invoice-kit's calculation, without rendering (amounts in rupees). */ + invoiceTotals: (request: InvoiceRequest) => run(O.invoiceTotals, { request }), + letterhead: (format?: T.DocumentFormat) => run(O.letterhead, { format }), + /** A stored invoice, printed. */ + invoice: (invoiceId: T.ID, o: Omit = {}) => + run(O.invoiceDocument, { invoiceId, ...o }), + /** A booking's verified or released reports printed together (or only `reportIds`). */ + bookingReports: (bookingId: T.ID, o: Omit & { reportIds?: T.ID[] } = {}) => + run(O.bookingReportDocument, { bookingId, ...o }), + }, + }; +} + +export type MgplGraphQLClient = ReturnType; + +/** A rendered document as a Blob: PDF from base64, HTML from its markup. Use with downloadBlob/openBlob/printBlob. */ +export function documentBlob(doc: Pick): Blob { + if (doc.base64 != null) { + const bin = atob(doc.base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new Blob([bytes], { type: doc.mimeType }); + } + return new Blob([doc.html ?? ""], { type: doc.mimeType }); +} + +/** A CSV export as a Blob (UTF-8). */ +export function csvBlob(file: T.CsvFile): Blob { + return new Blob([file.content], { type: file.mimeType }); +} +``` + + +--- + +## File: src/graphql/index.ts + +```ts +// "@mgpl-diagnostics/client/graphql": the GraphQL SDK with every type flat. +// The main entry re-exports the client and helpers too, with these types under the `Gql` namespace. +export { createMgplGraphQLClient, documentBlob, csvBlob, type MgplGraphQLClient } from "./client"; +export { createTransport, MgplGraphQLError, type GraphQLClientOptions, type GraphQLErrorItem, type Transport, type Variables } from "./transport"; +export { OPERATIONS, FRAGMENTS, withFragments, type Operation, type OperationName } from "./operations"; +export type * from "./types"; +``` + + +--- + +## File: src/graphql/operations.ts + +```ts +// Every operation the client sends, as plain GraphQL strings (no runtime GraphQL library). +// The selections match src/graphql/types.ts. The API's contract test validates each one against +// the server's schema (mgpl-diagnostics/api, test/client-contract.ts), so a renamed field fails there. + +/** Reusable selections. An operation gets the fragments it spreads (and theirs) appended. */ +export const FRAGMENTS: Record = { + AddressFields: `fragment AddressFields on Address { line1 line2 city district state pincode }`, + BranchRef: `fragment BranchRef on Branch { id code name }`, + UserFields: `fragment UserFields on User { id name email phone status roles { role branchId } lastLoginAt createdAt }`, + RoleFields: `fragment RoleFields on Role { id key name permissions system }`, + SessionFields: `fragment SessionFields on Session { id current createdAt lastSeenAt expiresAt ip userAgent }`, + BranchFields: `fragment BranchFields on Branch { id code name kind phone gstin stateCode active address { ...AddressFields } }`, + OrganizationFields: `fragment OrganizationFields on Organization { + id name code legalName gstin phone email address { ...AddressFields } branches { ...BranchFields } + }`, + AuditEntryFields: `fragment AuditEntryFields on AuditEntry { id action entity entityId branchId user { id name } at }`, + + CatalogTestFields: `fragment CatalogTestFields on CatalogTest { + id name code shortCode department sampleType mrp pricePaise isProfile parameterCount + }`, + + PatientFields: `fragment PatientFields on Patient { + id uhid title name sex dob ageYears phone email address { ...AddressFields } createdAt branch { ...BranchRef } + }`, + DoctorFields: `fragment DoctorFields on Doctor { id name qualification phone clinic active }`, + SampleFields: `fragment SampleFields on Sample { id barcode sampleType status collectedAt receivedAt rejectReason }`, + ReportFields: `fragment ReportFields on Report { + id testCode testName status version verifiedAt releasedAt updatedAt results { parameter value unit flag range } + }`, + BookingFields: `fragment BookingFields on Booking { + id bookingNo status notes createdAt + patient { id uhid name } doctor { id name } branch { ...BranchRef } + tests { testCode name pricePaise sampleType } + sampleCollection { type scheduledAt } + samples { ...SampleFields } + reports { id testCode testName status } + invoice { id invoiceNo status totalPaise duePaise } + }`, + + InvoiceFields: `fragment InvoiceFields on Invoice { + id invoiceNo status placeOfSupply buyerGstin + subtotalPaise discountPaise taxPaise roundOffPaise totalPaise paidPaise creditedPaise duePaise + issuedAt cancelReason createdAt + patient { id uhid name } branch { ...BranchRef } customer { id code name } + items { code name category qty ratePaise discountPaise taxPercent hsnSac taxability amountPaise } + payments { id kind amountPaise mode reference receivedAt } + }`, + + AccountGroupFields: `fragment AccountGroupFields on AccountGroup { id name nature affectsGrossProfit parent { id name } }`, + LedgerFields: `fragment LedgerFields on Ledger { id name systemKey gstin openingPaise active group { id name nature } }`, + VoucherFields: `fragment VoucherFields on Voucher { + id type voucherNo date narration status invoiceId + entries { ledger { id name } debitPaise creditPaise invoiceId } + }`, + StatementRowFields: `fragment StatementRowFields on StatementRow { date voucherNo voucherType debitPaise creditPaise balancePaise invoiceId }`, + LedgerStatementFields: `fragment LedgerStatementFields on LedgerStatement { + ledger { id name } openingPaise closingPaise totalDebitPaise totalCreditPaise rows { ...StatementRowFields } + }`, + GroupLineFields: `fragment GroupLineFields on GroupLine { group { id name } amountPaise ledgers { ledger { id name } amountPaise } }`, + + TaxRuleFields: `fragment TaxRuleFields on TaxRule { id code category hsnSac taxability ratePercent effectiveFrom effectiveTo note }`, + RateTotalsFields: `fragment RateTotalsFields on RateTotals { ratePercent taxablePaise igstPaise cgstPaise sgstPaise cessPaise }`, + TaxTotalsFields: `fragment TaxTotalsFields on TaxTotals { taxablePaise igstPaise cgstPaise sgstPaise cessPaise }`, + HsnRowFields: `fragment HsnRowFields on HsnRow { + hsnSac ratePercent taxability qty totalValuePaise taxablePaise igstPaise cgstPaise sgstPaise cessPaise + }`, + Gstr1InvoiceFields: `fragment Gstr1InvoiceFields on Gstr1Invoice { + invoiceId invoiceNo day valuePaise placeOfSupply reverseCharge ctin items { ...RateTotalsFields } + }`, + Gstr1NoteFields: `fragment Gstr1NoteFields on Gstr1Note { + noteId noteNo day noteType invoiceNo invoiceDay valuePaise placeOfSupply ctin type items { ...RateTotalsFields } + }`, + + CustomerFields: `fragment CustomerFields on Customer { + id code name kind legalName gstin stateCode pan phone email address { ...AddressFields } + creditLimitPaise creditDays active ledger { id name } + }`, + AgeingFields: `fragment AgeingFields on Ageing { notDue d1to30 d31to60 d61to90 d90plus }`, + CreditNoteFields: `fragment CreditNoteFields on CreditNote { + id noteNo date reason status taxablePaise taxPaise cgstPaise sgstPaise igstPaise roundOffPaise totalPaise + placeOfSupply buyerGstin invoice { id invoiceNo } customer { id code name } + lines { itemIndex name code hsnSac taxability taxPercent taxablePaise taxPaise cgstPaise sgstPaise igstPaise } + }`, + + AgreementFields: `fragment AgreementFields on PartnerAgreement { + id kind shareBp rates { code sharePaise } defaultSharePaise categories effectiveFrom effectiveTo notes + }`, + PartnerFields: `fragment PartnerFields on Partner { + id code name kind legalName pan gstin stateCode phone email address { ...AddressFields } + tds { section rateBp } active branch { ...BranchRef } ledger { id name } agreements { ...AgreementFields } + }`, + SettlementLineFields: `fragment SettlementLineFields on SettlementLine { + invoiceId invoiceNo day code name netPaise sharePaise agreementId creditNoteId + }`, + SettlementFields: `fragment SettlementFields on Settlement { + id period from to status grossPaise sharePaise deductionsPaise tdsSection tdsRateBp tdsBasePaise tdsPaise netPaise + paidAt createdAt partner { id code name } branch { ...BranchRef } + lines { ...SettlementLineFields } deductions { label amountPaise ledgerId } + accrualVoucher { id voucherNo } payoutVoucher { id voucherNo } + }`, + + CashClosingFields: `fragment CashClosingFields on CashClosing { + id day bookPaise countedPaise differencePaise denominations { valuePaise count } notes createdAt + branch { ...BranchRef } voucher { id voucherNo } + }`, + BankStatementFields: `fragment BankStatementFields on BankStatement { + id fileName from to openingPaise closingPaise lineCount createdAt branch { ...BranchRef } ledger { id name } + }`, + BankLineFields: `fragment BankLineFields on BankLine { + id statementId date description reference debitPaise creditPaise balancePaise status matchKind voucherIds matchedAt + settlement { ledgerId day grossPaise feePaise } + }`, + PeriodLockFields: `fragment PeriodLockFields on PeriodLock { id scope lockedUpTo reason updatedAt branch { ...BranchRef } }`, + CsvFileFields: `fragment CsvFileFields on CsvFile { fileName mimeType content }`, + + DocumentFields: `fragment DocumentFields on Document { format fileName mimeType byteLength base64 html }`, +}; + +/** Appends every fragment the document spreads, transitively, once each. */ +export function withFragments(doc: string): string { + const seen = new Set(); + const queue = [doc]; + while (queue.length) { + for (const m of queue.pop()!.matchAll(/\.\.\.\s*([A-Z]\w*)/g)) { + const name = m[1]!; + if (seen.has(name) || !FRAGMENTS[name]) continue; + seen.add(name); + queue.push(FRAGMENTS[name]); + } + } + return [doc, ...[...seen].map((n) => FRAGMENTS[n])].join("\n"); +} + +export interface Operation { + /** The root field whose value the client returns. */ + field: string; + document: string; +} + +/** + * One root field per operation. `vars` declares the variables ("$id: ID!, $limit: Int"); each one is + * passed to the field under the same name. Omitted variables fall back to the schema's defaults. + */ +function op(kind: "query" | "mutation", field: string, vars: string, selection: string): Operation { + const names = [...vars.matchAll(/\$(\w+)\s*:/g)].map((m) => m[1]!); + const decl = vars ? `(${vars})` : ""; + const args = names.length ? `(${names.map((n) => `${n}: $${n}`).join(", ")})` : ""; + const name = field[0]!.toUpperCase() + field.slice(1); + const body = selection ? ` { ${selection} }` : ""; + return { field, document: withFragments(`${kind} ${name}${decl} { ${field}${args}${body} }`) }; +} +const q = (field: string, vars: string, selection = "") => op("query", field, vars, selection); +const m = (field: string, vars: string, selection = "") => op("mutation", field, vars, selection); + +const PAGE = "$limit: Int, $offset: Int"; +const RANGE = "$from: DateTime!, $to: DateTime!"; +const CSV = "$bom: Boolean"; + +export const OPERATIONS = { + // ---------------------------------------------------------------- system + health: q("health", "", "ok service version database time"), + ping: m("ping", ""), + + // ---------------------------------------------------------------- auth + viewer: q("viewer", "", "user { ...UserFields } organization { ...OrganizationFields } permissions"), + can: { + field: "viewer", + document: `query Can($permission: String!, $branchId: ID) { viewer { can(permission: $permission, branchId: $branchId) } }`, + } as Operation, + login: m("login", "$input: LoginInput!", "token expiresAt user { ...UserFields }"), + logout: m("logout", ""), + logoutOtherSessions: m("logoutOtherSessions", ""), + revokeSession: m("revokeSession", "$id: ID!"), + changePassword: m("changePassword", "$currentPassword: String!, $newPassword: String!"), + requestPasswordReset: m("requestPasswordReset", "$email: String!, $orgCode: String"), + resetPassword: m("resetPassword", "$token: String!, $newPassword: String!"), + mySessions: q("mySessions", "", "...SessionFields"), + + users: q("users", "", "...UserFields"), + user: q("user", "$id: ID!", "...UserFields"), + roles: q("roles", "", "...RoleFields"), + permissions: q("permissions", ""), + createUser: m("createUser", "$input: CreateUserInput!", "...UserFields"), + setUserRoles: m("setUserRoles", "$userId: ID!, $roles: [RoleAssignmentInput!]!", "...UserFields"), + disableUser: m("disableUser", "$userId: ID!", "...UserFields"), + enableUser: m("enableUser", "$userId: ID!", "...UserFields"), + setUserPassword: m("setUserPassword", "$userId: ID!, $newPassword: String!"), + upsertRole: m("upsertRole", "$input: RoleInput!", "...RoleFields"), + + // ---------------------------------------------------------------- organisation + organization: q("organization", "", "...OrganizationFields"), + branches: q("branches", "", "...BranchFields"), + branch: q("branch", "$id: ID!", "...BranchFields"), + auditLog: q("auditLog", `$entity: String, $entityId: ID, ${PAGE}`, "...AuditEntryFields"), + updateOrganization: m("updateOrganization", "$input: OrganizationInput!", "...OrganizationFields"), + createBranch: m("createBranch", "$input: BranchInput!", "...BranchFields"), + updateBranch: m("updateBranch", "$id: ID!, $input: BranchPatch!", "...BranchFields"), + + // ---------------------------------------------------------------- catalog (no sign-in needed) + catalogSearch: q( + "catalogSearch", + "$query: String!, $department: String, $sampleType: String, $limit: Int, $fuzzy: Boolean", + "score test { ...CatalogTestFields }", + ), + catalogTest: { + field: "catalogTest", + document: withFragments(`query CatalogTest($id: Int, $code: String, $sex: Sex) { + catalogTest(id: $id, code: $code) { + ...CatalogTestFields secondarySampleTypes + parameters { name unit method type options calculated formulaText referenceText range(sex: $sex) } + } +}`), + } as Operation, + catalogTests: q("catalogTests", `$department: String, $sampleType: String, ${PAGE}`, "...CatalogTestFields"), + catalogDepartments: q("catalogDepartments", ""), + catalogSampleTypes: q("catalogSampleTypes", ""), + priceQuote: q("priceQuote", "$tests: [String!]!", "total totalPaise unknown tests { ...CatalogTestFields }"), + + // ---------------------------------------------------------------- lab + patients: q("patients", "$search: String!, $limit: Int", "...PatientFields"), + patient: q("patient", "$id: ID!", "...PatientFields"), + registerPatient: m("registerPatient", "$input: RegisterPatientInput!", "...PatientFields"), + updatePatient: m("updatePatient", "$id: ID!, $input: UpdatePatientInput!", "...PatientFields"), + + doctors: q("doctors", "$activeOnly: Boolean", "...DoctorFields"), + createDoctor: m("createDoctor", "$input: DoctorInput!", "...DoctorFields"), + updateDoctor: m("updateDoctor", "$id: ID!, $input: DoctorPatch!", "...DoctorFields"), + + bookings: q("bookings", `$branchId: ID, $patientId: ID, $status: BookingStatus, ${PAGE}`, "...BookingFields"), + booking: q("booking", "$id: ID!", "...BookingFields"), + createBooking: m("createBooking", "$input: CreateBookingInput!", "booking { ...BookingFields } samples { ...SampleFields }"), + cancelBooking: m("cancelBooking", "$id: ID!", "...BookingFields"), + + sample: q("sample", "$id: ID!", "...SampleFields"), + sampleByBarcode: q("sampleByBarcode", "$barcode: String!", "...SampleFields"), + collectSample: m("collectSample", "$id: ID!", "...SampleFields"), + receiveSample: m("receiveSample", "$id: ID!", "...SampleFields"), + rejectSample: m("rejectSample", "$id: ID!, $reason: String!", "...SampleFields"), + + report: q("report", "$id: ID!", "...ReportFields"), + createReport: m("createReport", "$bookingId: ID!, $testCode: String!, $testName: String", "...ReportFields"), + saveResults: m("saveResults", "$reportId: ID!, $results: [ResultInput!]!", "...ReportFields"), + verifyReport: m("verifyReport", "$id: ID!", "...ReportFields"), + releaseReport: m("releaseReport", "$id: ID!", "...ReportFields"), + amendReport: m("amendReport", "$id: ID!", "...ReportFields"), + + // ---------------------------------------------------------------- billing + invoices: q( + "invoices", + `$branchId: ID, $patientId: ID, $customerId: ID, $status: InvoiceStatus, ${PAGE}`, + "...InvoiceFields", + ), + invoice: q("invoice", "$id: ID!", "...InvoiceFields"), + issueInvoice: m("issueInvoice", "$input: IssueInvoiceInput!", "...InvoiceFields"), + recordPayment: m("recordPayment", "$invoiceId: ID!, $payment: PaymentInput!", "...InvoiceFields"), + refundPayment: m("refundPayment", "$invoiceId: ID!, $payment: PaymentInput!", "...InvoiceFields"), + cancelInvoice: m("cancelInvoice", "$id: ID!, $reason: String!", "...InvoiceFields"), + + // ---------------------------------------------------------------- books + accountGroups: q("accountGroups", "", "...AccountGroupFields"), + ledgers: q("ledgers", "", "...LedgerFields"), + ledger: q("ledger", "$id: ID!", "...LedgerFields"), + voucher: q("voucher", "$id: ID!", "...VoucherFields"), + ledgerStatement: q("ledgerStatement", `$ledgerId: ID!, ${RANGE}, $branchId: ID`, "...LedgerStatementFields"), + cashBook: q("cashBook", `${RANGE}, $branchId: ID`, "...LedgerStatementFields"), + trialBalance: q( + "trialBalance", + "$asOf: DateTime, $branchId: ID", + "rows { ledger { id name } group { id name } primaryGroup { id name } debitPaise creditPaise } totalDebitPaise totalCreditPaise differencePaise", + ), + profitAndLoss: q( + "profitAndLoss", + `${RANGE}, $branchId: ID`, + "income { ...GroupLineFields } expenses { ...GroupLineFields } grossProfitPaise netProfitPaise", + ), + balanceSheet: q( + "balanceSheet", + "$asOf: DateTime!, $branchId: ID", + "assets { ...GroupLineFields } liabilities { ...GroupLineFields } profitPaise totalAssetsPaise totalLiabilitiesPaise differencePaise", + ), + dayBook: q("dayBook", `${RANGE}, $branchId: ID, $type: VoucherType`, "...VoucherFields"), + outstandingReceivables: q( + "outstandingReceivables", + "$asOf: DateTime, $branchId: ID", + "rows { invoice { id invoiceNo duePaise patient { name } } ageDays bucket } buckets { d0to30 d31to60 d61to90 d90plus } totalDuePaise", + ), + gstSummary: q("gstSummary", `${RANGE}, $branchId: ID`, "taxableValuePaise cgstPaise sgstPaise igstPaise totalTaxPaise"), + setupChartOfAccounts: m("setupChartOfAccounts", "", "...LedgerFields"), + createLedger: m("createLedger", "$input: LedgerInput!", "...LedgerFields"), + postVoucher: m("postVoucher", "$input: PostVoucherInput!", "...VoucherFields"), + cancelVoucher: m("cancelVoucher", "$id: ID!", "...VoucherFields"), + + // ---------------------------------------------------------------- GST + taxRules: q("taxRules", "", "...TaxRuleFields"), + addTaxRule: m("addTaxRule", "$input: TaxRuleInput!", "...TaxRuleFields"), + seedTaxRules: m("seedTaxRules", "", "...TaxRuleFields"), + financeConfig: q( + "financeConfig", + "", + "b2clThresholdPaise tds { section rateBp noPanRateBp thresholdPaise } bankToleranceDays bankMaxFeeBp clearingLedgerKeys", + ), + verifyWithCA: q("verifyWithCA", ""), + gstr1: q( + "gstr1", + `${RANGE}, $gstin: String`, + `gstin period { from to } + b2b { ...Gstr1InvoiceFields } b2cl { ...Gstr1InvoiceFields } + b2cs { supplyType placeOfSupply ratePercent taxablePaise igstPaise cgstPaise sgstPaise cessPaise } + cdnr { ...Gstr1NoteFields } cdnur { ...Gstr1NoteFields } + nil { description nilRatedPaise exemptedPaise nonGstPaise } + hsn { b2b { ...HsnRowFields } b2c { ...HsnRowFields } } + docs { nature series from to total cancelled net } + totals { ...TaxTotalsFields } verifyWithCA`, + ), + gstr3b: q( + "gstr3b", + `${RANGE}, $gstin: String`, + `gstin period { from to } outwardTaxable { ...TaxTotalsFields } outwardNilExemptPaise outwardNonGstPaise + interStateUnregistered { placeOfSupply taxablePaise igstPaise } verifyWithCA`, + ), + + // ---------------------------------------------------------------- receivables (B2B) + customers: q("customers", "$activeOnly: Boolean", "...CustomerFields"), + customer: q("customer", "$id: ID!", "...CustomerFields"), + createCustomer: m("createCustomer", "$input: CustomerInput!", "...CustomerFields"), + updateCustomer: m("updateCustomer", "$id: ID!, $input: CustomerPatch!", "...CustomerFields"), + issueCustomerInvoice: m("issueCustomerInvoice", "$input: CustomerInvoiceInput!", "...InvoiceFields"), + customerStatement: q( + "customerStatement", + `$customerId: ID!, ${RANGE}`, + `customer { ...CustomerFields } openingPaise rows { ...StatementRowFields } totalDebitPaise totalCreditPaise closingPaise + open { invoiceId invoiceNo day dueDay totalPaise paidPaise creditedPaise duePaise overdueDays bucket } + ageing { ...AgeingFields } openTotalPaise availableCreditPaise`, + ), + receivablesSummary: q( + "receivablesSummary", + "$asOf: DateTime!", + "rows { customer { id code name } balancePaise overduePaise ageing { ...AgeingFields } } totalPaise", + ), + creditNotes: q("creditNotes", `$customerId: ID, $invoiceId: ID, ${PAGE}`, "...CreditNoteFields"), + creditNote: q("creditNote", "$id: ID!", "...CreditNoteFields"), + issueCreditNote: m( + "issueCreditNote", + "$input: CreditNoteInput!", + "creditNote { ...CreditNoteFields } invoice { ...InvoiceFields } refundablePaise", + ), + + // ---------------------------------------------------------------- partners and settlements + partners: q("partners", "$branchId: ID", "...PartnerFields"), + partner: q("partner", "$id: ID!", "...PartnerFields"), + createPartner: m("createPartner", "$input: PartnerInput!", "...PartnerFields"), + updatePartner: m("updatePartner", "$id: ID!, $input: PartnerPatch!", "...PartnerFields"), + addAgreement: m("addAgreement", "$input: AgreementInput!", "...AgreementFields"), + settlementPreview: q( + "settlementPreview", + "$partnerId: ID!, $period: String!, $deductions: [SettlementDeductionInput!]", + `period from to lines { ...SettlementLineFields } grossPaise sharePaise deductions { label amountPaise ledgerId } + deductionsPaise tds { section rateBp basePaise tdsPaise } netPaise uncoveredInvoices partner { id code name }`, + ), + settlements: q( + "settlements", + `$branchId: ID, $partnerId: ID, $status: SettlementStatus, ${PAGE}`, + "...SettlementFields", + ), + settlement: q("settlement", "$id: ID!", "...SettlementFields"), + createSettlement: m( + "createSettlement", + "$partnerId: ID!, $period: String!, $deductions: [SettlementDeductionInput!]", + "...SettlementFields", + ), + accrueSettlement: m("accrueSettlement", "$id: ID!", "...SettlementFields"), + paySettlement: m("paySettlement", "$input: PaySettlementInput!", "...SettlementFields"), + cancelSettlement: m("cancelSettlement", "$id: ID!", "...SettlementFields"), + + // ---------------------------------------------------------------- cash and bank + cashClosings: q("cashClosings", `$branchId: ID!, ${PAGE}`, "...CashClosingFields"), + closeCashDay: m("closeCashDay", "$input: CloseCashDayInput!", "...CashClosingFields"), + bankStatements: q("bankStatements", `$ledgerId: ID, ${PAGE}`, "...BankStatementFields"), + bankLines: q( + "bankLines", + `$ledgerId: ID, $statementId: ID, $status: BankLineStatus, ${PAGE}`, + "...BankLineFields", + ), + importBankStatement: m( + "importBankStatement", + "$input: BankImportInput!", + "added skipped statement { ...BankStatementFields }", + ), + reconcileBank: m("reconcileBank", "$ledgerId: ID!, $statementId: ID", "matched unmatched settlements"), + matchBankLine: m("matchBankLine", "$lineId: ID!, $voucherIds: [ID!]!", "...BankLineFields"), + setBankLineStatus: m("setBankLineStatus", "$lineId: ID!, $status: BankLineManualStatus!", "...BankLineFields"), + bankReconciliation: q( + "bankReconciliation", + "$ledgerId: ID!, $asOf: DateTime!", + `bookPaise bankPaise paymentsNotPresentedPaise depositsNotCreditedPaise bankCreditsNotInBooksPaise + bankDebitsNotInBooksPaise expectedBankPaise differencePaise reconciled { lines amountPaise } + unmatchedLines { ...BankLineFields } + unreconciledEntries { id voucherId voucherType voucherNo date debitPaise creditPaise invoiceId }`, + ), + + // ---------------------------------------------------------------- periods + lockDates: q("lockDates", "", "...PeriodLockFields"), + setLockDate: m("setLockDate", "$input: LockDateInput!", "...PeriodLockFields"), + closeFinancialYear: m("closeFinancialYear", "$fy: String!", "netProfitPaise vouchers { ...VoucherFields }"), + + // ---------------------------------------------------------------- CSV exports + exportTrialBalance: q("exportTrialBalance", `$asOf: DateTime, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportProfitAndLoss: q("exportProfitAndLoss", `${RANGE}, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportBalanceSheet: q("exportBalanceSheet", `$asOf: DateTime!, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportDayBook: q("exportDayBook", `${RANGE}, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportLedger: q("exportLedger", `$ledgerId: ID!, ${RANGE}, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportGstr1: q("exportGstr1", `${RANGE}, $gstin: String, ${CSV}`, "...CsvFileFields"), + exportGstr3b: q("exportGstr3b", `${RANGE}, $gstin: String, ${CSV}`, "...CsvFileFields"), + exportSettlement: q("exportSettlement", `$settlementId: ID!, ${CSV}`, "...CsvFileFields"), + + // ---------------------------------------------------------------- documents (PDF as base64, or HTML) + reportTemplates: q( + "reportTemplates", + "", + "templates { id name category specimen method fields { key label unit range } } profiles { id name tests }", + ), + priceList: q("priceList", "", "code name category rate taxPercent hsn"), + documentSamples: q("documentSamples", "", "id label group"), + invoiceSamples: q("invoiceSamples", "", "id label group"), + renderReport: q( + "renderReport", + "$request: JSON!, $format: DocumentFormat, $variant: ReportVariant, $design: ReportDesign, $fileName: String", + "...DocumentFields", + ), + renderInvoice: q( + "renderInvoice", + "$request: JSON!, $format: DocumentFormat, $variant: ReportVariant, $fileName: String", + "...DocumentFields", + ), + invoiceTotals: q("invoiceTotals", "$request: JSON!"), + letterhead: q("letterhead", "$format: DocumentFormat", "...DocumentFields"), + invoiceDocument: q( + "invoiceDocument", + "$invoiceId: ID!, $format: DocumentFormat, $variant: ReportVariant", + "...DocumentFields", + ), + bookingReportDocument: q( + "bookingReportDocument", + "$bookingId: ID!, $reportIds: [ID!], $format: DocumentFormat, $variant: ReportVariant, $design: ReportDesign", + "...DocumentFields", + ), +} satisfies Record; + +export type OperationName = keyof typeof OPERATIONS; +``` + + +--- + +## File: src/graphql/transport.ts + +```ts +// The GraphQL transport: one POST per operation to the backend's single endpoint (mgpl-diagnostics/api). + +export interface GraphQLClientOptions { + /** The endpoint: "/graphql" (same origin) or "https://api.your-lab.in/graphql". */ + url?: string; + /** Custom fetch (tests, SSR, retry wrappers). */ + fetch?: typeof fetch; + /** Starting session token (e.g. read from your own storage). `login` sets it; `logout` clears it. */ + token?: string | null; + /** Called whenever the token changes, so the app can persist it where it wants (or nowhere). */ + onToken?: (token: string | null) => void; + /** Extra headers on every request. */ + headers?: HeadersInit | (() => HeadersInit | Promise); + /** e.g. "include" when the API sits behind a cookie-forwarding proxy. */ + credentials?: RequestCredentials; +} + +/** One GraphQL error as the server sends it. `code` is the stable part (see the API's docs/05-errors.md). */ +export interface GraphQLErrorItem { + message: string; + path?: (string | number)[]; + extensions?: { code?: string; [key: string]: unknown }; +} + +/** + * Thrown when a response carries errors (or the HTTP request fails). `code` is the first error's + * code: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, CONFLICT, BAD_USER_INPUT, PERIOD_LOCKED, … . + */ +export class MgplGraphQLError extends Error { + readonly code: string; + constructor( + message: string, + readonly errors: GraphQLErrorItem[], + readonly status: number, + /** Whatever data came back next to the errors. */ + readonly data?: unknown, + ) { + super(message); + this.name = "MgplGraphQLError"; + this.code = errors[0]?.extensions?.code ?? (status >= 500 ? "INTERNAL_SERVER_ERROR" : "NETWORK_ERROR"); + } +} + +export type Variables = Record; + +export function createTransport(opts: GraphQLClientOptions = {}) { + const url = opts.url ?? "/graphql"; + const doFetch = opts.fetch ?? ((...a: Parameters) => fetch(...a)); + let token: string | null = opts.token ?? null; + + return { + url, + getToken: () => token, + setToken(next: string | null) { + token = next; + opts.onToken?.(next); + }, + /** Runs any operation. Throws MgplGraphQLError on errors; returns `data` otherwise. */ + async request(query: string, variables?: Variables, init: { signal?: AbortSignal } = {}): Promise { + const headers = new Headers(typeof opts.headers === "function" ? await opts.headers() : opts.headers); + headers.set("content-type", "application/json"); + headers.set("accept", "application/graphql-response+json, application/json"); + if (token) headers.set("authorization", `Bearer ${token}`); + let res: Response; + try { + res = await doFetch(url, { method: "POST", headers, credentials: opts.credentials, body: JSON.stringify({ query, variables }), signal: init.signal }); + } catch (e) { + throw new MgplGraphQLError((e as Error).message || "Network error", [], 0); + } + let body: { data?: T | null; errors?: GraphQLErrorItem[] } | undefined; + try { + body = (await res.json()) as typeof body; + } catch { + throw new MgplGraphQLError(`${res.status} ${res.statusText}`.trim(), [], res.status); + } + if (body?.errors?.length) throw new MgplGraphQLError(body.errors.map((e) => e.message).join("; "), body.errors, res.status, body.data); + if (!res.ok || body?.data == null) throw new MgplGraphQLError(`${res.status} ${res.statusText}`.trim(), [], res.status); + return body.data; + }, + }; +} + +export type Transport = ReturnType; +``` + + +--- + +## File: src/graphql/types.ts + +```ts +// What the client's operations return: the API's GraphQL types (mgpl-diagnostics/api, schema.graphql) +// as selected by src/graphql/operations.ts. Money is integer paise (Rs 1 = 100). DateTime values are +// ISO strings; Day values are "YYYY-MM-DD" (IST). The API's contract test validates every operation. + +export type ID = string; +export type DateTime = string; +export type Day = string; + +export type Sex = "male" | "female" | "other"; +export type PaymentMode = "cash" | "upi" | "card" | "netbanking" | "wallet" | "cheque" | "insurance"; +export type ItemCategory = "test" | "package" | "service" | "consumable" | "medicine" | "other"; +export type Taxability = "taxable" | "exempt" | "nil_rated" | "non_gst"; +export type VoucherType = "sales" | "receipt" | "payment" | "journal" | "contra" | "credit_note" | "debit_note" | "purchase" | "closing"; +export type BranchKind = "lab" | "collection_center"; +export type BookingStatus = "booked" | "sample_collected" | "in_process" | "completed" | "cancelled"; +export type CollectionType = "walk_in" | "home"; +export type SampleStatus = "pending" | "collected" | "received" | "rejected"; +export type ReportStatus = "draft" | "entered" | "verified" | "released" | "amended"; +/** "NONE" = within range. */ +export type ResultFlag = "L" | "H" | "A" | "NONE"; +export type InvoiceStatus = "draft" | "issued" | "partially_paid" | "paid" | "cancelled" | "refunded" | "credited"; +export type DocumentFormat = "pdf" | "html"; +export type ReportVariant = "header" | "non_header"; +export type ReportDesign = "sidebar" | "classic"; +export type CustomerKind = "hospital" | "insurer" | "tpa" | "corporate" | "other"; +export type PartnerKind = "collection_centre" | "franchise"; +export type AgreementKind = "revenue_share" | "fixed_rate"; +export type SettlementStatus = "draft" | "accrued" | "paid" | "cancelled"; +export type BankLineStatus = "unmatched" | "matched" | "ignored"; + +export interface Address { line1?: string | null; line2?: string | null; city?: string | null; district?: string | null; state?: string | null; pincode?: string | null } +export interface Ref { id: ID; name: string } +export interface BranchRef { id: ID; code: string; name: string } + +// ------------------------------------------------------------------ auth, organisation + +export interface RoleAssignment { role: string; branchId: ID | null } +export interface User { id: ID; name: string; email: string; phone: string | null; status: "active" | "disabled" | "invited"; roles: RoleAssignment[]; lastLoginAt: DateTime | null; createdAt: DateTime } +export interface Role { id: ID; key: string; name: string; permissions: string[]; system: boolean } +export interface Session { id: ID; current: boolean; createdAt: DateTime; lastSeenAt: DateTime; expiresAt: DateTime; ip: string | null; userAgent: string | null } +export interface LoginResult { token: string; expiresAt: DateTime; user: User } +export interface Branch { id: ID; code: string; name: string; kind: BranchKind; phone: string | null; gstin: string | null; stateCode: string | null; address: Address | null; active: boolean } +export interface Organization { id: ID; name: string; code: string; legalName: string | null; gstin: string | null; phone: string | null; email: string | null; address: Address | null; branches: Branch[] } +export interface Viewer { user: User; organization: Organization; permissions: string[] } +export interface AuditEntry { id: ID; action: string; entity: string | null; entityId: ID | null; branchId: ID | null; user: { id: ID; name: string } | null; at: DateTime } +export interface Health { ok: boolean; service: string; version: string; database: string; time: DateTime } + +// ------------------------------------------------------------------ catalog + +export interface CatalogParameter { name: string | null; unit: string | null; method: string | null; type: "numeric" | "text" | "options" | "image" | "title"; options: string[] | null; calculated: boolean; formulaText: string | null; referenceText: string | null; range: string } +export interface CatalogTest { id: number; name: string; code: string | null; shortCode: string | null; department: string; sampleType: string | null; mrp: number | null; pricePaise: number | null; isProfile: boolean; parameterCount: number } +export interface CatalogTestDetail extends CatalogTest { secondarySampleTypes: string[] | null; parameters: CatalogParameter[] } +export interface CatalogHit { test: CatalogTest; score: number } +export interface PriceQuote { total: number; totalPaise: number; tests: CatalogTest[]; unknown: string[] } + +// ------------------------------------------------------------------ lab + +export interface Patient { id: ID; uhid: string; title: string | null; name: string; sex: Sex; dob: DateTime | null; ageYears: number | null; phone: string | null; email: string | null; address: Address | null; createdAt: DateTime; branch: BranchRef | null } +export interface Doctor { id: ID; name: string; qualification: string | null; phone: string | null; clinic: string | null; active: boolean } +export interface Sample { id: ID; barcode: string; sampleType: string; status: SampleStatus; collectedAt: DateTime | null; receivedAt: DateTime | null; rejectReason: string | null } +export interface ResultValue { parameter: string; value: string; unit: string | null; flag: ResultFlag | null; range: string | null } +export interface Report { id: ID; testCode: string; testName: string; status: ReportStatus; version: number; verifiedAt: DateTime | null; releasedAt: DateTime | null; updatedAt: DateTime; results: ResultValue[] } +export interface BookingTest { testCode: string; name: string; pricePaise: number; sampleType: string | null } +export interface Booking { + id: ID; bookingNo: string; status: BookingStatus; notes: string | null; createdAt: DateTime; + patient: { id: ID; uhid: string; name: string }; doctor: Ref | null; branch: BranchRef; + tests: BookingTest[]; sampleCollection: { type: CollectionType; scheduledAt: DateTime | null }; + samples: Sample[]; reports: { id: ID; testCode: string; testName: string; status: ReportStatus }[]; + invoice: { id: ID; invoiceNo: string; status: InvoiceStatus; totalPaise: number; duePaise: number } | null; +} + +// ------------------------------------------------------------------ billing + +export interface InvoiceItem { code: string | null; name: string; category: ItemCategory; qty: number; ratePaise: number; discountPaise: number; taxPercent: number; hsnSac: string | null; taxability: Taxability | null; amountPaise: number } +export interface Payment { id: ID; kind: "payment" | "refund"; amountPaise: number; mode: PaymentMode; reference: string | null; receivedAt: DateTime } +export interface Invoice { + id: ID; invoiceNo: string; status: InvoiceStatus; placeOfSupply: string | null; buyerGstin: string | null; + subtotalPaise: number; discountPaise: number; taxPaise: number; roundOffPaise: number; totalPaise: number; paidPaise: number; creditedPaise: number; duePaise: number; + issuedAt: DateTime | null; cancelReason: string | null; createdAt: DateTime; + patient: { id: ID; uhid: string; name: string }; branch: BranchRef; customer: { id: ID; code: string; name: string } | null; + items: InvoiceItem[]; payments: Payment[]; +} + +// ------------------------------------------------------------------ books + +export type AccountNature = "assets" | "liabilities" | "income" | "expenses"; +export interface AccountGroup { id: ID; name: string; nature: AccountNature; affectsGrossProfit: boolean; parent: { id: ID; name: string } | null } +export interface Ledger { id: ID; name: string; systemKey: string | null; gstin: string | null; openingPaise: number; active: boolean; group: { id: ID; name: string; nature: AccountNature } } +export interface VoucherEntry { ledger: { id: ID; name: string }; debitPaise: number; creditPaise: number; invoiceId: ID | null } +export interface Voucher { id: ID; type: VoucherType; voucherNo: string; date: DateTime; narration: string | null; status: "posted" | "cancelled"; invoiceId: ID | null; entries: VoucherEntry[] } +export interface StatementRow { date: DateTime; voucherNo: string; voucherType: VoucherType; debitPaise: number; creditPaise: number; balancePaise: number; invoiceId: ID | null } +export interface LedgerStatement { ledger: { id: ID; name: string }; openingPaise: number; closingPaise: number; totalDebitPaise: number; totalCreditPaise: number; rows: StatementRow[] } +export interface TrialBalance { rows: { ledger: { id: ID; name: string }; group: { id: ID; name: string }; primaryGroup: { id: ID; name: string }; debitPaise: number; creditPaise: number }[]; totalDebitPaise: number; totalCreditPaise: number; differencePaise: number } +export interface GroupLine { group: { id: ID; name: string }; amountPaise: number; ledgers: { ledger: { id: ID; name: string }; amountPaise: number }[] } +export interface ProfitAndLoss { income: GroupLine[]; expenses: GroupLine[]; grossProfitPaise: number; netProfitPaise: number } +export interface BalanceSheet { assets: GroupLine[]; liabilities: GroupLine[]; profitPaise: number; totalAssetsPaise: number; totalLiabilitiesPaise: number; differencePaise: number } +export interface Outstanding { rows: { invoice: { id: ID; invoiceNo: string; duePaise: number; patient: { name: string } }; ageDays: number; bucket: "D0_30" | "D31_60" | "D61_90" | "D90_PLUS" }[]; buckets: { d0to30: number; d31to60: number; d61to90: number; d90plus: number }; totalDuePaise: number } +export interface GstSummary { taxableValuePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number; totalTaxPaise: number } + +// ------------------------------------------------------------------ finance + +export interface TaxRule { id: ID; code: string | null; category: ItemCategory | null; hsnSac: string; taxability: Taxability; ratePercent: number; effectiveFrom: DateTime; effectiveTo: DateTime | null; note: string | null } +export interface FinanceConfig { b2clThresholdPaise: number; tds: { section: string; rateBp: number; noPanRateBp: number; thresholdPaise: number | null }; bankToleranceDays: number; bankMaxFeeBp: number; clearingLedgerKeys: string[] } +export interface TaxTotals { taxablePaise: number; igstPaise: number; cgstPaise: number; sgstPaise: number; cessPaise: number } +export interface RateTotals extends TaxTotals { ratePercent: number } +export interface Gstr1Invoice { invoiceId: ID; invoiceNo: string; day: Day; valuePaise: number; placeOfSupply: string; reverseCharge: string; ctin: string | null; items: RateTotals[] } +export interface Gstr1Note { noteId: ID; noteNo: string; day: Day; noteType: string; invoiceNo: string; invoiceDay: Day; valuePaise: number; placeOfSupply: string; ctin: string | null; type: string | null; items: RateTotals[] } +export interface HsnRow extends TaxTotals { hsnSac: string; ratePercent: number; taxability: Taxability; qty: number; totalValuePaise: number } +export interface Gstr1 { + gstin: string; period: { from: DateTime; to: DateTime }; + b2b: Gstr1Invoice[]; b2cl: Gstr1Invoice[]; + b2cs: (TaxTotals & { supplyType: string; placeOfSupply: string; ratePercent: number })[]; + cdnr: Gstr1Note[]; cdnur: Gstr1Note[]; + nil: { description: string; nilRatedPaise: number; exemptedPaise: number; nonGstPaise: number }[]; + hsn: { b2b: HsnRow[]; b2c: HsnRow[] }; + docs: { nature: string; series: string; from: string; to: string; total: number; cancelled: number; net: number }[]; + totals: TaxTotals; verifyWithCA: string[]; +} +export interface Gstr3b { gstin: string; period: { from: DateTime; to: DateTime }; outwardTaxable: TaxTotals; outwardNilExemptPaise: number; outwardNonGstPaise: number; interStateUnregistered: { placeOfSupply: string; taxablePaise: number; igstPaise: number }[]; verifyWithCA: string[] } + +export interface Customer { id: ID; code: string; name: string; kind: CustomerKind; legalName: string | null; gstin: string | null; stateCode: string | null; pan: string | null; phone: string | null; email: string | null; address: Address | null; creditLimitPaise: number | null; creditDays: number; active: boolean; ledger: { id: ID; name: string } } +export interface Ageing { notDue: number; d1to30: number; d31to60: number; d61to90: number; d90plus: number } +export interface OpenItem { invoiceId: ID; invoiceNo: string; day: Day; dueDay: Day; totalPaise: number; paidPaise: number; creditedPaise: number; duePaise: number; overdueDays: number; bucket: "not_due" | "D1_30" | "D31_60" | "D61_90" | "D90_PLUS" } +export interface CustomerStatement { customer: Customer; openingPaise: number; rows: StatementRow[]; totalDebitPaise: number; totalCreditPaise: number; closingPaise: number; open: OpenItem[]; ageing: Ageing; openTotalPaise: number; availableCreditPaise: number | null } +export interface ReceivablesSummary { rows: { customer: { id: ID; code: string; name: string }; balancePaise: number; overduePaise: number; ageing: Ageing }[]; totalPaise: number } +export interface CreditNoteLine { itemIndex: number; name: string; code: string | null; hsnSac: string | null; taxability: Taxability | null; taxPercent: number; taxablePaise: number; taxPaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number } +export interface CreditNote { id: ID; noteNo: string; date: DateTime; reason: string; status: string; taxablePaise: number; taxPaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number; roundOffPaise: number; totalPaise: number; placeOfSupply: string | null; buyerGstin: string | null; invoice: { id: ID; invoiceNo: string }; customer: { id: ID; code: string; name: string } | null; lines: CreditNoteLine[] } + +export interface PartnerAgreement { id: ID; kind: AgreementKind; shareBp: number | null; rates: { code: string; sharePaise: number }[] | null; defaultSharePaise: number | null; categories: ItemCategory[] | null; effectiveFrom: DateTime; effectiveTo: DateTime | null; notes: string | null } +export interface Partner { id: ID; code: string; name: string; kind: PartnerKind; legalName: string | null; pan: string | null; gstin: string | null; stateCode: string | null; phone: string | null; email: string | null; address: Address | null; tds: { section: string; rateBp: number } | null; active: boolean; branch: BranchRef; ledger: { id: ID; name: string }; agreements: PartnerAgreement[] } +export interface SettlementLine { invoiceId: ID; invoiceNo: string; day: Day; code: string | null; name: string; netPaise: number; sharePaise: number; agreementId: ID; creditNoteId: ID | null } +export interface SettlementDeduction { label: string; amountPaise: number; ledgerId?: ID | null } +export interface SettlementPreview { period: string; from: DateTime; to: DateTime; lines: SettlementLine[]; grossPaise: number; sharePaise: number; deductions: SettlementDeduction[]; deductionsPaise: number; tds: { section: string | null; rateBp: number; basePaise: number; tdsPaise: number }; netPaise: number; uncoveredInvoices: string[]; partner: { id: ID; code: string; name: string } } +export interface Settlement { id: ID; period: string; from: DateTime; to: DateTime; status: SettlementStatus; grossPaise: number; sharePaise: number; deductionsPaise: number; tdsSection: string | null; tdsRateBp: number; tdsBasePaise: number; tdsPaise: number; netPaise: number; paidAt: DateTime | null; createdAt: DateTime; partner: { id: ID; code: string; name: string }; branch: BranchRef; lines: SettlementLine[]; deductions: SettlementDeduction[]; accrualVoucher: { id: ID; voucherNo: string } | null; payoutVoucher: { id: ID; voucherNo: string } | null } + +export interface CashClosing { id: ID; day: Day; bookPaise: number; countedPaise: number; differencePaise: number; denominations: { valuePaise: number; count: number }[] | null; notes: string | null; createdAt: DateTime; branch: BranchRef; voucher: { id: ID; voucherNo: string } | null } +export interface BankStatement { id: ID; fileName: string | null; from: DateTime | null; to: DateTime | null; openingPaise: number | null; closingPaise: number | null; lineCount: number; createdAt: DateTime; branch: BranchRef; ledger: { id: ID; name: string } } +export interface BankLine { id: ID; statementId: ID; date: DateTime; description: string; reference: string | null; debitPaise: number; creditPaise: number; balancePaise: number | null; status: BankLineStatus; matchKind: string | null; voucherIds: ID[]; matchedAt: DateTime | null; settlement: { ledgerId: ID; day: Day; grossPaise: number; feePaise: number } | null } +export interface BankReconciliation { bookPaise: number; bankPaise: number | null; paymentsNotPresentedPaise: number; depositsNotCreditedPaise: number; bankCreditsNotInBooksPaise: number; bankDebitsNotInBooksPaise: number; expectedBankPaise: number; differencePaise: number | null; reconciled: { lines: number; amountPaise: number }; unmatchedLines: BankLine[]; unreconciledEntries: { id: ID; voucherId: ID; voucherType: VoucherType; voucherNo: string; date: DateTime; debitPaise: number; creditPaise: number; invoiceId: ID | null }[] } +export interface PeriodLock { id: ID; scope: string; lockedUpTo: DateTime | null; reason: string | null; updatedAt: DateTime; branch: BranchRef | null } +export interface CsvFile { fileName: string; mimeType: string; content: string } + +// ------------------------------------------------------------------ documents + +/** A rendered PDF (base64) or self-contained HTML. Turn a PDF into a Blob with documentBlob(). */ +export interface RenderedDocument { format: DocumentFormat; fileName: string; mimeType: string; byteLength: number; base64: string | null; html: string | null } +export interface ReportTemplateInfo { id: string; name: string; category: string | null; specimen: string | null; method: string | null; fields: { key: string; label: string; unit: string | null; range: string[] | null }[] } +export interface ReportTemplateCatalog { templates: ReportTemplateInfo[]; profiles: { id: string; name: string; tests: string[] }[] } +export interface PriceListEntry { code: string; name: string; category: string; rate: number; taxPercent: number | null; hsn: string | null } + +// ------------------------------------------------------------------ inputs + +export interface RoleAssignmentInput { role: string; branchId?: ID | null } +export interface CreateUserInput { name: string; email: string; phone?: string; password: string; roles: RoleAssignmentInput[] } +export interface BranchInput { code: string; name: string; kind: BranchKind; phone?: string; gstin?: string; stateCode?: string; address?: Address; active?: boolean } +export interface RegisterPatientInput { branchId: ID; title?: string; name: string; sex: Sex; dob?: DateTime; ageYears?: number; phone?: string; email?: string; address?: Address } +export type UpdatePatientInput = Partial>; +export interface DoctorInput { name: string; qualification?: string; phone?: string; clinic?: string; active?: boolean } +/** A catalog code alone is enough: name, price and sample type come from the test catalog. */ +export interface BookingTestInput { testCode: string; name?: string; pricePaise?: number; sampleType?: string } +export interface CreateBookingInput { branchId: ID; patientId: ID; doctorId?: ID; tests: BookingTestInput[]; collectionType?: CollectionType; scheduledAt?: DateTime; notes?: string } +/** Unit, range and flag are filled from the catalog when omitted. */ +export interface ResultInput { parameter: string; value: string; unit?: string; flag?: ResultFlag; range?: string } +/** A catalog code alone is enough: name and MRP come from the test catalog. */ +export interface InvoiceItemInput { code?: string; name?: string; category?: ItemCategory; qty?: number; ratePaise?: number; discountPaise?: number; taxPercent?: number; hsnSac?: string; taxability?: Taxability } +export interface PaymentInput { amountPaise: number; mode: PaymentMode; reference?: string } +export interface IssueInvoiceInput { branchId: ID; patientId: ID; bookingId?: ID; items: InvoiceItemInput[]; billDiscountPaise?: number; payments?: PaymentInput[]; applyTaxRules?: boolean } +export interface PostVoucherInput { branchId: ID; type: VoucherType; date?: DateTime; narration?: string; entries: { ledgerId: ID; debitPaise?: number; creditPaise?: number; invoiceId?: ID }[] } +export interface LedgerInput { name: string; groupId: ID; openingPaise?: number; gstin?: string } +export interface TaxRuleInput { code?: string; category?: ItemCategory; hsnSac: string; taxability: Taxability; ratePercent: number; effectiveFrom: Day; note?: string } +export interface CustomerInput { code: string; name: string; kind: CustomerKind; legalName?: string; gstin?: string; stateCode?: string; pan?: string; phone?: string; email?: string; address?: Address; creditLimitPaise?: number | null; creditDays?: number } +export type CustomerPatch = Partial> & { active?: boolean }; +export interface CustomerInvoiceInput extends Omit { customerId: ID; placeOfSupply?: string; applyTaxRules?: boolean } +export interface CreditNoteInput { invoiceId: ID; reason: string; lines?: { itemIndex: number; taxablePaise?: number }[]; date?: DateTime } +export interface PartnerInput { code: string; name: string; kind: PartnerKind; branchId: ID; legalName?: string; pan?: string; gstin?: string; stateCode?: string; phone?: string; email?: string; address?: Address; tds?: { section: string; rateBp: number } } +export type PartnerPatch = Partial> & { tds?: { section: string; rateBp: number } | null; active?: boolean }; +export interface AgreementInput { partnerId: ID; kind: AgreementKind; shareBp?: number; rates?: { code: string; sharePaise: number }[]; defaultSharePaise?: number; categories?: ItemCategory[]; effectiveFrom: Day; effectiveTo?: Day; notes?: string } +export interface CloseCashDayInput { branchId: ID; day: Day; countedPaise: number; denominations?: { valuePaise: number; count: number }[]; notes?: string } +export interface BankImportInput { branchId: ID; ledgerId: ID; csv: string; fileName?: string } +export interface LockDateInput { branchId?: ID; upTo: Day | null; reason?: string } +export interface RenderOptions { format?: DocumentFormat; variant?: ReportVariant; design?: ReportDesign; fileName?: string } + +// ------------------------------------------------------------------ mutation results + +export interface CreateBookingResult { booking: Booking; samples: Sample[] } +export interface CreditNoteResult { creditNote: CreditNote; invoice: Invoice; refundablePaise: number } +export interface BankImportResult { added: number; skipped: number; statement: BankStatement } +export interface ReconcileResult { matched: number; unmatched: number; settlements: number } +export interface YearEndResult { netProfitPaise: number; vouchers: Voucher[] } +export interface DocumentSample { id: string; label: string; group: string | null } +export interface LoginInput { email: string; password: string; orgCode?: string } +export interface RoleInput { key: string; name: string; permissions: string[] } +export type OrganizationInput = Partial> & { address?: Address }; +export type BranchPatch = Partial>; +export type DoctorPatch = Partial; +export interface SettlementDeductionInput { label: string; amountPaise: number; ledgerId?: ID } +export interface PaySettlementInput { settlementId: ID; mode: PaymentMode; reference?: string; date?: DateTime } +``` + + --- ## File: src/index.ts @@ -491,6 +1970,21 @@ import type { export * from "./types"; +// The GraphQL SDK for mgpl-diagnostics/api (one endpoint for everything). Its types are under `Gql` +// here, or flat in "@mgpl-diagnostics/client/graphql". +export { + createMgplGraphQLClient, + documentBlob, + csvBlob, + createTransport, + MgplGraphQLError, + OPERATIONS, + FRAGMENTS, + withFragments, +} from "./graphql/index"; +export type { MgplGraphQLClient, GraphQLClientOptions, GraphQLErrorItem, Operation, OperationName } from "./graphql/index"; +export type * as Gql from "./graphql/types"; + /** Default mount point of the backend */ export const DEFAULT_BASE_URL = "/api/mgpl"; diff --git a/llms.txt b/llms.txt index 448349e..4cc3516 100644 --- a/llms.txt +++ b/llms.txt @@ -3,6 +3,7 @@ > Headless, zero-dependency TypeScript SDK for browsers (or any fetch runtime) that talks to the MGPL Diagnostics backend (`createMgplApi` / `createReportApi`). It requests lab reports and invoices as PDF Blobs, HTML strings or JSON, and fetches the test catalog and price list. It has no UI and no CSS; apps build their own UI/UX. Optional unstyled React hooks live in "@mgpl-diagnostics/client/react". Key facts for agents: +- GraphQL (mgpl-diagnostics/api, one endpoint for everything): `const mgpl = createMgplGraphQLClient({ url: "/graphql", token?, onToken? })`; `await mgpl.auth.login({ email, password })` keeps the token. Namespaces: auth, users, organization, catalog, patients, doctors, bookings, samples, reports, invoices, books, gst, receivables, settlements, cash, bank, periods, exports, documents; `request(query, vars)` for custom selections. Errors throw `MgplGraphQLError` with `code`. Money in paise; documents are base64 PDFs or HTML (`documentBlob`). Types flat in "@mgpl-diagnostics/client/graphql" or under `Gql`. - `const mgpl = createMgplClient({ baseUrl: "/api/mgpl", headers?, credentials?, fetch? })`. - Reports: `mgpl.reports.templates()`, `.pdf(req)`, `.html(req)`, `.letterheadPdf()`, `.samples()`. Invoices: `mgpl.invoices.totals(invoice)`, `.pdf(req)`, `.html(req)`, `.priceList()`. - Build entry forms from `templates()`: each template lists `fields` (key, label, unit, range); send `{ patient, tests: [{ template, results: { [key]: value } }] }`. @@ -13,6 +14,7 @@ Key facts for agents: ## Docs - [README](README.md): why, quick start, SDK table, React hooks, errors and auth +- [GraphQL client](docs/graphql.md): every method, the operation it runs, errors, conventions - [API reference](docs/api.md): every method, option and type - [AGENTS.md](AGENTS.md): rules (no deps, no UI, mirrored contract) - [llms-full.txt](llms-full.txt): everything above plus the source @@ -20,5 +22,8 @@ Key facts for agents: ## Source - [src/index.ts](src/index.ts): createMgplClient, helpers +- [src/graphql/client.ts](src/graphql/client.ts): createMgplGraphQLClient, documentBlob, csvBlob +- [src/graphql/operations.ts](src/graphql/operations.ts): fragments and operations +- [src/graphql/types.ts](src/graphql/types.ts): GraphQL result and input types - [src/react.tsx](src/react.tsx): hooks and HtmlFrame - [src/types.ts](src/types.ts): contract types diff --git a/package.json b/package.json index b86a9d0..5466b22 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mgpl-diagnostics/client", - "version": "1.0.0", - "description": "Headless browser SDK for the MGPL Diagnostics backend (reports + invoices). Zero dependencies, no UI — build your own.", + "version": "1.1.0", + "description": "Headless SDK for the MGPL Diagnostics backend: the GraphQL API (everything) and the report/invoice HTTP API. Zero dependencies, no UI \u2014 build your own.", "type": "module", "license": "SEE LICENSE IN LICENSE", "repository": "github:mgpl-diagnostics/client", @@ -13,6 +13,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./graphql": { + "types": "./dist/graphql.d.ts", + "import": "./dist/graphql.js" + }, "./react": { "types": "./dist/react.d.ts", "import": "./dist/react.js" diff --git a/src/graphql/client.ts b/src/graphql/client.ts new file mode 100644 index 0000000..6783874 --- /dev/null +++ b/src/graphql/client.ts @@ -0,0 +1,295 @@ +// createMgplGraphQLClient: one typed method per operation of the backend's single GraphQL endpoint +// (mgpl-diagnostics/api, POST /graphql). Zero dependencies: operations are plain strings. +import type { InvoiceRequest, InvoiceTotals, ReportRequest } from "../types"; +import { OPERATIONS, type Operation } from "./operations"; +import { createTransport, type GraphQLClientOptions, type Variables } from "./transport"; +import type * as T from "./types"; + +type Page = { limit?: number; offset?: number }; +type Range = { from: T.DateTime; to: T.DateTime }; +type Csv = { bom?: boolean }; + +export function createMgplGraphQLClient(opts: GraphQLClientOptions = {}) { + const transport = createTransport(opts); + + /** Runs one operation and returns its root field. */ + async function run(operation: Operation, variables?: Variables, signal?: AbortSignal): Promise { + const data = await transport.request>(operation.document, variables, { signal }); + return data[operation.field] as R; + } + const O = OPERATIONS; + + return { + url: transport.url, + /** Any query or mutation, for what the typed methods don't cover (e.g. a custom selection). */ + request: transport.request, + getToken: transport.getToken, + setToken: transport.setToken, + + health: () => run(O.health), + ping: () => run(O.ping), + + auth: { + /** Signs in and keeps the session token for every later call (see `onToken` to persist it). */ + async login(input: T.LoginInput): Promise { + const result = await run(O.login, { input }); + transport.setToken(result.token); + return result; + }, + /** Ends this session on the server and forgets the token. */ + async logout(): Promise { + try { + if (transport.getToken()) await run(O.logout); + } finally { + transport.setToken(null); + } + }, + /** The signed-in user, organisation and permissions; null when the token is missing or expired. */ + viewer: () => run(O.viewer), + /** Whether the signed-in user may do `permission` (at `branchId`, when given). */ + can: async (permission: string, branchId?: T.ID) => + (await run<{ can: boolean } | null>(O.can, { permission, branchId }))?.can ?? false, + sessions: () => run(O.mySessions), + logoutOtherSessions: () => run(O.logoutOtherSessions), + revokeSession: (id: T.ID) => run(O.revokeSession, { id }), + changePassword: (currentPassword: string, newPassword: string) => + run(O.changePassword, { currentPassword, newPassword }), + /** Always resolves true; the server emails the link if the account exists. */ + requestPasswordReset: (email: string, orgCode?: string) => run(O.requestPasswordReset, { email, orgCode }), + resetPassword: (token: string, newPassword: string) => run(O.resetPassword, { token, newPassword }), + }, + + users: { + list: () => run(O.users), + get: (id: T.ID) => run(O.user, { id }), + create: (input: T.CreateUserInput) => run(O.createUser, { input }), + setRoles: (userId: T.ID, roles: T.RoleAssignmentInput[]) => run(O.setUserRoles, { userId, roles }), + disable: (userId: T.ID) => run(O.disableUser, { userId }), + enable: (userId: T.ID) => run(O.enableUser, { userId }), + setPassword: (userId: T.ID, newPassword: string) => run(O.setUserPassword, { userId, newPassword }), + roles: () => run(O.roles), + upsertRole: (input: T.RoleInput) => run(O.upsertRole, { input }), + /** Every permission name the API checks. */ + permissions: () => run(O.permissions), + }, + + organization: { + get: () => run(O.organization), + update: (input: T.OrganizationInput) => run(O.updateOrganization, { input }), + branches: () => run(O.branches), + branch: (id: T.ID) => run(O.branch, { id }), + createBranch: (input: T.BranchInput) => run(O.createBranch, { input }), + updateBranch: (id: T.ID, input: T.BranchPatch) => run(O.updateBranch, { id, input }), + auditLog: (filter: { entity?: string; entityId?: T.ID } & Page = {}) => run(O.auditLog, filter), + }, + + /** The test catalog. No sign-in needed. */ + catalog: { + search: (query: string, o: { department?: string; sampleType?: string; limit?: number; fuzzy?: boolean } = {}) => + run(O.catalogSearch, { query, ...o }), + /** By numeric id or code ("CBC", "LFT"); `sex` picks the reference range. */ + test: (idOrCode: number | string, sex?: T.Sex) => + run(O.catalogTest, typeof idOrCode === "number" ? { id: idOrCode, sex } : { code: idOrCode, sex }), + tests: (filter: { department?: string; sampleType?: string } & Page = {}) => run(O.catalogTests, filter), + departments: () => run(O.catalogDepartments), + sampleTypes: () => run(O.catalogSampleTypes), + priceQuote: (tests: string[]) => run(O.priceQuote, { tests }), + }, + + patients: { + /** Name, phone or UHID. */ + search: (search: string, limit?: number) => run(O.patients, { search, limit }), + get: (id: T.ID) => run(O.patient, { id }), + register: (input: T.RegisterPatientInput) => run(O.registerPatient, { input }), + update: (id: T.ID, input: T.UpdatePatientInput) => run(O.updatePatient, { id, input }), + }, + + doctors: { + list: (activeOnly?: boolean) => run(O.doctors, { activeOnly }), + create: (input: T.DoctorInput) => run(O.createDoctor, { input }), + update: (id: T.ID, input: T.DoctorPatch) => run(O.updateDoctor, { id, input }), + }, + + bookings: { + list: (filter: { branchId?: T.ID; patientId?: T.ID; status?: T.BookingStatus } & Page = {}) => + run(O.bookings, filter), + get: (id: T.ID) => run(O.booking, { id }), + /** Creates the booking and one sample per sample type. */ + create: (input: T.CreateBookingInput) => run(O.createBooking, { input }), + cancel: (id: T.ID) => run(O.cancelBooking, { id }), + }, + + samples: { + get: (id: T.ID) => run(O.sample, { id }), + byBarcode: (barcode: string) => run(O.sampleByBarcode, { barcode }), + collect: (id: T.ID) => run(O.collectSample, { id }), + receive: (id: T.ID) => run(O.receiveSample, { id }), + reject: (id: T.ID, reason: string) => run(O.rejectSample, { id, reason }), + }, + + reports: { + get: (id: T.ID) => run(O.report, { id }), + create: (bookingId: T.ID, testCode: string, testName?: string) => + run(O.createReport, { bookingId, testCode, testName }), + /** Unit, range and flag are filled from the catalog when omitted. */ + saveResults: (reportId: T.ID, results: T.ResultInput[]) => run(O.saveResults, { reportId, results }), + verify: (id: T.ID) => run(O.verifyReport, { id }), + release: (id: T.ID) => run(O.releaseReport, { id }), + amend: (id: T.ID) => run(O.amendReport, { id }), + }, + + invoices: { + list: (filter: { branchId?: T.ID; patientId?: T.ID; customerId?: T.ID; status?: T.InvoiceStatus } & Page = {}) => + run(O.invoices, filter), + get: (id: T.ID) => run(O.invoice, { id }), + /** Items can be catalog codes alone; name and MRP come from the catalog. Posts to the books. */ + issue: (input: T.IssueInvoiceInput) => run(O.issueInvoice, { input }), + recordPayment: (invoiceId: T.ID, payment: T.PaymentInput) => run(O.recordPayment, { invoiceId, payment }), + refund: (invoiceId: T.ID, payment: T.PaymentInput) => run(O.refundPayment, { invoiceId, payment }), + cancel: (id: T.ID, reason: string) => run(O.cancelInvoice, { id, reason }), + }, + + books: { + groups: () => run(O.accountGroups), + ledgers: () => run(O.ledgers), + ledger: (id: T.ID) => run(O.ledger, { id }), + voucher: (id: T.ID) => run(O.voucher, { id }), + ledgerStatement: (ledgerId: T.ID, range: Range, branchId?: T.ID) => + run(O.ledgerStatement, { ledgerId, ...range, branchId }), + cashBook: (range: Range, branchId?: T.ID) => run(O.cashBook, { ...range, branchId }), + trialBalance: (o: { asOf?: T.DateTime; branchId?: T.ID } = {}) => run(O.trialBalance, o), + profitAndLoss: (range: Range, branchId?: T.ID) => run(O.profitAndLoss, { ...range, branchId }), + balanceSheet: (asOf: T.DateTime, branchId?: T.ID) => run(O.balanceSheet, { asOf, branchId }), + dayBook: (range: Range, o: { branchId?: T.ID; type?: T.VoucherType } = {}) => run(O.dayBook, { ...range, ...o }), + outstandingReceivables: (o: { asOf?: T.DateTime; branchId?: T.ID } = {}) => run(O.outstandingReceivables, o), + gstSummary: (range: Range, branchId?: T.ID) => run(O.gstSummary, { ...range, branchId }), + /** Seeds the standard chart and finance ledgers. Safe to repeat. */ + setupChartOfAccounts: () => run(O.setupChartOfAccounts), + createLedger: (input: T.LedgerInput) => run(O.createLedger, { input }), + postVoucher: (input: T.PostVoucherInput) => run(O.postVoucher, { input }), + cancelVoucher: (id: T.ID) => run(O.cancelVoucher, { id }), + }, + + gst: { + taxRules: () => run(O.taxRules), + addTaxRule: (input: T.TaxRuleInput) => run(O.addTaxRule, { input }), + /** finance-kit's starting rules. Every rate must be verified with the lab's CA. */ + seedTaxRules: () => run(O.seedTaxRules), + config: () => run(O.financeConfig), + verifyWithCA: () => run(O.verifyWithCA), + gstr1: (range: Range, gstin?: string) => run(O.gstr1, { ...range, gstin }), + gstr3b: (range: Range, gstin?: string) => run(O.gstr3b, { ...range, gstin }), + }, + + receivables: { + customers: (activeOnly?: boolean) => run(O.customers, { activeOnly }), + customer: (id: T.ID) => run(O.customer, { id }), + createCustomer: (input: T.CustomerInput) => run(O.createCustomer, { input }), + updateCustomer: (id: T.ID, input: T.CustomerPatch) => run(O.updateCustomer, { id, input }), + /** A credit invoice to a hospital, insurer, TPA or corporate (checks the credit limit). */ + issueInvoice: (input: T.CustomerInvoiceInput) => run(O.issueCustomerInvoice, { input }), + statement: (customerId: T.ID, range: Range) => run(O.customerStatement, { customerId, ...range }), + summary: (asOf: T.DateTime) => run(O.receivablesSummary, { asOf }), + creditNotes: (filter: { customerId?: T.ID; invoiceId?: T.ID } & Page = {}) => run(O.creditNotes, filter), + creditNote: (id: T.ID) => run(O.creditNote, { id }), + issueCreditNote: (input: T.CreditNoteInput) => run(O.issueCreditNote, { input }), + }, + + settlements: { + partners: (branchId?: T.ID) => run(O.partners, { branchId }), + partner: (id: T.ID) => run(O.partner, { id }), + createPartner: (input: T.PartnerInput) => run(O.createPartner, { input }), + updatePartner: (id: T.ID, input: T.PartnerPatch) => run(O.updatePartner, { id, input }), + addAgreement: (input: T.AgreementInput) => run(O.addAgreement, { input }), + /** period: "YYYY-MM". Computes without saving. */ + preview: (partnerId: T.ID, period: string, deductions?: T.SettlementDeductionInput[]) => + run(O.settlementPreview, { partnerId, period, deductions }), + list: (filter: { branchId?: T.ID; partnerId?: T.ID; status?: T.SettlementStatus } & Page = {}) => + run(O.settlements, filter), + get: (id: T.ID) => run(O.settlement, { id }), + create: (partnerId: T.ID, period: string, deductions?: T.SettlementDeductionInput[]) => + run(O.createSettlement, { partnerId, period, deductions }), + accrue: (id: T.ID) => run(O.accrueSettlement, { id }), + pay: (input: T.PaySettlementInput) => run(O.paySettlement, { input }), + cancel: (id: T.ID) => run(O.cancelSettlement, { id }), + }, + + cash: { + closings: (branchId: T.ID, page: Page = {}) => run(O.cashClosings, { branchId, ...page }), + closeDay: (input: T.CloseCashDayInput) => run(O.closeCashDay, { input }), + }, + + bank: { + statements: (filter: { ledgerId?: T.ID } & Page = {}) => run(O.bankStatements, filter), + lines: (filter: { ledgerId?: T.ID; statementId?: T.ID; status?: T.BankLineStatus } & Page = {}) => + run(O.bankLines, filter), + import: (input: T.BankImportInput) => run(O.importBankStatement, { input }), + reconcile: (ledgerId: T.ID, statementId?: T.ID) => run(O.reconcileBank, { ledgerId, statementId }), + match: (lineId: T.ID, voucherIds: T.ID[]) => run(O.matchBankLine, { lineId, voucherIds }), + setStatus: (lineId: T.ID, status: "ignored" | "unmatched") => run(O.setBankLineStatus, { lineId, status }), + reconciliation: (ledgerId: T.ID, asOf: T.DateTime) => run(O.bankReconciliation, { ledgerId, asOf }), + }, + + periods: { + lockDates: () => run(O.lockDates), + /** upTo: "YYYY-MM-DD", or null to unlock. Without branchId it locks the whole organisation. */ + setLockDate: (input: T.LockDateInput) => run(O.setLockDate, { input }), + /** fy: "2026-27". Posts closing vouchers on 31 March and locks the year. */ + closeFinancialYear: (fy: string) => run(O.closeFinancialYear, { fy }), + }, + + /** CSV files ending with TOTAL rows. Save one with `csvBlob(file)` + downloadBlob. */ + exports: { + trialBalance: (o: { asOf?: T.DateTime; branchId?: T.ID } & Csv = {}) => run(O.exportTrialBalance, o), + profitAndLoss: (range: Range, o: { branchId?: T.ID } & Csv = {}) => run(O.exportProfitAndLoss, { ...range, ...o }), + balanceSheet: (asOf: T.DateTime, o: { branchId?: T.ID } & Csv = {}) => run(O.exportBalanceSheet, { asOf, ...o }), + dayBook: (range: Range, o: { branchId?: T.ID } & Csv = {}) => run(O.exportDayBook, { ...range, ...o }), + ledger: (ledgerId: T.ID, range: Range, o: { branchId?: T.ID } & Csv = {}) => + run(O.exportLedger, { ledgerId, ...range, ...o }), + /** One file per section: b2b, b2cl, b2cs, cdnr, cdnur, nil, hsn, docs. */ + gstr1: (range: Range, o: { gstin?: string } & Csv = {}) => run(O.exportGstr1, { ...range, ...o }), + gstr3b: (range: Range, o: { gstin?: string } & Csv = {}) => run(O.exportGstr3b, { ...range, ...o }), + settlement: (settlementId: T.ID, o: Csv = {}) => run(O.exportSettlement, { settlementId, ...o }), + }, + + /** Reports and invoices rendered by the server (report-kit / invoice-kit). PDFs come as base64. */ + documents: { + templates: () => run(O.reportTemplates), + priceList: () => run(O.priceList), + reportSamples: () => run(O.documentSamples), + invoiceSamples: () => run(O.invoiceSamples), + /** Any report request (patient + tests | profile | report | sample), as with the REST client. */ + renderReport: (request: ReportRequest, o: T.RenderOptions = {}) => run(O.renderReport, { request, ...o }), + /** Any invoice request ({ invoice } or { sample }). */ + renderInvoice: (request: InvoiceRequest, o: Omit = {}) => + run(O.renderInvoice, { request, ...o }), + /** invoice-kit's calculation, without rendering (amounts in rupees). */ + invoiceTotals: (request: InvoiceRequest) => run(O.invoiceTotals, { request }), + letterhead: (format?: T.DocumentFormat) => run(O.letterhead, { format }), + /** A stored invoice, printed. */ + invoice: (invoiceId: T.ID, o: Omit = {}) => + run(O.invoiceDocument, { invoiceId, ...o }), + /** A booking's verified or released reports printed together (or only `reportIds`). */ + bookingReports: (bookingId: T.ID, o: Omit & { reportIds?: T.ID[] } = {}) => + run(O.bookingReportDocument, { bookingId, ...o }), + }, + }; +} + +export type MgplGraphQLClient = ReturnType; + +/** A rendered document as a Blob: PDF from base64, HTML from its markup. Use with downloadBlob/openBlob/printBlob. */ +export function documentBlob(doc: Pick): Blob { + if (doc.base64 != null) { + const bin = atob(doc.base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new Blob([bytes], { type: doc.mimeType }); + } + return new Blob([doc.html ?? ""], { type: doc.mimeType }); +} + +/** A CSV export as a Blob (UTF-8). */ +export function csvBlob(file: T.CsvFile): Blob { + return new Blob([file.content], { type: file.mimeType }); +} diff --git a/src/graphql/index.ts b/src/graphql/index.ts new file mode 100644 index 0000000..4591325 --- /dev/null +++ b/src/graphql/index.ts @@ -0,0 +1,6 @@ +// "@mgpl-diagnostics/client/graphql": the GraphQL SDK with every type flat. +// The main entry re-exports the client and helpers too, with these types under the `Gql` namespace. +export { createMgplGraphQLClient, documentBlob, csvBlob, type MgplGraphQLClient } from "./client"; +export { createTransport, MgplGraphQLError, type GraphQLClientOptions, type GraphQLErrorItem, type Transport, type Variables } from "./transport"; +export { OPERATIONS, FRAGMENTS, withFragments, type Operation, type OperationName } from "./operations"; +export type * from "./types"; diff --git a/src/graphql/operations.ts b/src/graphql/operations.ts new file mode 100644 index 0000000..c239a61 --- /dev/null +++ b/src/graphql/operations.ts @@ -0,0 +1,449 @@ +// Every operation the client sends, as plain GraphQL strings (no runtime GraphQL library). +// The selections match src/graphql/types.ts. The API's contract test validates each one against +// the server's schema (mgpl-diagnostics/api, test/client-contract.ts), so a renamed field fails there. + +/** Reusable selections. An operation gets the fragments it spreads (and theirs) appended. */ +export const FRAGMENTS: Record = { + AddressFields: `fragment AddressFields on Address { line1 line2 city district state pincode }`, + BranchRef: `fragment BranchRef on Branch { id code name }`, + UserFields: `fragment UserFields on User { id name email phone status roles { role branchId } lastLoginAt createdAt }`, + RoleFields: `fragment RoleFields on Role { id key name permissions system }`, + SessionFields: `fragment SessionFields on Session { id current createdAt lastSeenAt expiresAt ip userAgent }`, + BranchFields: `fragment BranchFields on Branch { id code name kind phone gstin stateCode active address { ...AddressFields } }`, + OrganizationFields: `fragment OrganizationFields on Organization { + id name code legalName gstin phone email address { ...AddressFields } branches { ...BranchFields } + }`, + AuditEntryFields: `fragment AuditEntryFields on AuditEntry { id action entity entityId branchId user { id name } at }`, + + CatalogTestFields: `fragment CatalogTestFields on CatalogTest { + id name code shortCode department sampleType mrp pricePaise isProfile parameterCount + }`, + + PatientFields: `fragment PatientFields on Patient { + id uhid title name sex dob ageYears phone email address { ...AddressFields } createdAt branch { ...BranchRef } + }`, + DoctorFields: `fragment DoctorFields on Doctor { id name qualification phone clinic active }`, + SampleFields: `fragment SampleFields on Sample { id barcode sampleType status collectedAt receivedAt rejectReason }`, + ReportFields: `fragment ReportFields on Report { + id testCode testName status version verifiedAt releasedAt updatedAt results { parameter value unit flag range } + }`, + BookingFields: `fragment BookingFields on Booking { + id bookingNo status notes createdAt + patient { id uhid name } doctor { id name } branch { ...BranchRef } + tests { testCode name pricePaise sampleType } + sampleCollection { type scheduledAt } + samples { ...SampleFields } + reports { id testCode testName status } + invoice { id invoiceNo status totalPaise duePaise } + }`, + + InvoiceFields: `fragment InvoiceFields on Invoice { + id invoiceNo status placeOfSupply buyerGstin + subtotalPaise discountPaise taxPaise roundOffPaise totalPaise paidPaise creditedPaise duePaise + issuedAt cancelReason createdAt + patient { id uhid name } branch { ...BranchRef } customer { id code name } + items { code name category qty ratePaise discountPaise taxPercent hsnSac taxability amountPaise } + payments { id kind amountPaise mode reference receivedAt } + }`, + + AccountGroupFields: `fragment AccountGroupFields on AccountGroup { id name nature affectsGrossProfit parent { id name } }`, + LedgerFields: `fragment LedgerFields on Ledger { id name systemKey gstin openingPaise active group { id name nature } }`, + VoucherFields: `fragment VoucherFields on Voucher { + id type voucherNo date narration status invoiceId + entries { ledger { id name } debitPaise creditPaise invoiceId } + }`, + StatementRowFields: `fragment StatementRowFields on StatementRow { date voucherNo voucherType debitPaise creditPaise balancePaise invoiceId }`, + LedgerStatementFields: `fragment LedgerStatementFields on LedgerStatement { + ledger { id name } openingPaise closingPaise totalDebitPaise totalCreditPaise rows { ...StatementRowFields } + }`, + GroupLineFields: `fragment GroupLineFields on GroupLine { group { id name } amountPaise ledgers { ledger { id name } amountPaise } }`, + + TaxRuleFields: `fragment TaxRuleFields on TaxRule { id code category hsnSac taxability ratePercent effectiveFrom effectiveTo note }`, + RateTotalsFields: `fragment RateTotalsFields on RateTotals { ratePercent taxablePaise igstPaise cgstPaise sgstPaise cessPaise }`, + TaxTotalsFields: `fragment TaxTotalsFields on TaxTotals { taxablePaise igstPaise cgstPaise sgstPaise cessPaise }`, + HsnRowFields: `fragment HsnRowFields on HsnRow { + hsnSac ratePercent taxability qty totalValuePaise taxablePaise igstPaise cgstPaise sgstPaise cessPaise + }`, + Gstr1InvoiceFields: `fragment Gstr1InvoiceFields on Gstr1Invoice { + invoiceId invoiceNo day valuePaise placeOfSupply reverseCharge ctin items { ...RateTotalsFields } + }`, + Gstr1NoteFields: `fragment Gstr1NoteFields on Gstr1Note { + noteId noteNo day noteType invoiceNo invoiceDay valuePaise placeOfSupply ctin type items { ...RateTotalsFields } + }`, + + CustomerFields: `fragment CustomerFields on Customer { + id code name kind legalName gstin stateCode pan phone email address { ...AddressFields } + creditLimitPaise creditDays active ledger { id name } + }`, + AgeingFields: `fragment AgeingFields on Ageing { notDue d1to30 d31to60 d61to90 d90plus }`, + CreditNoteFields: `fragment CreditNoteFields on CreditNote { + id noteNo date reason status taxablePaise taxPaise cgstPaise sgstPaise igstPaise roundOffPaise totalPaise + placeOfSupply buyerGstin invoice { id invoiceNo } customer { id code name } + lines { itemIndex name code hsnSac taxability taxPercent taxablePaise taxPaise cgstPaise sgstPaise igstPaise } + }`, + + AgreementFields: `fragment AgreementFields on PartnerAgreement { + id kind shareBp rates { code sharePaise } defaultSharePaise categories effectiveFrom effectiveTo notes + }`, + PartnerFields: `fragment PartnerFields on Partner { + id code name kind legalName pan gstin stateCode phone email address { ...AddressFields } + tds { section rateBp } active branch { ...BranchRef } ledger { id name } agreements { ...AgreementFields } + }`, + SettlementLineFields: `fragment SettlementLineFields on SettlementLine { + invoiceId invoiceNo day code name netPaise sharePaise agreementId creditNoteId + }`, + SettlementFields: `fragment SettlementFields on Settlement { + id period from to status grossPaise sharePaise deductionsPaise tdsSection tdsRateBp tdsBasePaise tdsPaise netPaise + paidAt createdAt partner { id code name } branch { ...BranchRef } + lines { ...SettlementLineFields } deductions { label amountPaise ledgerId } + accrualVoucher { id voucherNo } payoutVoucher { id voucherNo } + }`, + + CashClosingFields: `fragment CashClosingFields on CashClosing { + id day bookPaise countedPaise differencePaise denominations { valuePaise count } notes createdAt + branch { ...BranchRef } voucher { id voucherNo } + }`, + BankStatementFields: `fragment BankStatementFields on BankStatement { + id fileName from to openingPaise closingPaise lineCount createdAt branch { ...BranchRef } ledger { id name } + }`, + BankLineFields: `fragment BankLineFields on BankLine { + id statementId date description reference debitPaise creditPaise balancePaise status matchKind voucherIds matchedAt + settlement { ledgerId day grossPaise feePaise } + }`, + PeriodLockFields: `fragment PeriodLockFields on PeriodLock { id scope lockedUpTo reason updatedAt branch { ...BranchRef } }`, + CsvFileFields: `fragment CsvFileFields on CsvFile { fileName mimeType content }`, + + DocumentFields: `fragment DocumentFields on Document { format fileName mimeType byteLength base64 html }`, +}; + +/** Appends every fragment the document spreads, transitively, once each. */ +export function withFragments(doc: string): string { + const seen = new Set(); + const queue = [doc]; + while (queue.length) { + for (const m of queue.pop()!.matchAll(/\.\.\.\s*([A-Z]\w*)/g)) { + const name = m[1]!; + if (seen.has(name) || !FRAGMENTS[name]) continue; + seen.add(name); + queue.push(FRAGMENTS[name]); + } + } + return [doc, ...[...seen].map((n) => FRAGMENTS[n])].join("\n"); +} + +export interface Operation { + /** The root field whose value the client returns. */ + field: string; + document: string; +} + +/** + * One root field per operation. `vars` declares the variables ("$id: ID!, $limit: Int"); each one is + * passed to the field under the same name. Omitted variables fall back to the schema's defaults. + */ +function op(kind: "query" | "mutation", field: string, vars: string, selection: string): Operation { + const names = [...vars.matchAll(/\$(\w+)\s*:/g)].map((m) => m[1]!); + const decl = vars ? `(${vars})` : ""; + const args = names.length ? `(${names.map((n) => `${n}: $${n}`).join(", ")})` : ""; + const name = field[0]!.toUpperCase() + field.slice(1); + const body = selection ? ` { ${selection} }` : ""; + return { field, document: withFragments(`${kind} ${name}${decl} { ${field}${args}${body} }`) }; +} +const q = (field: string, vars: string, selection = "") => op("query", field, vars, selection); +const m = (field: string, vars: string, selection = "") => op("mutation", field, vars, selection); + +const PAGE = "$limit: Int, $offset: Int"; +const RANGE = "$from: DateTime!, $to: DateTime!"; +const CSV = "$bom: Boolean"; + +export const OPERATIONS = { + // ---------------------------------------------------------------- system + health: q("health", "", "ok service version database time"), + ping: m("ping", ""), + + // ---------------------------------------------------------------- auth + viewer: q("viewer", "", "user { ...UserFields } organization { ...OrganizationFields } permissions"), + can: { + field: "viewer", + document: `query Can($permission: String!, $branchId: ID) { viewer { can(permission: $permission, branchId: $branchId) } }`, + } as Operation, + login: m("login", "$input: LoginInput!", "token expiresAt user { ...UserFields }"), + logout: m("logout", ""), + logoutOtherSessions: m("logoutOtherSessions", ""), + revokeSession: m("revokeSession", "$id: ID!"), + changePassword: m("changePassword", "$currentPassword: String!, $newPassword: String!"), + requestPasswordReset: m("requestPasswordReset", "$email: String!, $orgCode: String"), + resetPassword: m("resetPassword", "$token: String!, $newPassword: String!"), + mySessions: q("mySessions", "", "...SessionFields"), + + users: q("users", "", "...UserFields"), + user: q("user", "$id: ID!", "...UserFields"), + roles: q("roles", "", "...RoleFields"), + permissions: q("permissions", ""), + createUser: m("createUser", "$input: CreateUserInput!", "...UserFields"), + setUserRoles: m("setUserRoles", "$userId: ID!, $roles: [RoleAssignmentInput!]!", "...UserFields"), + disableUser: m("disableUser", "$userId: ID!", "...UserFields"), + enableUser: m("enableUser", "$userId: ID!", "...UserFields"), + setUserPassword: m("setUserPassword", "$userId: ID!, $newPassword: String!"), + upsertRole: m("upsertRole", "$input: RoleInput!", "...RoleFields"), + + // ---------------------------------------------------------------- organisation + organization: q("organization", "", "...OrganizationFields"), + branches: q("branches", "", "...BranchFields"), + branch: q("branch", "$id: ID!", "...BranchFields"), + auditLog: q("auditLog", `$entity: String, $entityId: ID, ${PAGE}`, "...AuditEntryFields"), + updateOrganization: m("updateOrganization", "$input: OrganizationInput!", "...OrganizationFields"), + createBranch: m("createBranch", "$input: BranchInput!", "...BranchFields"), + updateBranch: m("updateBranch", "$id: ID!, $input: BranchPatch!", "...BranchFields"), + + // ---------------------------------------------------------------- catalog (no sign-in needed) + catalogSearch: q( + "catalogSearch", + "$query: String!, $department: String, $sampleType: String, $limit: Int, $fuzzy: Boolean", + "score test { ...CatalogTestFields }", + ), + catalogTest: { + field: "catalogTest", + document: withFragments(`query CatalogTest($id: Int, $code: String, $sex: Sex) { + catalogTest(id: $id, code: $code) { + ...CatalogTestFields secondarySampleTypes + parameters { name unit method type options calculated formulaText referenceText range(sex: $sex) } + } +}`), + } as Operation, + catalogTests: q("catalogTests", `$department: String, $sampleType: String, ${PAGE}`, "...CatalogTestFields"), + catalogDepartments: q("catalogDepartments", ""), + catalogSampleTypes: q("catalogSampleTypes", ""), + priceQuote: q("priceQuote", "$tests: [String!]!", "total totalPaise unknown tests { ...CatalogTestFields }"), + + // ---------------------------------------------------------------- lab + patients: q("patients", "$search: String!, $limit: Int", "...PatientFields"), + patient: q("patient", "$id: ID!", "...PatientFields"), + registerPatient: m("registerPatient", "$input: RegisterPatientInput!", "...PatientFields"), + updatePatient: m("updatePatient", "$id: ID!, $input: UpdatePatientInput!", "...PatientFields"), + + doctors: q("doctors", "$activeOnly: Boolean", "...DoctorFields"), + createDoctor: m("createDoctor", "$input: DoctorInput!", "...DoctorFields"), + updateDoctor: m("updateDoctor", "$id: ID!, $input: DoctorPatch!", "...DoctorFields"), + + bookings: q("bookings", `$branchId: ID, $patientId: ID, $status: BookingStatus, ${PAGE}`, "...BookingFields"), + booking: q("booking", "$id: ID!", "...BookingFields"), + createBooking: m("createBooking", "$input: CreateBookingInput!", "booking { ...BookingFields } samples { ...SampleFields }"), + cancelBooking: m("cancelBooking", "$id: ID!", "...BookingFields"), + + sample: q("sample", "$id: ID!", "...SampleFields"), + sampleByBarcode: q("sampleByBarcode", "$barcode: String!", "...SampleFields"), + collectSample: m("collectSample", "$id: ID!", "...SampleFields"), + receiveSample: m("receiveSample", "$id: ID!", "...SampleFields"), + rejectSample: m("rejectSample", "$id: ID!, $reason: String!", "...SampleFields"), + + report: q("report", "$id: ID!", "...ReportFields"), + createReport: m("createReport", "$bookingId: ID!, $testCode: String!, $testName: String", "...ReportFields"), + saveResults: m("saveResults", "$reportId: ID!, $results: [ResultInput!]!", "...ReportFields"), + verifyReport: m("verifyReport", "$id: ID!", "...ReportFields"), + releaseReport: m("releaseReport", "$id: ID!", "...ReportFields"), + amendReport: m("amendReport", "$id: ID!", "...ReportFields"), + + // ---------------------------------------------------------------- billing + invoices: q( + "invoices", + `$branchId: ID, $patientId: ID, $customerId: ID, $status: InvoiceStatus, ${PAGE}`, + "...InvoiceFields", + ), + invoice: q("invoice", "$id: ID!", "...InvoiceFields"), + issueInvoice: m("issueInvoice", "$input: IssueInvoiceInput!", "...InvoiceFields"), + recordPayment: m("recordPayment", "$invoiceId: ID!, $payment: PaymentInput!", "...InvoiceFields"), + refundPayment: m("refundPayment", "$invoiceId: ID!, $payment: PaymentInput!", "...InvoiceFields"), + cancelInvoice: m("cancelInvoice", "$id: ID!, $reason: String!", "...InvoiceFields"), + + // ---------------------------------------------------------------- books + accountGroups: q("accountGroups", "", "...AccountGroupFields"), + ledgers: q("ledgers", "", "...LedgerFields"), + ledger: q("ledger", "$id: ID!", "...LedgerFields"), + voucher: q("voucher", "$id: ID!", "...VoucherFields"), + ledgerStatement: q("ledgerStatement", `$ledgerId: ID!, ${RANGE}, $branchId: ID`, "...LedgerStatementFields"), + cashBook: q("cashBook", `${RANGE}, $branchId: ID`, "...LedgerStatementFields"), + trialBalance: q( + "trialBalance", + "$asOf: DateTime, $branchId: ID", + "rows { ledger { id name } group { id name } primaryGroup { id name } debitPaise creditPaise } totalDebitPaise totalCreditPaise differencePaise", + ), + profitAndLoss: q( + "profitAndLoss", + `${RANGE}, $branchId: ID`, + "income { ...GroupLineFields } expenses { ...GroupLineFields } grossProfitPaise netProfitPaise", + ), + balanceSheet: q( + "balanceSheet", + "$asOf: DateTime!, $branchId: ID", + "assets { ...GroupLineFields } liabilities { ...GroupLineFields } profitPaise totalAssetsPaise totalLiabilitiesPaise differencePaise", + ), + dayBook: q("dayBook", `${RANGE}, $branchId: ID, $type: VoucherType`, "...VoucherFields"), + outstandingReceivables: q( + "outstandingReceivables", + "$asOf: DateTime, $branchId: ID", + "rows { invoice { id invoiceNo duePaise patient { name } } ageDays bucket } buckets { d0to30 d31to60 d61to90 d90plus } totalDuePaise", + ), + gstSummary: q("gstSummary", `${RANGE}, $branchId: ID`, "taxableValuePaise cgstPaise sgstPaise igstPaise totalTaxPaise"), + setupChartOfAccounts: m("setupChartOfAccounts", "", "...LedgerFields"), + createLedger: m("createLedger", "$input: LedgerInput!", "...LedgerFields"), + postVoucher: m("postVoucher", "$input: PostVoucherInput!", "...VoucherFields"), + cancelVoucher: m("cancelVoucher", "$id: ID!", "...VoucherFields"), + + // ---------------------------------------------------------------- GST + taxRules: q("taxRules", "", "...TaxRuleFields"), + addTaxRule: m("addTaxRule", "$input: TaxRuleInput!", "...TaxRuleFields"), + seedTaxRules: m("seedTaxRules", "", "...TaxRuleFields"), + financeConfig: q( + "financeConfig", + "", + "b2clThresholdPaise tds { section rateBp noPanRateBp thresholdPaise } bankToleranceDays bankMaxFeeBp clearingLedgerKeys", + ), + verifyWithCA: q("verifyWithCA", ""), + gstr1: q( + "gstr1", + `${RANGE}, $gstin: String`, + `gstin period { from to } + b2b { ...Gstr1InvoiceFields } b2cl { ...Gstr1InvoiceFields } + b2cs { supplyType placeOfSupply ratePercent taxablePaise igstPaise cgstPaise sgstPaise cessPaise } + cdnr { ...Gstr1NoteFields } cdnur { ...Gstr1NoteFields } + nil { description nilRatedPaise exemptedPaise nonGstPaise } + hsn { b2b { ...HsnRowFields } b2c { ...HsnRowFields } } + docs { nature series from to total cancelled net } + totals { ...TaxTotalsFields } verifyWithCA`, + ), + gstr3b: q( + "gstr3b", + `${RANGE}, $gstin: String`, + `gstin period { from to } outwardTaxable { ...TaxTotalsFields } outwardNilExemptPaise outwardNonGstPaise + interStateUnregistered { placeOfSupply taxablePaise igstPaise } verifyWithCA`, + ), + + // ---------------------------------------------------------------- receivables (B2B) + customers: q("customers", "$activeOnly: Boolean", "...CustomerFields"), + customer: q("customer", "$id: ID!", "...CustomerFields"), + createCustomer: m("createCustomer", "$input: CustomerInput!", "...CustomerFields"), + updateCustomer: m("updateCustomer", "$id: ID!, $input: CustomerPatch!", "...CustomerFields"), + issueCustomerInvoice: m("issueCustomerInvoice", "$input: CustomerInvoiceInput!", "...InvoiceFields"), + customerStatement: q( + "customerStatement", + `$customerId: ID!, ${RANGE}`, + `customer { ...CustomerFields } openingPaise rows { ...StatementRowFields } totalDebitPaise totalCreditPaise closingPaise + open { invoiceId invoiceNo day dueDay totalPaise paidPaise creditedPaise duePaise overdueDays bucket } + ageing { ...AgeingFields } openTotalPaise availableCreditPaise`, + ), + receivablesSummary: q( + "receivablesSummary", + "$asOf: DateTime!", + "rows { customer { id code name } balancePaise overduePaise ageing { ...AgeingFields } } totalPaise", + ), + creditNotes: q("creditNotes", `$customerId: ID, $invoiceId: ID, ${PAGE}`, "...CreditNoteFields"), + creditNote: q("creditNote", "$id: ID!", "...CreditNoteFields"), + issueCreditNote: m( + "issueCreditNote", + "$input: CreditNoteInput!", + "creditNote { ...CreditNoteFields } invoice { ...InvoiceFields } refundablePaise", + ), + + // ---------------------------------------------------------------- partners and settlements + partners: q("partners", "$branchId: ID", "...PartnerFields"), + partner: q("partner", "$id: ID!", "...PartnerFields"), + createPartner: m("createPartner", "$input: PartnerInput!", "...PartnerFields"), + updatePartner: m("updatePartner", "$id: ID!, $input: PartnerPatch!", "...PartnerFields"), + addAgreement: m("addAgreement", "$input: AgreementInput!", "...AgreementFields"), + settlementPreview: q( + "settlementPreview", + "$partnerId: ID!, $period: String!, $deductions: [SettlementDeductionInput!]", + `period from to lines { ...SettlementLineFields } grossPaise sharePaise deductions { label amountPaise ledgerId } + deductionsPaise tds { section rateBp basePaise tdsPaise } netPaise uncoveredInvoices partner { id code name }`, + ), + settlements: q( + "settlements", + `$branchId: ID, $partnerId: ID, $status: SettlementStatus, ${PAGE}`, + "...SettlementFields", + ), + settlement: q("settlement", "$id: ID!", "...SettlementFields"), + createSettlement: m( + "createSettlement", + "$partnerId: ID!, $period: String!, $deductions: [SettlementDeductionInput!]", + "...SettlementFields", + ), + accrueSettlement: m("accrueSettlement", "$id: ID!", "...SettlementFields"), + paySettlement: m("paySettlement", "$input: PaySettlementInput!", "...SettlementFields"), + cancelSettlement: m("cancelSettlement", "$id: ID!", "...SettlementFields"), + + // ---------------------------------------------------------------- cash and bank + cashClosings: q("cashClosings", `$branchId: ID!, ${PAGE}`, "...CashClosingFields"), + closeCashDay: m("closeCashDay", "$input: CloseCashDayInput!", "...CashClosingFields"), + bankStatements: q("bankStatements", `$ledgerId: ID, ${PAGE}`, "...BankStatementFields"), + bankLines: q( + "bankLines", + `$ledgerId: ID, $statementId: ID, $status: BankLineStatus, ${PAGE}`, + "...BankLineFields", + ), + importBankStatement: m( + "importBankStatement", + "$input: BankImportInput!", + "added skipped statement { ...BankStatementFields }", + ), + reconcileBank: m("reconcileBank", "$ledgerId: ID!, $statementId: ID", "matched unmatched settlements"), + matchBankLine: m("matchBankLine", "$lineId: ID!, $voucherIds: [ID!]!", "...BankLineFields"), + setBankLineStatus: m("setBankLineStatus", "$lineId: ID!, $status: BankLineManualStatus!", "...BankLineFields"), + bankReconciliation: q( + "bankReconciliation", + "$ledgerId: ID!, $asOf: DateTime!", + `bookPaise bankPaise paymentsNotPresentedPaise depositsNotCreditedPaise bankCreditsNotInBooksPaise + bankDebitsNotInBooksPaise expectedBankPaise differencePaise reconciled { lines amountPaise } + unmatchedLines { ...BankLineFields } + unreconciledEntries { id voucherId voucherType voucherNo date debitPaise creditPaise invoiceId }`, + ), + + // ---------------------------------------------------------------- periods + lockDates: q("lockDates", "", "...PeriodLockFields"), + setLockDate: m("setLockDate", "$input: LockDateInput!", "...PeriodLockFields"), + closeFinancialYear: m("closeFinancialYear", "$fy: String!", "netProfitPaise vouchers { ...VoucherFields }"), + + // ---------------------------------------------------------------- CSV exports + exportTrialBalance: q("exportTrialBalance", `$asOf: DateTime, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportProfitAndLoss: q("exportProfitAndLoss", `${RANGE}, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportBalanceSheet: q("exportBalanceSheet", `$asOf: DateTime!, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportDayBook: q("exportDayBook", `${RANGE}, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportLedger: q("exportLedger", `$ledgerId: ID!, ${RANGE}, $branchId: ID, ${CSV}`, "...CsvFileFields"), + exportGstr1: q("exportGstr1", `${RANGE}, $gstin: String, ${CSV}`, "...CsvFileFields"), + exportGstr3b: q("exportGstr3b", `${RANGE}, $gstin: String, ${CSV}`, "...CsvFileFields"), + exportSettlement: q("exportSettlement", `$settlementId: ID!, ${CSV}`, "...CsvFileFields"), + + // ---------------------------------------------------------------- documents (PDF as base64, or HTML) + reportTemplates: q( + "reportTemplates", + "", + "templates { id name category specimen method fields { key label unit range } } profiles { id name tests }", + ), + priceList: q("priceList", "", "code name category rate taxPercent hsn"), + documentSamples: q("documentSamples", "", "id label group"), + invoiceSamples: q("invoiceSamples", "", "id label group"), + renderReport: q( + "renderReport", + "$request: JSON!, $format: DocumentFormat, $variant: ReportVariant, $design: ReportDesign, $fileName: String", + "...DocumentFields", + ), + renderInvoice: q( + "renderInvoice", + "$request: JSON!, $format: DocumentFormat, $variant: ReportVariant, $fileName: String", + "...DocumentFields", + ), + invoiceTotals: q("invoiceTotals", "$request: JSON!"), + letterhead: q("letterhead", "$format: DocumentFormat", "...DocumentFields"), + invoiceDocument: q( + "invoiceDocument", + "$invoiceId: ID!, $format: DocumentFormat, $variant: ReportVariant", + "...DocumentFields", + ), + bookingReportDocument: q( + "bookingReportDocument", + "$bookingId: ID!, $reportIds: [ID!], $format: DocumentFormat, $variant: ReportVariant, $design: ReportDesign", + "...DocumentFields", + ), +} satisfies Record; + +export type OperationName = keyof typeof OPERATIONS; diff --git a/src/graphql/transport.ts b/src/graphql/transport.ts new file mode 100644 index 0000000..fb72839 --- /dev/null +++ b/src/graphql/transport.ts @@ -0,0 +1,83 @@ +// The GraphQL transport: one POST per operation to the backend's single endpoint (mgpl-diagnostics/api). + +export interface GraphQLClientOptions { + /** The endpoint: "/graphql" (same origin) or "https://api.your-lab.in/graphql". */ + url?: string; + /** Custom fetch (tests, SSR, retry wrappers). */ + fetch?: typeof fetch; + /** Starting session token (e.g. read from your own storage). `login` sets it; `logout` clears it. */ + token?: string | null; + /** Called whenever the token changes, so the app can persist it where it wants (or nowhere). */ + onToken?: (token: string | null) => void; + /** Extra headers on every request. */ + headers?: HeadersInit | (() => HeadersInit | Promise); + /** e.g. "include" when the API sits behind a cookie-forwarding proxy. */ + credentials?: RequestCredentials; +} + +/** One GraphQL error as the server sends it. `code` is the stable part (see the API's docs/05-errors.md). */ +export interface GraphQLErrorItem { + message: string; + path?: (string | number)[]; + extensions?: { code?: string; [key: string]: unknown }; +} + +/** + * Thrown when a response carries errors (or the HTTP request fails). `code` is the first error's + * code: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, CONFLICT, BAD_USER_INPUT, PERIOD_LOCKED, … . + */ +export class MgplGraphQLError extends Error { + readonly code: string; + constructor( + message: string, + readonly errors: GraphQLErrorItem[], + readonly status: number, + /** Whatever data came back next to the errors. */ + readonly data?: unknown, + ) { + super(message); + this.name = "MgplGraphQLError"; + this.code = errors[0]?.extensions?.code ?? (status >= 500 ? "INTERNAL_SERVER_ERROR" : "NETWORK_ERROR"); + } +} + +export type Variables = Record; + +export function createTransport(opts: GraphQLClientOptions = {}) { + const url = opts.url ?? "/graphql"; + const doFetch = opts.fetch ?? ((...a: Parameters) => fetch(...a)); + let token: string | null = opts.token ?? null; + + return { + url, + getToken: () => token, + setToken(next: string | null) { + token = next; + opts.onToken?.(next); + }, + /** Runs any operation. Throws MgplGraphQLError on errors; returns `data` otherwise. */ + async request(query: string, variables?: Variables, init: { signal?: AbortSignal } = {}): Promise { + const headers = new Headers(typeof opts.headers === "function" ? await opts.headers() : opts.headers); + headers.set("content-type", "application/json"); + headers.set("accept", "application/graphql-response+json, application/json"); + if (token) headers.set("authorization", `Bearer ${token}`); + let res: Response; + try { + res = await doFetch(url, { method: "POST", headers, credentials: opts.credentials, body: JSON.stringify({ query, variables }), signal: init.signal }); + } catch (e) { + throw new MgplGraphQLError((e as Error).message || "Network error", [], 0); + } + let body: { data?: T | null; errors?: GraphQLErrorItem[] } | undefined; + try { + body = (await res.json()) as typeof body; + } catch { + throw new MgplGraphQLError(`${res.status} ${res.statusText}`.trim(), [], res.status); + } + if (body?.errors?.length) throw new MgplGraphQLError(body.errors.map((e) => e.message).join("; "), body.errors, res.status, body.data); + if (!res.ok || body?.data == null) throw new MgplGraphQLError(`${res.status} ${res.statusText}`.trim(), [], res.status); + return body.data; + }, + }; +} + +export type Transport = ReturnType; diff --git a/src/graphql/types.ts b/src/graphql/types.ts new file mode 100644 index 0000000..aeef7ed --- /dev/null +++ b/src/graphql/types.ts @@ -0,0 +1,197 @@ +// What the client's operations return: the API's GraphQL types (mgpl-diagnostics/api, schema.graphql) +// as selected by src/graphql/operations.ts. Money is integer paise (Rs 1 = 100). DateTime values are +// ISO strings; Day values are "YYYY-MM-DD" (IST). The API's contract test validates every operation. + +export type ID = string; +export type DateTime = string; +export type Day = string; + +export type Sex = "male" | "female" | "other"; +export type PaymentMode = "cash" | "upi" | "card" | "netbanking" | "wallet" | "cheque" | "insurance"; +export type ItemCategory = "test" | "package" | "service" | "consumable" | "medicine" | "other"; +export type Taxability = "taxable" | "exempt" | "nil_rated" | "non_gst"; +export type VoucherType = "sales" | "receipt" | "payment" | "journal" | "contra" | "credit_note" | "debit_note" | "purchase" | "closing"; +export type BranchKind = "lab" | "collection_center"; +export type BookingStatus = "booked" | "sample_collected" | "in_process" | "completed" | "cancelled"; +export type CollectionType = "walk_in" | "home"; +export type SampleStatus = "pending" | "collected" | "received" | "rejected"; +export type ReportStatus = "draft" | "entered" | "verified" | "released" | "amended"; +/** "NONE" = within range. */ +export type ResultFlag = "L" | "H" | "A" | "NONE"; +export type InvoiceStatus = "draft" | "issued" | "partially_paid" | "paid" | "cancelled" | "refunded" | "credited"; +export type DocumentFormat = "pdf" | "html"; +export type ReportVariant = "header" | "non_header"; +export type ReportDesign = "sidebar" | "classic"; +export type CustomerKind = "hospital" | "insurer" | "tpa" | "corporate" | "other"; +export type PartnerKind = "collection_centre" | "franchise"; +export type AgreementKind = "revenue_share" | "fixed_rate"; +export type SettlementStatus = "draft" | "accrued" | "paid" | "cancelled"; +export type BankLineStatus = "unmatched" | "matched" | "ignored"; + +export interface Address { line1?: string | null; line2?: string | null; city?: string | null; district?: string | null; state?: string | null; pincode?: string | null } +export interface Ref { id: ID; name: string } +export interface BranchRef { id: ID; code: string; name: string } + +// ------------------------------------------------------------------ auth, organisation + +export interface RoleAssignment { role: string; branchId: ID | null } +export interface User { id: ID; name: string; email: string; phone: string | null; status: "active" | "disabled" | "invited"; roles: RoleAssignment[]; lastLoginAt: DateTime | null; createdAt: DateTime } +export interface Role { id: ID; key: string; name: string; permissions: string[]; system: boolean } +export interface Session { id: ID; current: boolean; createdAt: DateTime; lastSeenAt: DateTime; expiresAt: DateTime; ip: string | null; userAgent: string | null } +export interface LoginResult { token: string; expiresAt: DateTime; user: User } +export interface Branch { id: ID; code: string; name: string; kind: BranchKind; phone: string | null; gstin: string | null; stateCode: string | null; address: Address | null; active: boolean } +export interface Organization { id: ID; name: string; code: string; legalName: string | null; gstin: string | null; phone: string | null; email: string | null; address: Address | null; branches: Branch[] } +export interface Viewer { user: User; organization: Organization; permissions: string[] } +export interface AuditEntry { id: ID; action: string; entity: string | null; entityId: ID | null; branchId: ID | null; user: { id: ID; name: string } | null; at: DateTime } +export interface Health { ok: boolean; service: string; version: string; database: string; time: DateTime } + +// ------------------------------------------------------------------ catalog + +export interface CatalogParameter { name: string | null; unit: string | null; method: string | null; type: "numeric" | "text" | "options" | "image" | "title"; options: string[] | null; calculated: boolean; formulaText: string | null; referenceText: string | null; range: string } +export interface CatalogTest { id: number; name: string; code: string | null; shortCode: string | null; department: string; sampleType: string | null; mrp: number | null; pricePaise: number | null; isProfile: boolean; parameterCount: number } +export interface CatalogTestDetail extends CatalogTest { secondarySampleTypes: string[] | null; parameters: CatalogParameter[] } +export interface CatalogHit { test: CatalogTest; score: number } +export interface PriceQuote { total: number; totalPaise: number; tests: CatalogTest[]; unknown: string[] } + +// ------------------------------------------------------------------ lab + +export interface Patient { id: ID; uhid: string; title: string | null; name: string; sex: Sex; dob: DateTime | null; ageYears: number | null; phone: string | null; email: string | null; address: Address | null; createdAt: DateTime; branch: BranchRef | null } +export interface Doctor { id: ID; name: string; qualification: string | null; phone: string | null; clinic: string | null; active: boolean } +export interface Sample { id: ID; barcode: string; sampleType: string; status: SampleStatus; collectedAt: DateTime | null; receivedAt: DateTime | null; rejectReason: string | null } +export interface ResultValue { parameter: string; value: string; unit: string | null; flag: ResultFlag | null; range: string | null } +export interface Report { id: ID; testCode: string; testName: string; status: ReportStatus; version: number; verifiedAt: DateTime | null; releasedAt: DateTime | null; updatedAt: DateTime; results: ResultValue[] } +export interface BookingTest { testCode: string; name: string; pricePaise: number; sampleType: string | null } +export interface Booking { + id: ID; bookingNo: string; status: BookingStatus; notes: string | null; createdAt: DateTime; + patient: { id: ID; uhid: string; name: string }; doctor: Ref | null; branch: BranchRef; + tests: BookingTest[]; sampleCollection: { type: CollectionType; scheduledAt: DateTime | null }; + samples: Sample[]; reports: { id: ID; testCode: string; testName: string; status: ReportStatus }[]; + invoice: { id: ID; invoiceNo: string; status: InvoiceStatus; totalPaise: number; duePaise: number } | null; +} + +// ------------------------------------------------------------------ billing + +export interface InvoiceItem { code: string | null; name: string; category: ItemCategory; qty: number; ratePaise: number; discountPaise: number; taxPercent: number; hsnSac: string | null; taxability: Taxability | null; amountPaise: number } +export interface Payment { id: ID; kind: "payment" | "refund"; amountPaise: number; mode: PaymentMode; reference: string | null; receivedAt: DateTime } +export interface Invoice { + id: ID; invoiceNo: string; status: InvoiceStatus; placeOfSupply: string | null; buyerGstin: string | null; + subtotalPaise: number; discountPaise: number; taxPaise: number; roundOffPaise: number; totalPaise: number; paidPaise: number; creditedPaise: number; duePaise: number; + issuedAt: DateTime | null; cancelReason: string | null; createdAt: DateTime; + patient: { id: ID; uhid: string; name: string }; branch: BranchRef; customer: { id: ID; code: string; name: string } | null; + items: InvoiceItem[]; payments: Payment[]; +} + +// ------------------------------------------------------------------ books + +export type AccountNature = "assets" | "liabilities" | "income" | "expenses"; +export interface AccountGroup { id: ID; name: string; nature: AccountNature; affectsGrossProfit: boolean; parent: { id: ID; name: string } | null } +export interface Ledger { id: ID; name: string; systemKey: string | null; gstin: string | null; openingPaise: number; active: boolean; group: { id: ID; name: string; nature: AccountNature } } +export interface VoucherEntry { ledger: { id: ID; name: string }; debitPaise: number; creditPaise: number; invoiceId: ID | null } +export interface Voucher { id: ID; type: VoucherType; voucherNo: string; date: DateTime; narration: string | null; status: "posted" | "cancelled"; invoiceId: ID | null; entries: VoucherEntry[] } +export interface StatementRow { date: DateTime; voucherNo: string; voucherType: VoucherType; debitPaise: number; creditPaise: number; balancePaise: number; invoiceId: ID | null } +export interface LedgerStatement { ledger: { id: ID; name: string }; openingPaise: number; closingPaise: number; totalDebitPaise: number; totalCreditPaise: number; rows: StatementRow[] } +export interface TrialBalance { rows: { ledger: { id: ID; name: string }; group: { id: ID; name: string }; primaryGroup: { id: ID; name: string }; debitPaise: number; creditPaise: number }[]; totalDebitPaise: number; totalCreditPaise: number; differencePaise: number } +export interface GroupLine { group: { id: ID; name: string }; amountPaise: number; ledgers: { ledger: { id: ID; name: string }; amountPaise: number }[] } +export interface ProfitAndLoss { income: GroupLine[]; expenses: GroupLine[]; grossProfitPaise: number; netProfitPaise: number } +export interface BalanceSheet { assets: GroupLine[]; liabilities: GroupLine[]; profitPaise: number; totalAssetsPaise: number; totalLiabilitiesPaise: number; differencePaise: number } +export interface Outstanding { rows: { invoice: { id: ID; invoiceNo: string; duePaise: number; patient: { name: string } }; ageDays: number; bucket: "D0_30" | "D31_60" | "D61_90" | "D90_PLUS" }[]; buckets: { d0to30: number; d31to60: number; d61to90: number; d90plus: number }; totalDuePaise: number } +export interface GstSummary { taxableValuePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number; totalTaxPaise: number } + +// ------------------------------------------------------------------ finance + +export interface TaxRule { id: ID; code: string | null; category: ItemCategory | null; hsnSac: string; taxability: Taxability; ratePercent: number; effectiveFrom: DateTime; effectiveTo: DateTime | null; note: string | null } +export interface FinanceConfig { b2clThresholdPaise: number; tds: { section: string; rateBp: number; noPanRateBp: number; thresholdPaise: number | null }; bankToleranceDays: number; bankMaxFeeBp: number; clearingLedgerKeys: string[] } +export interface TaxTotals { taxablePaise: number; igstPaise: number; cgstPaise: number; sgstPaise: number; cessPaise: number } +export interface RateTotals extends TaxTotals { ratePercent: number } +export interface Gstr1Invoice { invoiceId: ID; invoiceNo: string; day: Day; valuePaise: number; placeOfSupply: string; reverseCharge: string; ctin: string | null; items: RateTotals[] } +export interface Gstr1Note { noteId: ID; noteNo: string; day: Day; noteType: string; invoiceNo: string; invoiceDay: Day; valuePaise: number; placeOfSupply: string; ctin: string | null; type: string | null; items: RateTotals[] } +export interface HsnRow extends TaxTotals { hsnSac: string; ratePercent: number; taxability: Taxability; qty: number; totalValuePaise: number } +export interface Gstr1 { + gstin: string; period: { from: DateTime; to: DateTime }; + b2b: Gstr1Invoice[]; b2cl: Gstr1Invoice[]; + b2cs: (TaxTotals & { supplyType: string; placeOfSupply: string; ratePercent: number })[]; + cdnr: Gstr1Note[]; cdnur: Gstr1Note[]; + nil: { description: string; nilRatedPaise: number; exemptedPaise: number; nonGstPaise: number }[]; + hsn: { b2b: HsnRow[]; b2c: HsnRow[] }; + docs: { nature: string; series: string; from: string; to: string; total: number; cancelled: number; net: number }[]; + totals: TaxTotals; verifyWithCA: string[]; +} +export interface Gstr3b { gstin: string; period: { from: DateTime; to: DateTime }; outwardTaxable: TaxTotals; outwardNilExemptPaise: number; outwardNonGstPaise: number; interStateUnregistered: { placeOfSupply: string; taxablePaise: number; igstPaise: number }[]; verifyWithCA: string[] } + +export interface Customer { id: ID; code: string; name: string; kind: CustomerKind; legalName: string | null; gstin: string | null; stateCode: string | null; pan: string | null; phone: string | null; email: string | null; address: Address | null; creditLimitPaise: number | null; creditDays: number; active: boolean; ledger: { id: ID; name: string } } +export interface Ageing { notDue: number; d1to30: number; d31to60: number; d61to90: number; d90plus: number } +export interface OpenItem { invoiceId: ID; invoiceNo: string; day: Day; dueDay: Day; totalPaise: number; paidPaise: number; creditedPaise: number; duePaise: number; overdueDays: number; bucket: "not_due" | "D1_30" | "D31_60" | "D61_90" | "D90_PLUS" } +export interface CustomerStatement { customer: Customer; openingPaise: number; rows: StatementRow[]; totalDebitPaise: number; totalCreditPaise: number; closingPaise: number; open: OpenItem[]; ageing: Ageing; openTotalPaise: number; availableCreditPaise: number | null } +export interface ReceivablesSummary { rows: { customer: { id: ID; code: string; name: string }; balancePaise: number; overduePaise: number; ageing: Ageing }[]; totalPaise: number } +export interface CreditNoteLine { itemIndex: number; name: string; code: string | null; hsnSac: string | null; taxability: Taxability | null; taxPercent: number; taxablePaise: number; taxPaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number } +export interface CreditNote { id: ID; noteNo: string; date: DateTime; reason: string; status: string; taxablePaise: number; taxPaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number; roundOffPaise: number; totalPaise: number; placeOfSupply: string | null; buyerGstin: string | null; invoice: { id: ID; invoiceNo: string }; customer: { id: ID; code: string; name: string } | null; lines: CreditNoteLine[] } + +export interface PartnerAgreement { id: ID; kind: AgreementKind; shareBp: number | null; rates: { code: string; sharePaise: number }[] | null; defaultSharePaise: number | null; categories: ItemCategory[] | null; effectiveFrom: DateTime; effectiveTo: DateTime | null; notes: string | null } +export interface Partner { id: ID; code: string; name: string; kind: PartnerKind; legalName: string | null; pan: string | null; gstin: string | null; stateCode: string | null; phone: string | null; email: string | null; address: Address | null; tds: { section: string; rateBp: number } | null; active: boolean; branch: BranchRef; ledger: { id: ID; name: string }; agreements: PartnerAgreement[] } +export interface SettlementLine { invoiceId: ID; invoiceNo: string; day: Day; code: string | null; name: string; netPaise: number; sharePaise: number; agreementId: ID; creditNoteId: ID | null } +export interface SettlementDeduction { label: string; amountPaise: number; ledgerId?: ID | null } +export interface SettlementPreview { period: string; from: DateTime; to: DateTime; lines: SettlementLine[]; grossPaise: number; sharePaise: number; deductions: SettlementDeduction[]; deductionsPaise: number; tds: { section: string | null; rateBp: number; basePaise: number; tdsPaise: number }; netPaise: number; uncoveredInvoices: string[]; partner: { id: ID; code: string; name: string } } +export interface Settlement { id: ID; period: string; from: DateTime; to: DateTime; status: SettlementStatus; grossPaise: number; sharePaise: number; deductionsPaise: number; tdsSection: string | null; tdsRateBp: number; tdsBasePaise: number; tdsPaise: number; netPaise: number; paidAt: DateTime | null; createdAt: DateTime; partner: { id: ID; code: string; name: string }; branch: BranchRef; lines: SettlementLine[]; deductions: SettlementDeduction[]; accrualVoucher: { id: ID; voucherNo: string } | null; payoutVoucher: { id: ID; voucherNo: string } | null } + +export interface CashClosing { id: ID; day: Day; bookPaise: number; countedPaise: number; differencePaise: number; denominations: { valuePaise: number; count: number }[] | null; notes: string | null; createdAt: DateTime; branch: BranchRef; voucher: { id: ID; voucherNo: string } | null } +export interface BankStatement { id: ID; fileName: string | null; from: DateTime | null; to: DateTime | null; openingPaise: number | null; closingPaise: number | null; lineCount: number; createdAt: DateTime; branch: BranchRef; ledger: { id: ID; name: string } } +export interface BankLine { id: ID; statementId: ID; date: DateTime; description: string; reference: string | null; debitPaise: number; creditPaise: number; balancePaise: number | null; status: BankLineStatus; matchKind: string | null; voucherIds: ID[]; matchedAt: DateTime | null; settlement: { ledgerId: ID; day: Day; grossPaise: number; feePaise: number } | null } +export interface BankReconciliation { bookPaise: number; bankPaise: number | null; paymentsNotPresentedPaise: number; depositsNotCreditedPaise: number; bankCreditsNotInBooksPaise: number; bankDebitsNotInBooksPaise: number; expectedBankPaise: number; differencePaise: number | null; reconciled: { lines: number; amountPaise: number }; unmatchedLines: BankLine[]; unreconciledEntries: { id: ID; voucherId: ID; voucherType: VoucherType; voucherNo: string; date: DateTime; debitPaise: number; creditPaise: number; invoiceId: ID | null }[] } +export interface PeriodLock { id: ID; scope: string; lockedUpTo: DateTime | null; reason: string | null; updatedAt: DateTime; branch: BranchRef | null } +export interface CsvFile { fileName: string; mimeType: string; content: string } + +// ------------------------------------------------------------------ documents + +/** A rendered PDF (base64) or self-contained HTML. Turn a PDF into a Blob with documentBlob(). */ +export interface RenderedDocument { format: DocumentFormat; fileName: string; mimeType: string; byteLength: number; base64: string | null; html: string | null } +export interface ReportTemplateInfo { id: string; name: string; category: string | null; specimen: string | null; method: string | null; fields: { key: string; label: string; unit: string | null; range: string[] | null }[] } +export interface ReportTemplateCatalog { templates: ReportTemplateInfo[]; profiles: { id: string; name: string; tests: string[] }[] } +export interface PriceListEntry { code: string; name: string; category: string; rate: number; taxPercent: number | null; hsn: string | null } + +// ------------------------------------------------------------------ inputs + +export interface RoleAssignmentInput { role: string; branchId?: ID | null } +export interface CreateUserInput { name: string; email: string; phone?: string; password: string; roles: RoleAssignmentInput[] } +export interface BranchInput { code: string; name: string; kind: BranchKind; phone?: string; gstin?: string; stateCode?: string; address?: Address; active?: boolean } +export interface RegisterPatientInput { branchId: ID; title?: string; name: string; sex: Sex; dob?: DateTime; ageYears?: number; phone?: string; email?: string; address?: Address } +export type UpdatePatientInput = Partial>; +export interface DoctorInput { name: string; qualification?: string; phone?: string; clinic?: string; active?: boolean } +/** A catalog code alone is enough: name, price and sample type come from the test catalog. */ +export interface BookingTestInput { testCode: string; name?: string; pricePaise?: number; sampleType?: string } +export interface CreateBookingInput { branchId: ID; patientId: ID; doctorId?: ID; tests: BookingTestInput[]; collectionType?: CollectionType; scheduledAt?: DateTime; notes?: string } +/** Unit, range and flag are filled from the catalog when omitted. */ +export interface ResultInput { parameter: string; value: string; unit?: string; flag?: ResultFlag; range?: string } +/** A catalog code alone is enough: name and MRP come from the test catalog. */ +export interface InvoiceItemInput { code?: string; name?: string; category?: ItemCategory; qty?: number; ratePaise?: number; discountPaise?: number; taxPercent?: number; hsnSac?: string; taxability?: Taxability } +export interface PaymentInput { amountPaise: number; mode: PaymentMode; reference?: string } +export interface IssueInvoiceInput { branchId: ID; patientId: ID; bookingId?: ID; items: InvoiceItemInput[]; billDiscountPaise?: number; payments?: PaymentInput[]; applyTaxRules?: boolean } +export interface PostVoucherInput { branchId: ID; type: VoucherType; date?: DateTime; narration?: string; entries: { ledgerId: ID; debitPaise?: number; creditPaise?: number; invoiceId?: ID }[] } +export interface LedgerInput { name: string; groupId: ID; openingPaise?: number; gstin?: string } +export interface TaxRuleInput { code?: string; category?: ItemCategory; hsnSac: string; taxability: Taxability; ratePercent: number; effectiveFrom: Day; note?: string } +export interface CustomerInput { code: string; name: string; kind: CustomerKind; legalName?: string; gstin?: string; stateCode?: string; pan?: string; phone?: string; email?: string; address?: Address; creditLimitPaise?: number | null; creditDays?: number } +export type CustomerPatch = Partial> & { active?: boolean }; +export interface CustomerInvoiceInput extends Omit { customerId: ID; placeOfSupply?: string; applyTaxRules?: boolean } +export interface CreditNoteInput { invoiceId: ID; reason: string; lines?: { itemIndex: number; taxablePaise?: number }[]; date?: DateTime } +export interface PartnerInput { code: string; name: string; kind: PartnerKind; branchId: ID; legalName?: string; pan?: string; gstin?: string; stateCode?: string; phone?: string; email?: string; address?: Address; tds?: { section: string; rateBp: number } } +export type PartnerPatch = Partial> & { tds?: { section: string; rateBp: number } | null; active?: boolean }; +export interface AgreementInput { partnerId: ID; kind: AgreementKind; shareBp?: number; rates?: { code: string; sharePaise: number }[]; defaultSharePaise?: number; categories?: ItemCategory[]; effectiveFrom: Day; effectiveTo?: Day; notes?: string } +export interface CloseCashDayInput { branchId: ID; day: Day; countedPaise: number; denominations?: { valuePaise: number; count: number }[]; notes?: string } +export interface BankImportInput { branchId: ID; ledgerId: ID; csv: string; fileName?: string } +export interface LockDateInput { branchId?: ID; upTo: Day | null; reason?: string } +export interface RenderOptions { format?: DocumentFormat; variant?: ReportVariant; design?: ReportDesign; fileName?: string } + +// ------------------------------------------------------------------ mutation results + +export interface CreateBookingResult { booking: Booking; samples: Sample[] } +export interface CreditNoteResult { creditNote: CreditNote; invoice: Invoice; refundablePaise: number } +export interface BankImportResult { added: number; skipped: number; statement: BankStatement } +export interface ReconcileResult { matched: number; unmatched: number; settlements: number } +export interface YearEndResult { netProfitPaise: number; vouchers: Voucher[] } +export interface DocumentSample { id: string; label: string; group: string | null } +export interface LoginInput { email: string; password: string; orgCode?: string } +export interface RoleInput { key: string; name: string; permissions: string[] } +export type OrganizationInput = Partial> & { address?: Address }; +export type BranchPatch = Partial>; +export type DoctorPatch = Partial; +export interface SettlementDeductionInput { label: string; amountPaise: number; ledgerId?: ID } +export interface PaySettlementInput { settlementId: ID; mode: PaymentMode; reference?: string; date?: DateTime } diff --git a/src/index.ts b/src/index.ts index 76762ed..e55b0dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,21 @@ import type { export * from "./types"; +// The GraphQL SDK for mgpl-diagnostics/api (one endpoint for everything). Its types are under `Gql` +// here, or flat in "@mgpl-diagnostics/client/graphql". +export { + createMgplGraphQLClient, + documentBlob, + csvBlob, + createTransport, + MgplGraphQLError, + OPERATIONS, + FRAGMENTS, + withFragments, +} from "./graphql/index"; +export type { MgplGraphQLClient, GraphQLClientOptions, GraphQLErrorItem, Operation, OperationName } from "./graphql/index"; +export type * as Gql from "./graphql/types"; + /** Default mount point of the backend */ export const DEFAULT_BASE_URL = "/api/mgpl"; diff --git a/tsup.config.ts b/tsup.config.ts index 5b9e845..29c9c7c 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig([ // plain SDK — usable from server components, workers, Node scripts, any framework - { entry: { index: "src/index.ts" }, format: ["esm"], dts: true, target: "es2020", platform: "browser", clean: true }, + { entry: { index: "src/index.ts", graphql: "src/graphql/index.ts" }, format: ["esm"], dts: true, target: "es2020", platform: "browser", clean: true }, // React hooks — marked as a client module for Next.js { entry: { react: "src/react.tsx" },