From 6c70eb8eb35cd36eccb20eec8292edfe40d21e66 Mon Sep 17 00:00:00 2001 From: officer Date: Wed, 19 Aug 2026 03:47:44 +0000 Subject: [PATCH] invoices, extracted from the platform as a plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth plugin out, third resurrection: switched off since 2026-08-13 with the permission reduced to api: [], so the server booted clean and every panel request 404'd. invoiceshelf/schema was also commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the frontend route, the table prefix and the install key all at once, and before extraction those had drifted: the API was /api/invoiceshelf while the screen was /invoices. The URL is what a person sees and should name what the plugin shows rather than the daemon behind it — the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts to satisfy the table-prefix rule. That rename was free ONLY because the table existed on no server, which was checked before deciding. With rows in it, keeping the vendor name would have been the better trade: db:push sees a rename as a drop plus a create and this project has no migration mechanism. The sidecar keeps its Invoiceshelf-named functions deliberately — they name the upstream API it actually speaks. 91 palette classes converted, 52 of them in STATUS_TONE, which was already the single source of status colour but held raw palette strings. SENT and VIEWED now share `info`: the closed six-tone set has no second shade of "in flight, not yet actioned". Recorded in PLUGIN.md as a real loss. --- .gitignore | 1 + PLUGIN.md | 93 +++++ README.md | 32 ++ api/router.ts | 20 + db/index.ts | 12 + db/queries.ts | 212 +++++++++++ db/schema.ts | 58 +++ manifest.ts | 48 +++ sidecar/config.ts | 326 ++++++++++++++++ sidecar/index.ts | 177 +++++++++ sidecar/routes.ts | 322 ++++++++++++++++ sidecar/upstream.ts | 202 ++++++++++ web/AccountSwitcher.tsx | 88 +++++ web/ConfirmDialog.tsx | 50 +++ web/ConnectionSection.tsx | 449 +++++++++++++++++++++++ web/CustomersListView.tsx | 348 ++++++++++++++++++ web/DashboardView.tsx | 279 ++++++++++++++ web/DocumentEditor.tsx | 732 ++++++++++++++++++++++++++++++++++++ web/EstimatesListView.tsx | 407 ++++++++++++++++++++ web/ExpensesListView.tsx | 313 ++++++++++++++++ web/InvoicesListView.tsx | 406 ++++++++++++++++++++ web/InvoicesNav.tsx | 121 ++++++ web/InvoicesView.tsx | 154 ++++++++ web/InvoicesViewHeader.tsx | 36 ++ web/ItemsListView.tsx | 262 +++++++++++++ web/PaymentsListView.tsx | 302 +++++++++++++++ web/PdfPane.tsx | 91 +++++ web/RecordEditors.tsx | 734 +++++++++++++++++++++++++++++++++++++ web/RecurringListView.tsx | 322 ++++++++++++++++ web/ReportsView.tsx | 211 +++++++++++ web/SendDocumentDialog.tsx | 147 ++++++++ web/components.tsx | 282 ++++++++++++++ web/format.ts | 152 ++++++++ web/layout.ts | 11 + web/panels.ts | 24 ++ web/shared.ts | 464 +++++++++++++++++++++++ web/useInvoiceShelfData.ts | 574 +++++++++++++++++++++++++++++ web/useInvoicesSection.ts | 94 +++++ 38 files changed, 8556 insertions(+) create mode 100644 .gitignore create mode 100644 PLUGIN.md create mode 100644 README.md create mode 100644 api/router.ts create mode 100644 db/index.ts create mode 100644 db/queries.ts create mode 100644 db/schema.ts create mode 100644 manifest.ts create mode 100644 sidecar/config.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/routes.ts create mode 100644 sidecar/upstream.ts create mode 100644 web/AccountSwitcher.tsx create mode 100644 web/ConfirmDialog.tsx create mode 100644 web/ConnectionSection.tsx create mode 100644 web/CustomersListView.tsx create mode 100644 web/DashboardView.tsx create mode 100644 web/DocumentEditor.tsx create mode 100644 web/EstimatesListView.tsx create mode 100644 web/ExpensesListView.tsx create mode 100644 web/InvoicesListView.tsx create mode 100644 web/InvoicesNav.tsx create mode 100644 web/InvoicesView.tsx create mode 100644 web/InvoicesViewHeader.tsx create mode 100644 web/ItemsListView.tsx create mode 100644 web/PaymentsListView.tsx create mode 100644 web/PdfPane.tsx create mode 100644 web/RecordEditors.tsx create mode 100644 web/RecurringListView.tsx create mode 100644 web/ReportsView.tsx create mode 100644 web/SendDocumentDialog.tsx create mode 100644 web/components.tsx create mode 100644 web/format.ts create mode 100644 web/layout.ts create mode 100644 web/panels.ts create mode 100644 web/shared.ts create mode 100644 web/useInvoiceShelfData.ts create mode 100644 web/useInvoicesSection.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..91c4fc4 --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,93 @@ +# Invoices — the extraction record + +Extracted from the platform on **2026-08-19**, the ninth plugin out, after jellyfin. +`plugins/EXTRACTING-A-PLUGIN.md` is the runbook. + +## Dead on arrival, like the two before it + +`hono.ts` carried the router import and its `PROTECTED_MOUNTS` entry commented out +(`// plugin — switched off 2026-08-13`) and the permission had been reduced to `api: []`, so +`assertPermissionTotality` was satisfied, the server booted clean, and every panel request 404'd. Same +data hazard too: `invoiceshelf/schema` was commented out of the drizzle barrel. + +## The naming decision, which is the interesting part + +Before extraction the API was **`/api/invoiceshelf`** and the screen was **`/invoices`**. Those had +drifted apart harmlessly while they were separate hardcoded strings. A plugin cannot hold both: +`appName` is THE id — API prefix, frontend route, table prefix, sidecar suffix and install key all +derive from it. + +So one of them had to move, and the choice was: + +| appName | URL | API | Tables | +| -------------- | -------------- | -------------------- | ------------------------------- | +| `invoiceshelf` | `/invoiceshelf` ❌ | `/api/invoiceshelf` ✓ | `invoiceshelf_accounts` ✓ | +| `invoices` | `/invoices` ✓ | `/api/invoices` (3 sites) | rename to `invoices_accounts` | + +**`invoices` won**, for two reasons. The URL is the part a person sees, and it should name what the +thing shows rather than the vendor behind it — the permission key and the label were already +`invoices`, so the API prefix was the odd one out rather than the route. And the cost was small: +three call sites, all in `useInvoiceShelfData.ts`, plus a table rename. + +**The rename was free only because the table existed on no server** — checked before deciding, not +after. `invoiceshelf_accounts` did not exist in the live database, so there were no rows to migrate. +Had there been any, keeping the vendor name and living with `/invoiceshelf` would have been the better +trade: `bun db:push` sees a rename as a drop plus a create, so a table with data would have needed a +hand-written `ALTER TABLE … RENAME TO` before the push, and that is a migration this project has no +mechanism for. + +## What moved + +| Was | Is now | +| ------------------------------------------------------------ | --------------- | +| `src/servers/sidecar/invoiceshelf/*` (4 files) | `sidecar/*` | +| `src/servers/api/invoiceshelf/router.ts` | `api/router.ts` | +| `src/databases/officer_db/src/invoiceshelf/*` (3 files) | `db/*` | +| `src/workspaces/officerdev/src/apps/Invoices/*` (25 files) | `web/*` | +| `src/apps/officer-web/Screens/Dashboard/Invoices/*` | `web/layout.ts` | + +The `invoiceshelf*` symbols inside `db/` were renamed to `invoices*` with the table, so the schema +file and the SQL agree. The SIDECAR keeps its `Invoiceshelf`-named functions — those name the upstream +API it speaks, which really is InvoiceShelf, and renaming them would have made the sidecar lie about +what it talks to. + +## Self-contained + +`invoices_accounts` is a registry this plugin owns outright — label, URL, encrypted credential, one +active — like photos and jellyfin, and for the same reason: one person really does keep more than one +company's books. No core table, no platform union to extend. + +## Fixtures + +`invoiceshelf` was a fixture in `members.test.ts` — re-anchored an hour earlier when jellyfin left, and +broken again here. It now names `caldav` and `memos`, both services with **no extraction planned**, +which is the actual fix for a test that has been re-pointed four times. + +## Not carried over + +`composeTemplate` and `modes: ['existing', 'provisioned']` died with the catalogue entry. Like every +plugin so far, this is **point-at-your-own-instance only** until plugin Docker provisioning exists. + +## One status distinction the tone set could not keep + +`STATUS_TONE` in `web/shared.ts` was already the single source for status colour, but as raw palette +classes — 52 of this plugin's 91 palette occurrences were in that one map. It is now +`Record`, read only by `StatusBadge`. + +**`SENT` and `VIEWED` both resolve to `info`, and upstream had them as distinct blues.** They are real, +ordered states, and the closed six-tone set has no second shade of "in flight, not yet actioned" — +`busy` would misrepresent *viewed* as in progress. The badge text still distinguishes them; only the +colour is shared. Recorded here because it is a genuine loss rather than a tidy-up, and because the +right fix, if it matters, is a seventh tone rather than a local exception. + +Two judgement calls in the same map, both reversible one-liners: + +- `PARTIALLY_PAID` → `busy`, not `warning`, because it renders in the same cell as `UNPAID` and + flattening both to warning would destroy a live distinction. +- `EXPIRED` → `warning` while `REJECTED` → `danger`: an estimate that lapsed can be re-issued, one that + was declined is over. + +The dashboard chart's three hard-coded hexes moved with the legend dots that label them, to +`var(--info)` / `var(--success)` / `var(--destructive)`. Those resolve from `:root` in every theme +(only 3 of 14 themes override `--info`/`--success`; all 14 define `--destructive`). **Not verified in a +browser.** diff --git a/README.md b/README.md new file mode 100644 index 0000000..81f9882 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Invoices + +Your [InvoiceShelf](https://invoiceshelf.com) books, inside Officer — invoices, estimates, customers and +payments, with the credentials held server-side. + +## What you get + +- **Invoices** — list, filter by status, open one and see its lines +- **Estimates** — the same, before they become invoices +- **Customers** and their history +- **Payments** recorded against invoices +- **Multiple companies** — add several sets of books and switch between them + +## Requirements + +A running InvoiceShelf instance Officer can reach, and credentials for it. + +## Setup + +Install the plugin, open **/invoices**, and add an account: a label, the URL, and the credentials. They +are encrypted at rest and never sent to the browser. Mark one active. + +The plugin does **not** install or supervise InvoiceShelf. Point it at one you already run. + +## Permissions + +One permission, `invoices`, grantable per role like any other app permission. + +## Notes + +- The plugin is named **`invoices`**, not `invoiceshelf` — named for what it shows rather than the + daemon behind it, which is what keeps the URL `/invoices`. See `PLUGIN.md`. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..dca5849 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,20 @@ +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; + +// /api/invoiceshelf/* — auth, then forward to officer-invoiceshelf. No routes of its own and no invoiceshelf knowledge: +// this file must never grow app logic. +// +// The sidecar owns the InvoiceShelf contract and holds its credentials. + +const proxy = createSidecarProxy({ + name: 'invoiceshelf', + // DERIVED, never written out. Note the appName is `invoices`, not `invoiceshelf`: the plugin is named + // for the thing it shows rather than the daemon behind it, which is what keeps the URL `/invoices`. + prefix: `/api${mountPrefix({ appName: 'invoices', manifest })}`, +}); + +export const router = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getInvoiceshelfServerUrl = proxy.getHttpUrl; diff --git a/db/index.ts b/db/index.ts new file mode 100644 index 0000000..257db90 --- /dev/null +++ b/db/index.ts @@ -0,0 +1,12 @@ +export { + listInvoiceshelfAccounts, + getActiveInvoiceshelfCredentials, + getInvoiceshelfCredentials, + createInvoiceshelfAccount, + updateInvoiceshelfAccount, + setActiveInvoiceshelfAccount, + deleteInvoiceshelfAccount, + recordInvoiceshelfProbe, +} from './queries'; + +export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries'; diff --git a/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..20975ed --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,212 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { db } from 'officerdb/db'; +import { invoicesAccounts } from './schema'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; + +// InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption +// to and from at-rest ciphertext happens here. See ../crypto.ts and ../schema/invoiceshelf.ts. +// +// Two return types, and the split is the safety property: +// InvoiceshelfAccount — safe to serialize to the browser. Has NO token field at all, not even masked. +// InvoiceshelfCredentials — url + decrypted token + company, for the sidecar's own upstream calls. +// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list +// response the moment someone forgot to strip it. + +export type InvoiceshelfAccount = { + id: number; + label: string; + url: string; + companyId: number | null; + companyName: string | null; + version: string | null; + isActive: boolean; + lastSeenAt: Date | null; + createdAt: Date; +}; + +export type InvoiceshelfCredentials = { + id: number; + label: string; + url: string; + token: string; + companyId: number | null; +}; + +const accountCols = { + id: invoicesAccounts.id, + label: invoicesAccounts.label, + url: invoicesAccounts.url, + companyId: invoicesAccounts.companyId, + companyName: invoicesAccounts.companyName, + version: invoicesAccounts.version, + isActive: invoicesAccounts.isActive, + lastSeenAt: invoicesAccounts.lastSeenAt, + createdAt: invoicesAccounts.createdAt, +}; + +/** Every account the owner has added, active first then newest. Never includes the token. */ +export async function listInvoiceshelfAccounts(userId: number): Promise { + return db + .select(accountCols) + .from(invoicesAccounts) + .where(eq(invoicesAccounts.userId, userId)) + .orderBy(desc(invoicesAccounts.isActive), desc(invoicesAccounts.createdAt)); +} + +/** The selected account with its token decrypted, or null when none is added. */ +export async function getActiveInvoiceshelfCredentials(userId: number): Promise { + const [row] = await db + .select() + .from(invoicesAccounts) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.isActive, true))); + if (!row) return null; + return { + id: row.id, + label: row.label, + url: row.url, + token: decryptSecret('invoiceshelf', row.token), + companyId: row.companyId, + }; +} + +/** One account's credentials by id — for probing a specific account rather than the active one. */ +export async function getInvoiceshelfCredentials(userId: number, id: number): Promise { + const [row] = await db + .select() + .from(invoicesAccounts) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))); + if (!row) return null; + return { + id: row.id, + label: row.label, + url: row.url, + token: decryptSecret('invoiceshelf', row.token), + companyId: row.companyId, + }; +} + +type CreateInvoiceshelfAccountParams = { + userId: number; + label: string; + url: string; + token: string; + companyId: number | null; + companyName: string | null; + version: string | null; + /** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */ + activate: boolean; +}; + +/** Add an account. The token is encrypted before write; the returned row carries no token. */ +export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccountParams): Promise { + const { userId, label, url, token, companyId, companyName, version, activate } = params; + return db.transaction(async (tx) => { + if (activate) { + await tx + .update(invoicesAccounts) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.isActive, true))); + } + const [row] = await tx + .insert(invoicesAccounts) + .values({ + userId, + label, + url, + token: encryptSecret('invoiceshelf', token), + companyId, + companyName, + version, + isActive: activate, + lastSeenAt: version ? new Date() : null, + }) + .returning(accountCols); + return row!; + }); +} + +type UpdateInvoiceshelfAccountParams = { + label?: string; + url?: string; + token?: string; + companyId?: number | null; + companyName?: string | null; + version?: string | null; +}; + +/** Edit an account. Omitted fields are left alone; a supplied token is re-encrypted. */ +export async function updateInvoiceshelfAccount( + userId: number, + id: number, + params: UpdateInvoiceshelfAccountParams, +): Promise { + const set: Record = { updatedAt: new Date() }; + if (params.label !== undefined) set.label = params.label; + if (params.url !== undefined) set.url = params.url; + if (params.token !== undefined) set.token = encryptSecret('invoiceshelf', params.token); + if (params.companyId !== undefined) set.companyId = params.companyId; + if (params.companyName !== undefined) set.companyName = params.companyName; + if (params.version !== undefined) set.version = params.version; + + const [row] = await db + .update(invoicesAccounts) + .set(set) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))) + .returning(accountCols); + return row ?? null; +} + +/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */ +export async function setActiveInvoiceshelfAccount(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + await tx + .update(invoicesAccounts) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.isActive, true))); + const [row] = await tx + .update(invoicesAccounts) + .set({ isActive: true, updatedAt: new Date() }) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))) + .returning(accountCols); + return row ?? null; + }); +} + +/** + * Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the + * account in use would leave the owner with accounts added but none selected, which reads as "not connected" + * and is a confusing place to land. + */ +export async function deleteInvoiceshelfAccount(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + const [deleted] = await tx + .delete(invoicesAccounts) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))) + .returning({ id: invoicesAccounts.id, wasActive: invoicesAccounts.isActive }); + if (!deleted) return false; + + if (deleted.wasActive) { + const [next] = await tx + .select({ id: invoicesAccounts.id }) + .from(invoicesAccounts) + .where(eq(invoicesAccounts.userId, userId)) + .orderBy(desc(invoicesAccounts.createdAt)) + .limit(1); + if (next) { + await tx + .update(invoicesAccounts) + .set({ isActive: true, updatedAt: new Date() }) + .where(eq(invoicesAccounts.id, next.id)); + } + } + return true; + }); +} + +/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */ +export async function recordInvoiceshelfProbe(userId: number, id: number, version: string | null): Promise { + await db + .update(invoicesAccounts) + .set({ version, lastSeenAt: new Date() }) + .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))); +} diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..defdf12 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,58 @@ +import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from 'officerdb/auth/schema'; + +// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar. +// +// This used to be INVOICESHELF_URL + INVOICESHELF_TOKEN + INVOICESHELF_COMPANY_ID in the platform-wide +// `.env`, which was wrong twice over: Bun auto-loads `.env` into EVERY process started in the platform +// directory, so `officer` itself held a Sanctum token it has no code to use — and connecting a books +// instance was a shell task on the server rather than something the owner could do from the app. +// +// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the +// same shape offscale_servers and photos_config use. Uniqueness is on the label rather than the URL, +// because the same instance with a different company is a different account here, and two of those share +// a URL AND a token. +// +// `token` is encrypted at rest via ../crypto.ts. A Sanctum token can read and write the entire books — every +// invoice, customer and payment — so a DB dump must not hand it over. Encryption is confined to +// queries/invoiceshelf.ts; nothing outside that file sees ciphertext, and no route ever returns the token. +export const invoicesAccounts = pgTable( + 'invoices_accounts', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */ + label: text('label').notNull(), + // Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator. + url: text('url').notNull(), + token: text('token').notNull(), // encrypted + /** + * The company every request is scoped to, pinned explicitly. + * + * This is the sharpest edge in InvoiceShelf's API: the `company` header scopes almost every route, and a + * wrong or missing value does NOT error — it silently returns another company's books. Storing the + * choice means it is made once, visibly, instead of falling back per request. + */ + companyId: integer('company_id'), + /** Shown in the switcher next to the label, so two companies on one token are told apart at a glance. */ + companyName: text('company_name'), + /** InvoiceShelf version seen at the last successful probe — shown in the UI, never used for behaviour. */ + version: text('version'), + isActive: boolean('is_active').notNull().default(false), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('uq_invoices_accounts_user_label').on(t.userId, t.label), + // At most one active account per owner, enforced by the DB rather than by convention: a partial unique + // index over the active rows only. setActiveInvoiceshelfAccount still clears the others in a transaction, + // but a bug there fails loudly here instead of silently leaving two active and the UI picking one. + uniqueIndex('uq_invoices_accounts_one_active') + .on(t.userId) + .where(sql`${t.isActive}`), + ], +); diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..e9bee3f --- /dev/null +++ b/manifest.ts @@ -0,0 +1,48 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Invoices — your InvoiceShelf books, inside Officer. + * + * ── Why `invoices` and not `invoiceshelf` ── + * + * The appName is THE id: it drives the API prefix, the frontend route, the table prefix, the sidecar + * suffix and the install key, all at once. Before extraction those had drifted apart — the API was + * `/api/invoiceshelf` while the screen was `/invoices` — and a plugin cannot hold both. + * + * Named for what it shows rather than the daemon behind it, so the URL a person sees stays `/invoices`. + * The cost was renaming `invoiceshelf_accounts` to `invoices_accounts` to satisfy the table-prefix rule, + * which was free here because the table existed on no server. Had there been rows, keeping the vendor + * name would have been the better trade. + * + * ── What this plugin is, and what it is not ── + * + * It does not install or supervise InvoiceShelf, and it holds no books. You point it at an instance and + * it speaks that API on your behalf, holding the credentials server-side. + * + * ── Where the connection lives ── + * + * In this plugin's own `invoices_accounts` — a registry, like photos and jellyfin, because one person + * really does keep more than one company's books. Label, URL, encrypted credential, one active. + * + * ── No `osDependencies` ── + * + * Nothing shells out. The sidecar speaks InvoiceShelf's HTTP API over `fetch`. + */ +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + + label: 'Invoices', + summary: 'InvoiceShelf books — invoices, estimates, customers and payments', + icon: 'ReceiptText', + color: '#0ea5e9', + + permissions: [ + { + key: 'invoices', + label: 'Invoices', + description: 'InvoiceShelf books', + }, + ], +}; diff --git a/sidecar/config.ts b/sidecar/config.ts new file mode 100644 index 0000000..2b61141 --- /dev/null +++ b/sidecar/config.ts @@ -0,0 +1,326 @@ +import type { Company, UpstreamTarget } from './upstream'; +import { + createInvoiceshelfAccount, + deleteInvoiceshelfAccount, + getInvoiceshelfCredentials, + listInvoiceshelfAccounts, + recordInvoiceshelfProbe, + setActiveInvoiceshelfAccount, + updateInvoiceshelfAccount, +} from '../db'; +import { UpstreamError, callUpstream, invalidateConfig, listCompanies, mintToken, normalizeBase } from './upstream'; + +// `/_config` — the InvoiceShelf account registry, driven from the app rather than from a shell on the server. +// +// The Sanctum token is WRITE-ONLY across this boundary. The list reports each account's label, URL, pinned +// company and whether it is selected; it has no field that could carry a token, masked or otherwise. The +// only way to change one is to send a new one, which is the same shape photos and offscale use. +// +// A save is validated before it is stored, and the validation does something the photos one does not: it +// lists the companies the token can act for and PINS one. The `company` header is the sharpest edge in this +// API — a wrong or missing value silently returns another company's books instead of erroring — so the +// choice is made once, here, and stored, rather than falling back per request. +// +// Two accounts on the same URL with the same token but different companies is therefore an ordinary thing +// to have, and is exactly why the label is what has to be unique. + +export type ProbeResult = + | { ok: true; version: string | null; companies: Company[] } + | { ok: false; version: string | null; error: string }; + +/** 401/419, or a 3xx that is Laravel redirecting to its HTML login — all three mean the token was refused. */ +const rejected = (status: number): boolean => status === 401 || status === 419 || (status >= 300 && status < 400); + +const REJECTED_MESSAGE = + 'the API token was rejected — mint a new one and paste the whole value, including the leading "1|"'; + +/** + * Ask an instance whether it is really there and whether the token works. + * + * Two calls, because they answer different questions, but NOT the two you would expect: `/app/version` is an + * UNAUTHENTICATED route on 2.4.2, so it answers 200 to a revoked, truncated or empty token. It proves only + * that the URL points at an InvoiceShelf. The companies lookup is the first call that exercises the token at + * all, which is why its 401 has to be reported as a credential problem and not as "companies lookup failed". + */ +export async function probe(cfg: UpstreamTarget): Promise { + let version: string | null = null; + + try { + const res = await callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false }); + if (!res.ok) { + return { ok: false, version: null, error: `that URL does not look like an InvoiceShelf (${res.status})` }; + } + const payload = (await res.json()) as { version?: string }; + version = payload.version ?? null; + } catch (err) { + return { ok: false, version: null, error: `could not reach the instance (${String(err)})` }; + } + + try { + const companies = await listCompanies(cfg); + if (!companies.length) return { ok: false, version, error: 'no company is visible to this token' }; + return { ok: true, version, companies }; + } catch (err) { + const status = err instanceof UpstreamError ? err.status : 0; + if (rejected(status)) return { ok: false, version, error: REJECTED_MESSAGE }; + return { ok: false, version, error: String(err) }; + } +} + +/** What the browser is allowed to know about the registry. Never includes a token. */ +async function accountList(userId: number): Promise { + const accounts = await listInvoiceshelfAccounts(userId); + const active = accounts.find((account) => account.isActive) ?? null; + return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts }); +} + +/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */ +export async function noteProbe(userId: number, id: number, version: string | null): Promise { + await recordInvoiceshelfProbe(userId, id, version).catch(() => { + /* a stale lastSeenAt is not worth failing a request over */ + }); +} + +const bad = (error: string, status = 400) => Response.json({ error }, { status }); + +type AccountBody = { + label?: unknown; + url?: unknown; + token?: unknown; + companyId?: unknown; + email?: unknown; + password?: unknown; +}; + +const readBody = async (req: Request): Promise => + ((await req.json().catch(() => null)) as AccountBody | null) ?? {}; + +const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : ''); +const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : ''); +const readToken = (body: AccountBody): string => (typeof body.token === 'string' ? body.token.trim() : ''); + +const readCompanyId = (body: AccountBody): number | null => { + const n = Number(body.companyId); + return Number.isInteger(n) && n > 0 ? n : null; +}; + +const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : ''); +const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : ''); + +const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); + +/** + * The token to store, however the owner chose to supply it. + * + * A pasted token wins; otherwise an email and password are traded for a fresh one and the password is + * discarded with this function's stack frame. Returns '' when neither was given, which for an edit means + * "keep what is stored" and for an add is an error. + */ +async function resolveToken(body: AccountBody, base: string): Promise { + const token = readToken(body); + if (token) return token; + + const email = readEmail(body); + const password = readPassword(body); + if (!email || !password) return ''; + + return mintToken(base, email, password); +} + +/** Sign-in failures, in the words of what the owner actually did. 422 is Laravel's validation refusal. */ +function signInError(err: unknown): string { + if (!(err instanceof UpstreamError)) return `could not reach the instance (${String(err)})`; + if (err.status === 422 || err.status === 401) return 'that email and password were rejected by InvoiceShelf'; + if (err.status === 429) return 'too many sign-in attempts — wait a moment and try again'; + return err.message; +} + +const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_invoiceshelf_accounts_user_label'); + +/** + * Pick the company to pin. + * + * An explicit choice is honoured only if the token can actually see it — pinning an invisible company is the + * silent-wrong-books failure this whole mechanism exists to prevent. Otherwise the sole company is taken + * without asking, and with several the caller must choose: `needsChoice` is the 409 that makes the UI show + * the picker instead of guessing. + */ +function pickCompany(companies: Company[], requested: number | null): Company | null { + if (requested != null) return companies.find((c) => c.id === requested) ?? null; + return companies.length === 1 ? companies[0]! : null; +} + +/** Add an account: validated against the live instance, company pinned, then stored encrypted. */ +async function addAccount(req: Request, userId: number): Promise { + const body = await readBody(req); + const url = readUrl(body); + const requested = readCompanyId(body); + let label = readLabel(body); + + if (!url) return bad('url is required'); + if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); + + let token: string; + try { + token = await resolveToken(body, url); + } catch (err) { + return bad(signInError(err)); + } + if (!token) return bad('an API token, or an email and password to mint one, is required'); + + const result = await probe({ base: url, token, companyId: null }); + if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); + + const company = pickCompany(result.companies, requested); + if (!company) { + // 409, not 400: nothing is wrong with what was sent, it is just not enough to decide. The UI answers + // this by showing the returned companies and asking, then posting again with companyId. + return Response.json( + { + error: requested ? 'that company is not visible to this token' : 'choose which company to use', + needsChoice: true, + companies: result.companies, + version: result.version, + }, + { status: 409 }, + ); + } + + // An unlabelled account takes the company's own name, which is nearly always what the owner would type. + // Falling back to the host keeps the switcher readable when the company is unnamed. + if (!label) label = company.name ?? new URL(url).host; + + // The first account wins the selection: a registry with rows but nothing selected reads as "not connected". + const existing = await listInvoiceshelfAccounts(userId); + const activate = existing.length === 0; + + try { + const account = await createInvoiceshelfAccount({ + userId, + label, + url, + token, + companyId: company.id, + companyName: company.name, + version: result.version, + activate, + }); + invalidateConfig(userId); + return Response.json({ account }); + } catch (err) { + if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); + throw err; + } +} + +/** Edit one account. No credential means "keep the stored one", so a rename never needs one re-typed. */ +async function editAccount(req: Request, userId: number, id: number): Promise { + const body = await readBody(req); + const label = readLabel(body); + const url = readUrl(body); + const requested = readCompanyId(body); + + const current = await getInvoiceshelfCredentials(userId, id); + if (!current) return bad('no such account', 404); + if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://'); + + let token: string; + try { + token = await resolveToken(body, url || current.url); + } catch (err) { + return bad(signInError(err)); + } + + // Re-validate whenever what we would talk to changes. A rename on its own never touches the instance. + let version: string | null | undefined; + let companyId: number | undefined; + let companyName: string | null | undefined; + + if ((url && url !== current.url) || token || requested != null) { + const target = { base: url || current.url, token: token || current.token, companyId: null }; + const result = await probe(target); + if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); + version = result.version; + + // Keep the pinned company across a token rotation, but only if the new token can still see it. + const company = pickCompany(result.companies, requested ?? current.companyId); + if (!company) { + return Response.json( + { error: 'choose which company to use', needsChoice: true, companies: result.companies }, + { status: 409 }, + ); + } + companyId = company.id; + companyName = company.name; + } + + try { + const account = await updateInvoiceshelfAccount(userId, id, { + label: label || undefined, + url: url || undefined, + token: token || undefined, + companyId, + companyName, + version, + }); + if (!account) return bad('no such account', 404); + invalidateConfig(userId); + return Response.json({ account }); + } catch (err) { + if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); + throw err; + } +} + +/** The companies a stored account's token can act for — what the company picker is populated from. */ +async function accountCompanies(userId: number, id: number): Promise { + const creds = await getInvoiceshelfCredentials(userId, id); + if (!creds) return bad('no such account', 404); + try { + const companies = await listCompanies({ base: normalizeBase(creds.url), token: creds.token, companyId: null }); + return Response.json({ companies, companyId: creds.companyId }); + } catch (err) { + const status = err instanceof UpstreamError ? err.status : 0; + return Response.json({ error: rejected(status) ? REJECTED_MESSAGE : String(err) }, { status: 502 }); + } +} + +/** `subpath` is '' for /_config, or '/', '//activate', '//companies'. */ +export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { + const [, rawId, action] = subpath.split('/'); + + if (!rawId) { + if (req.method === 'GET') return accountList(userId); + if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId); + return bad('method not allowed', 405); + } + + const id = Number(rawId); + if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404); + + if (action === 'activate') { + if (req.method !== 'POST') return bad('method not allowed', 405); + const account = await setActiveInvoiceshelfAccount(userId, id); + if (!account) return bad('no such account', 404); + invalidateConfig(userId); + return accountList(userId); + } + + if (action === 'companies') { + if (req.method !== 'GET') return bad('method not allowed', 405); + return accountCompanies(userId, id); + } + + if (action) return bad('not found', 404); + + if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id); + + if (req.method === 'DELETE') { + const removed = await deleteInvoiceshelfAccount(userId, id); + if (!removed) return bad('no such account', 404); + invalidateConfig(userId); + return accountList(userId); + } + + return bad('method not allowed', 405); +} diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..9323895 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,177 @@ +import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; +import { createSidecarConnector } from '@@/sidecar/connect'; +import { handleConfigRoute, noteProbe, probe } from './config'; +import { handleOfficerRoute } from './routes'; +import { getConfig } from './upstream'; +import { API_URL } from '@@/officer-url.mjs'; + +// The officer-invoiceshelf sidecar. Owns the whole InvoiceShelf contract for Officer: the instance URL, the +// Sanctum API token, and the `company` header that scopes every request. The platform API is a thin +// auth-gated forwarder (src/servers/api/invoiceshelf/router.ts) holding no InvoiceShelf credentials. +// +// The connection is the OWNER'S to set, from the UI — accounts are stored encrypted in +// `invoiceshelf_accounts` and no longer read from the environment. See upstream.ts for why that mattered. +// +// Built and verified against the LIVE instance, which runs 2.4.2 — NOT against the 3.0.0-alpha.1 checkout in +// _references/InvoiceShelf. The two differ in ways that matter (2.4.2 has no invoices/{id}/convert-to-estimate +// and no installation/is-installed). The route allow-list in routes.ts came from `artisan route:list` on the +// running container. +// +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// HTTP CONTRACT — the platform strips its /api/invoiceshelf mount prefix before forwarding. +// +// GET /_health ours. Confirms the token is live and reports the pinned company. +// GET /_config the account registry MINUS every token +// POST /_config { label?, url, companyId?, and EITHER token OR email+password } +// — the password mints a token and is never stored. +// 409 { needsChoice, companies } when the company is ambiguous. +// PATCH /_config/:id same fields, all optional — no credential keeps the stored token +// GET /_config/:id/companies the companies that account's token can act for +// POST /_config/:id/activate switch to that account +// DEL /_config/:id remove it; the newest survivor is promoted if it was active +// GET /_officer/summary me + current company + dashboard totals, one call +// GET /_officer/lookups customers/items/units/tax-types/categories/payment-methods/currencies +// GET /_officer/bootstrap the upstream's own bootstrap blob +// GET /_officer/next-number?key=invoice the number the next document would take +// GET /_officer/search?search= cross-entity search +// GET /_officer/reports/:kind sales-customers|sales-items|tax-summary|profit-loss|expenses → PDF +// +// GET|POST /_officer/:resource list / create +// GET|PUT|DELETE /_officer/:resource/:id read / update / delete +// POST /_officer/:resource/delete bulk delete {ids:[…]} +// GET /_officer/:resource/templates invoices|estimates only +// GET /_officer/:resource/:id/pdf invoices|estimates|payments — magic-byte repaired +// GET /_officer/:resource/:id/preview rendered email HTML, sends nothing. +// Requires ?subject=&body=&from=&to= — the controller +// validates them and 422s otherwise. +// GET /_officer/customers/:id/stats +// POST /_officer/:resource/:id/:action status|clone|send|convert-to-invoice|duplicate +// anything else 404 +// +// `:resource` is an allow-list (routes.ts). The administrative half of the API — backups, disks, modules, +// update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +/** Grab an ephemeral free port by briefly binding one and releasing it. */ +function getFreePort(): number { + const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const p = probeServer.port; + probeServer.stop(true); + if (p == null) throw new Error('failed to acquire a free port'); + return p; +} + +const port = getFreePort(); + +const server = Bun.serve({ + port, + hostname: '127.0.0.1', + // Expense receipts and company logos are uploaded as multipart bodies through this proxy. + maxRequestBodySize: 64 * 1024 * 1024, + async fetch(req) { + const url = new URL(req.url); + + const officerUser = req.headers.get('X-Officer-User'); + const userId = Number(officerUser); + if (!officerUser || !Number.isInteger(userId) || userId <= 0) { + return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); + } + + if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { + try { + return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); + } catch (err) { + console.error(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + } + + const cfg = await getConfig(userId); + + // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a + // configured-but-broken instance is the whole reason the flag is on the response. + if (url.pathname === '/_health') { + if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); + const started = Date.now(); + const result = await probe(cfg); + const ms = Date.now() - started; + + if (!result.ok) { + return Response.json( + { ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms }, + { status: 502 }, + ); + } + await noteProbe(userId, cfg.id, result.version); + return Response.json({ + ok: true, + configured: true, + account: cfg.label, + version: result.version, + company: cfg.companyId, + ms, + }); + } + + if (url.pathname.startsWith('/_officer/')) { + if (!cfg) return Response.json({ error: 'invoiceshelf not connected', configured: false }, { status: 503 }); + try { + const res = await handleOfficerRoute(cfg, req, url); + if (res) return res; + return Response.json({ error: 'not found' }, { status: 404 }); + } catch (err) { + console.error(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + } + + return Response.json({ error: 'not found' }, { status: 404 }); + }, +}); + +console.log( + `[invoiceshelf] listening on 127.0.0.1:${port} (instance configured from the UI, stored in invoiceshelf_accounts)`, +); + +type ReplyFn = (msg: SidecarEvent) => void; + +function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { + switch (cmd.type) { + case 'ping': + reply({ type: 'pong', id: cmd.id }); + break; + default: + reply({ + type: 'error', + id: (cmd as SidecarCommand).id, + error: `Unknown command type: ${(cmd as Record).type}`, + }); + } +} + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'invoiceshelf', + handles: ['invoiceshelf'], + onCommand(cmd, reply) { + handleCommand(cmd as SidecarCommand, reply as ReplyFn); + }, + onConnected() { + connection.send({ type: 'invoiceshelf:server', port }); + console.log(`[invoiceshelf] reported server port ${port} to API`); + }, +}); + +function shutdown(signal: string) { + console.log(`[invoiceshelf] ${signal} received, shutting down...`); + try { + server.stop(true); + } catch { + /* already stopped */ + } + connection.destroy(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/sidecar/routes.ts b/sidecar/routes.ts new file mode 100644 index 0000000..9a52dc4 --- /dev/null +++ b/sidecar/routes.ts @@ -0,0 +1,322 @@ +import type { UpstreamConfig } from './upstream'; +import { callUpstream, resolveCompanyHash } from './upstream'; + +// The Officer-owned route surface. Every path here is deliberate: this is an allow-list, not a passthrough. +// +// InvoiceShelf's API is already REST, so most of these are shaped proxies rather than translations — the +// value the sidecar adds is (a) holding the credential, (b) pinning the company, (c) repairing the PDF +// responses, and (d) refusing to expose the administrative half of the API at all. +// +// DELIBERATELY NOT EXPOSED: backups, disks, modules, update/*, installation/*, mail config and test-send, +// settings writes, and transfer/ownership. Those either mutate the deployment or can lock the owner out of +// it, and none of them belong behind a dashboard proxy. Adding one should be a decision, not an accident. + +type Capabilities = { write: boolean; bulkDelete: boolean }; + +const RESOURCES: Record = { + invoices: { write: true, bulkDelete: true }, + estimates: { write: true, bulkDelete: true }, + 'recurring-invoices': { write: true, bulkDelete: true }, + payments: { write: true, bulkDelete: true }, + expenses: { write: true, bulkDelete: true }, + customers: { write: true, bulkDelete: true }, + items: { write: true, bulkDelete: true }, + units: { write: true, bulkDelete: false }, + categories: { write: true, bulkDelete: false }, + 'tax-types': { write: true, bulkDelete: false }, + 'payment-methods': { write: true, bulkDelete: false }, + notes: { write: true, bulkDelete: false }, + 'custom-fields': { write: true, bulkDelete: false }, + currencies: { write: false, bulkDelete: false }, + countries: { write: false, bulkDelete: false }, + users: { write: false, bulkDelete: false }, + roles: { write: false, bulkDelete: false }, +}; + +// Non-REST sub-actions, per resource. Verified against `artisan route:list` on the LIVE 2.4.2 instance +// rather than the 3.0.0-alpha checkout in _references — they differ. Notably 2.4.2 has +// estimates/{id}/convert-to-invoice but NO invoices/{id}/convert-to-estimate, which 3.0 added. +const ACTIONS: Record = { + invoices: ['status', 'clone', 'send'], + estimates: ['status', 'clone', 'send', 'convert-to-invoice'], + payments: ['send'], + expenses: ['duplicate'], +}; + +// Resources whose PDF lives on a web route keyed by `unique_hash`. +const PDF_RESOURCES = new Set(['invoices', 'estimates', 'payments']); + +const REPORTS: Record = { + 'sales-customers': 'reports/sales/customers', + 'sales-items': 'reports/sales/items', + 'tax-summary': 'reports/tax-summary', + 'profit-loss': 'reports/profit-loss', + expenses: 'reports/expenses', +}; + +const notFound = () => Response.json({ error: 'not found' }, { status: 404 }); +const badRequest = (error: string) => Response.json({ error }, { status: 400 }); + +/** Pass an upstream JSON response through untouched — including Laravel's 422 `errors{}` envelope. */ +async function passthrough(res: Response): Promise { + const body = await res.arrayBuffer(); + const headers = new Headers(); + const type = res.headers.get('content-type'); + if (type) headers.set('Content-Type', type); + return new Response(body, { status: res.status, headers }); +} + +/** + * InvoiceShelf 2.4.2 prefixes the document PDFs with a literal serialised HTTP response — the body of + * `/invoices/pdf/{hash}` starts `HTTP/1.0 200 OK\r\nCache-Control: …\r\n\r\n%PDF-1.7`, 201 bytes of it, + * inside a response whose own Content-Type is already application/pdf. Every PDF reader rejects that. + * + * So: find the `%PDF-` magic and slice from there. Verified on this instance — the document routes carry the + * prefix, the report routes do not, and slicing is a no-op when the magic is already at offset 0. + */ +function repairPdf(bytes: Uint8Array): Uint8Array | null { + const magic = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF- + const limit = Math.min(bytes.length - magic.length, 4096); + for (let i = 0; i <= limit; i++) { + let hit = true; + for (let j = 0; j < magic.length; j++) { + if (bytes[i + j] !== magic[j]) { + hit = false; + break; + } + } + if (hit) return i === 0 ? bytes : bytes.subarray(i); + } + return null; +} + +type PdfParams = { cfg: UpstreamConfig; path: string; query: string; filename: string }; + +async function servePdf({ cfg, path, query, filename }: PdfParams): Promise { + const res = await callUpstream(cfg, { path, query, accept: 'application/pdf' }); + if (!res.ok) return passthrough(res); + + const repaired = repairPdf(new Uint8Array(await res.arrayBuffer())); + if (!repaired) return Response.json({ error: 'upstream did not return a PDF' }, { status: 502 }); + + return new Response(new Blob([repaired]), { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }); +} + +/** Look up a document's `unique_hash`, which its PDF web route is keyed by. */ +async function fetchUniqueHash(cfg: UpstreamConfig, resource: string, id: string): Promise { + const res = await callUpstream(cfg, { path: `/api/v1/${resource}/${id}` }); + if (!res.ok) return null; + const payload = (await res.json()) as { data?: { unique_hash?: string } }; + return payload.data?.unique_hash ?? null; +} + +async function readJson(req: Request): Promise { + try { + return await req.json(); + } catch { + return null; + } +} + +type Bootstrap = { + current_user?: unknown; + current_company?: unknown; + current_company_currency?: unknown; + current_company_settings?: unknown; +}; + +/** + * Everything a dashboard needs on first paint, in one round trip: who we are, which company we are pinned to, + * how to format its money, and the totals. + * + * The currency matters more than it looks. Every amount in this API is an integer in minor units, and + * InvoiceShelf's own frontend just divides by 100 — but `current_company_currency` carries the real + * precision, separators and `swap_currency_symbol` (this instance is EUR: "1.234,56 €"). A client that + * formats from these fields is right for currencies the upstream's own UI gets wrong. + */ +async function summary(cfg: UpstreamConfig): Promise { + const [bootRes, dashRes] = await Promise.all([ + callUpstream(cfg, { path: '/api/v1/bootstrap' }), + callUpstream(cfg, { path: '/api/v1/dashboard' }), + ]); + + if (!dashRes.ok) return passthrough(dashRes); + + const boot = bootRes.ok ? ((await bootRes.json()) as Bootstrap) : null; + const dashboard = await dashRes.json(); + + return Response.json({ + me: boot?.current_user ?? null, + company: boot?.current_company ?? null, + currency: boot?.current_company_currency ?? null, + settings: boot?.current_company_settings ?? null, + dashboard, + }); +} + +/** + * The reference-data a form needs, fetched in parallel. All of these are small and change rarely, so one + * round trip beats seven — and `limit=all` keeps customers and items out of pagination. + */ +async function lookups(cfg: UpstreamConfig): Promise { + const wanted = [ + ['customers', '/api/v1/customers?limit=all'], + ['items', '/api/v1/items?limit=all'], + ['units', '/api/v1/units?limit=all'], + ['taxTypes', '/api/v1/tax-types?limit=all'], + ['categories', '/api/v1/categories?limit=all'], + ['paymentMethods', '/api/v1/payment-methods?limit=all'], + ['currencies', '/api/v1/currencies'], + ] as const; + + const entries = await Promise.all( + wanted.map(async ([key, path]) => { + const res = await callUpstream(cfg, { path }); + if (!res.ok) return [key, []] as const; + const payload = (await res.json()) as { data?: unknown }; + return [key, payload.data ?? []] as const; + }), + ); + + return Response.json(Object.fromEntries(entries)); +} + +export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise { + const segments = url.pathname + .replace(/^\/_officer\/?/, '') + .split('/') + .filter(Boolean); + const query = url.search; + const method = req.method; + + const head = segments[0]; + if (!head) return notFound(); + const second = segments[1]; + const third = segments[2]; + + // ── Officer-composed reads ──────────────────────────────────────────────────────────────────── + if (head === 'summary' && method === 'GET') return summary(cfg); + if (head === 'lookups' && method === 'GET') return lookups(cfg); + + if (head === 'bootstrap' && method === 'GET') { + return passthrough(await callUpstream(cfg, { path: '/api/v1/bootstrap', query })); + } + + // `?key=invoice|estimate|payment` — the number the next document would get. + if (head === 'next-number' && method === 'GET') { + return passthrough(await callUpstream(cfg, { path: '/api/v1/next-number', query })); + } + + if (head === 'search' && method === 'GET') { + return passthrough(await callUpstream(cfg, { path: '/api/v1/search', query })); + } + + // ── Reports: PDFs on web routes keyed by the company hash ───────────────────────────────────── + if (head === 'reports') { + if (method !== 'GET' || !second) return notFound(); + const route = REPORTS[second]; + if (!route) return notFound(); + const hash = await resolveCompanyHash(cfg); + return servePdf({ cfg, path: `/${route}/${hash}`, query, filename: `${second}.pdf` }); + } + + // ── Allow-listed resources ──────────────────────────────────────────────────────────────────── + const caps = RESOURCES[head]; + if (!caps) return notFound(); + + const base = `/api/v1/${head}`; + const contentType = req.headers.get('content-type') ?? 'application/json'; + + // Collection: list and create. + if (!second) { + if (method === 'GET') return passthrough(await callUpstream(cfg, { path: base, query })); + if (method === 'POST') { + if (!caps.write) return badRequest(`${head} is read-only`); + return passthrough( + await callUpstream(cfg, { path: base, method: 'POST', body: await req.arrayBuffer(), contentType }), + ); + } + return notFound(); + } + + // Bulk delete — InvoiceShelf models it as POST /{resource}/delete with {"ids":[…]}, not DELETE. + if (second === 'delete' && method === 'POST') { + if (!caps.bulkDelete) return badRequest(`${head} has no bulk delete`); + const body = await readJson(req); + const ids = (body as { ids?: unknown })?.ids; + if (!Array.isArray(ids) || ids.length === 0) return badRequest('ids must be a non-empty array'); + return passthrough( + await callUpstream(cfg, { + path: `${base}/delete`, + method: 'POST', + body: JSON.stringify({ ids }), + contentType: 'application/json', + }), + ); + } + + // The template galleries, which sit alongside the collection rather than under an id. + if (second === 'templates' && method === 'GET' && (head === 'invoices' || head === 'estimates')) { + return passthrough(await callUpstream(cfg, { path: `${base}/templates` })); + } + + const id = second; + + // Single resource. + if (!third) { + if (method === 'GET') return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, query })); + if (method === 'PUT' || method === 'PATCH') { + if (!caps.write) return badRequest(`${head} is read-only`); + return passthrough( + await callUpstream(cfg, { path: `${base}/${id}`, method: 'PUT', body: await req.arrayBuffer(), contentType }), + ); + } + if (method === 'DELETE') { + if (!caps.write) return badRequest(`${head} is read-only`); + return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, method: 'DELETE' })); + } + return notFound(); + } + + // ── Sub-resources ───────────────────────────────────────────────────────────────────────────── + if (third === 'pdf' && method === 'GET') { + if (!PDF_RESOURCES.has(head)) return notFound(); + const hash = await fetchUniqueHash(cfg, head, id); + if (!hash) return Response.json({ error: `${head}/${id} not found` }, { status: 404 }); + // Built from OUR base rather than the upstream's `*_pdf_url`, which is rendered from the instance's + // APP_URL and can point somewhere this process cannot reach. + return servePdf({ cfg, path: `/${head}/pdf/${hash}`, query, filename: `${head.slice(0, -1)}-${id}.pdf` }); + } + + // The rendered email body, as HTML, without sending anything. + if (third === 'preview' && method === 'GET') { + if (!ACTIONS[head]?.includes('send')) return notFound(); + return passthrough(await callUpstream(cfg, { path: `${base}/${id}/send/preview`, query })); + } + + if (head === 'customers' && third === 'stats' && method === 'GET') { + return passthrough(await callUpstream(cfg, { path: `${base}/${id}/stats`, query })); + } + + // Named actions. `send` really does email the customer, which is why actions are an explicit allow-list + // per resource rather than a wildcard under the id. + if (method === 'POST' && ACTIONS[head]?.includes(third)) { + return passthrough( + await callUpstream(cfg, { + path: `${base}/${id}/${third}`, + method: 'POST', + body: await req.arrayBuffer(), + contentType, + }), + ); + } + + return notFound(); +} diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts new file mode 100644 index 0000000..134e461 --- /dev/null +++ b/sidecar/upstream.ts @@ -0,0 +1,202 @@ +// InvoiceShelf upstream config + the one function that talks to it. +// +// All knowledge of the InvoiceShelf instance — its URL, its API token and which company the token acts on +// behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin +// auth+forward proxy and holds NO InvoiceShelf credentials. +// +// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `invoiceshelf_accounts` (see +// databases/officer_db/src/invoiceshelf/queries.ts). It is deliberately no longer read from the environment: +// Bun auto-loads `.env` into every process started in the platform directory, so an `INVOICESHELF_TOKEN` +// there was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no +// code to use it and the largest attack surface in the system. Nothing in this file reads process.env. +// +// Three things about InvoiceShelf's API are load-bearing and easy to get wrong: +// +// 1. `Accept: application/json` is MANDATORY. Without it Laravel's Authenticate middleware answers an +// unauthenticated request with a 302 to an HTML login page instead of a 401, so every error turns into +// an unparseable redirect. +// 2. NEVER send Origin or Referer. InvoiceShelf's `statefulApi()` middleware treats a request carrying +// either as a first-party browser call and switches from token auth to session+CSRF, which then fails +// 419. Bun's fetch adds neither on its own; the platform proxy in api/invoiceshelf/router.ts +// deliberately forwards neither. Don't add them. +// 3. The `company` header (lowercase, a bare numeric id) scopes almost every route — and a wrong or +// missing value does NOT error. It silently falls back to another company's data. That is why the +// company is PINNED ON THE ACCOUNT ROW and chosen once when the account is added, rather than resolved +// per request from whatever the token happens to see first. + +import { getActiveInvoiceshelfCredentials } from '../db'; + +/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */ +export type UpstreamTarget = { base: string; token: string; companyId: number | null }; + +/** + * A stored account, which is where every real call goes. + * + * `id` and `label` ride along because the owner can have several accounts registered and only one selected: + * a probe has to be recorded against the row it actually reached, and a log line saying which books answered + * is the difference between "invoices is broken" and "you are looking at the other company". + */ +export type UpstreamConfig = UpstreamTarget & { id: number; label: string }; + +/** Trailing slashes off, so `${base}/api/v1/...` never doubles the separator. */ +export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); + +// A list view is a burst of requests and each one needs the token, so the row is cached rather than re-read +// per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql, which +// then takes effect within a minute instead of needing a restart. +const TTL_MS = 60_000; +const cache = new Map(); + +/** + * The owner's SELECTED account, or null when /invoices has no account yet — the sidecar then answers 503, + * and the UI turns that into the setup form rather than a screen of empty tables. + */ +export async function getConfig(userId: number): Promise { + const hit = cache.get(userId); + if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; + + const creds = await getActiveInvoiceshelfCredentials(userId); + const cfg = creds + ? { + id: creds.id, + label: creds.label, + base: normalizeBase(creds.url), + token: creds.token, + companyId: creds.companyId, + } + : null; + cache.set(userId, { cfg, at: Date.now() }); + return cfg; +} + +/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */ +export function invalidateConfig(userId: number): void { + cache.delete(userId); + hashCache.clear(); +} + +export type Company = { id: number; name: string | null; unique_hash?: string | null }; + +/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */ +export class UpstreamError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +/** + * Trade an email and password for a Sanctum token. + * + * InvoiceShelf 2.4.2 has no screen that issues personal access tokens — `POST /api/v1/auth/login` (the + * controller upstream files under "Mobile") is the only way to get one, which meant connecting Officer + * required a curl command. Doing it here instead is the whole point: the password is used for this one call + * and never stored, and only the token it returns is persisted. + * + * Each call mints a NEW token upstream; the previous one is deliberately left alone, because Officer cannot + * know whether the owner also uses it somewhere else. Old ones are revoked from InvoiceShelf, not from here. + */ +export async function mintToken(base: string, email: string, password: string): Promise { + const res = await fetch(`${base}/api/v1/auth/login`, { + method: 'POST', + // No Origin or Referer — see the header note. `device_name` names the token in InvoiceShelf's own list. + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: email, password, device_name: 'officer' }), + redirect: 'manual', + }); + if (!res.ok) throw new UpstreamError(`sign-in failed with ${res.status}`, res.status); + const payload = (await res.json()) as { token?: unknown }; + if (typeof payload.token !== 'string' || !payload.token) { + throw new UpstreamError('sign-in returned no token', res.status); + } + return payload.token; +} + +/** + * Every company this token can act for. Asked once when an account is added, so the owner picks explicitly + * instead of inheriting whichever one the API happens to list first. + * + * This is also the FIRST call that actually exercises the token: `/app/version` is an unauthenticated route, + * so it answers 200 to a wrong token, an empty one or a revoked one. A 401 here means the token, not the URL. + */ +export async function listCompanies(cfg: UpstreamTarget): Promise { + const res = await fetch(`${cfg.base}/api/v1/companies`, { + headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' }, + redirect: 'manual', + }); + if (!res.ok) throw new UpstreamError(`companies lookup failed with ${res.status}`, res.status); + const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> }; + return (payload.data ?? []) + .filter((c): c is { id: number; name?: string } => typeof c.id === 'number') + .map((c) => ({ id: c.id, name: c.name ?? null })); +} + +// Keyed by account id, because two accounts can be two companies on one instance and the hash is per company. +const hashCache = new Map>(); + +/** + * The pinned company's `unique_hash`. The report PDFs live on web routes keyed by it + * (`/reports/sales/customers/{hash}`), not by company id, so it has to be looked up and is worth caching. + */ +export async function resolveCompanyHash(cfg: UpstreamConfig): Promise { + const cached = hashCache.get(cfg.id); + if (cached) return cached; + + const pending = (async () => { + const res = await callUpstream(cfg, { path: '/api/v1/current-company' }); + if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`); + const payload = (await res.json()) as { data?: { unique_hash?: string } }; + const hash = payload.data?.unique_hash; + if (!hash) throw new Error('current-company returned no unique_hash'); + return hash; + })().catch((err) => { + // Clear on failure so the next request retries instead of latching a transient network error forever. + hashCache.delete(cfg.id); + throw err; + }); + + hashCache.set(cfg.id, pending); + return pending; +} + +type CallOptions = { + /** Absolute path on the InvoiceShelf host, e.g. `/api/v1/invoices`. */ + path: string; + method?: string; + /** Raw search string including the leading `?`, or empty. */ + query?: string; + body?: BodyInit | null; + contentType?: string | null; + /** Defaults to `application/json`; PDF routes ask for the bytes instead. */ + accept?: string; + /** Set false for the handful of routes scoped to the user rather than a company. */ + withCompany?: boolean; +}; + +/** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */ +export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise { + const headers: Record = { + Authorization: `Bearer ${cfg.token}`, + Accept: opts.accept ?? 'application/json', + }; + + if (opts.withCompany !== false && cfg.companyId != null) headers.company = String(cfg.companyId); + if (opts.contentType) headers['Content-Type'] = opts.contentType; + + const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, { + method: opts.method ?? 'GET', + headers, + body: opts.body ?? undefined, + // A 302 here means the token was rejected and Laravel is redirecting to the HTML login. Following it + // would turn a clean 401 into a 200 full of HTML, so we surface the redirect instead. + redirect: 'manual', + }); + + if (res.status === 401 || res.status === 419 || (res.status >= 300 && res.status < 400)) { + console.warn(`[invoiceshelf] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (token rejected or stateful)`); + } + + return res; +} diff --git a/web/AccountSwitcher.tsx b/web/AccountSwitcher.tsx new file mode 100644 index 0000000..8cba76b --- /dev/null +++ b/web/AccountSwitcher.tsx @@ -0,0 +1,88 @@ +import { Link } from 'react-router'; +import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { invoicesSectionPath } from './shared'; +import { useInvoiceShelfAccountActions, useInvoiceShelfAccounts, useInvoiceShelfHealth } from './useInvoiceShelfData'; + +// Which books you are looking at, and how to change them. +// +// The nav header's subtitle was already the connection state, so the switcher takes that line rather than +// adding a second control saying nearly the same thing. With one account it stays exactly what it was: a +// line of text plus a way into the settings screen. +// +// Each entry shows its company under the label, because two accounts on the same instance differing only by +// company is the normal case here — the label alone would not tell them apart. +// +// Switching invalidates the whole ['invoiceshelf'] key: it changes the answer to every query in the +// workspace without changing any of their inputs. + +export const AccountSwitcher = () => { + const { data } = useInvoiceShelfAccounts(); + const { data: health } = useInvoiceShelfHealth(); + const { activate } = useInvoiceShelfAccountActions(); + + const accounts = data?.accounts ?? []; + const active = accounts.find((account) => account.isActive) ?? null; + + // Before the registry answers, fall back to health — it is the query that was already driving this line. + const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected'); + + if (accounts.length < 2) { + return ( + + {status} + + ); + } + + return ( + + + {status} + {activate.isPending ? ( + + ) : ( + + )} + + + + InvoiceShelf accounts + + {accounts.map((account) => ( + void activate.mutateAsync(account.id).catch(() => undefined)} + className="gap-2" + > + + + {account.label} + + {account.companyName ?? account.url} + + + + ))} + + + + + Manage accounts + + + + + ); +}; diff --git a/web/ConfirmDialog.tsx b/web/ConfirmDialog.tsx new file mode 100644 index 0000000..834e5ea --- /dev/null +++ b/web/ConfirmDialog.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +// Confirmation for the destructive and the irreversible-ish. Upstream routes every one of these through a +// single dialog store with the same "are you sure" copy; this keeps the single gate but says what will +// actually happen, because "are you sure?" on its own never told anyone anything. + +export type ConfirmState = { + title: string; + description: ReactNode; + confirmLabel?: string; + destructive?: boolean; + onConfirm: () => void; +} | null; + +export const ConfirmDialog = ({ state, onClose }: { state: ConfirmState; onClose: () => void }) => { + if (!state) return null; + + return ( + !open && onClose()}> + + + {state.title} + {state.description} + + + Cancel + { + state.onConfirm(); + onClose(); + }} + className={state.destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''} + > + {state.confirmLabel ?? 'Confirm'} + + + + + ); +}; diff --git a/web/ConnectionSection.tsx b/web/ConnectionSection.tsx new file mode 100644 index 0000000..9aea834 --- /dev/null +++ b/web/ConnectionSection.tsx @@ -0,0 +1,449 @@ +import type { CompanyOption, Credential, InvoiceShelfAccount } from './useInvoiceShelfData'; +import { useState } from 'react'; +import { Building2, Check, CheckCircle2, Loader2, Trash2, TriangleAlert } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + parseAccountError, + useAccountCompanies, + useInvoiceShelfAccountActions, + useInvoiceShelfAccounts, + useInvoiceShelfHealth, +} from './useInvoiceShelfData'; + +// Connecting InvoiceShelf instances to Officer, from the app. +// +// This screen is BOTH the setup wizard and the permanent settings page: InvoicesView renders it in place of +// whatever section the URL asks for while nothing is connected, and /invoices/settings renders it for good. +// +// The company picker is the part that has no equivalent in the photos version, and it is not a nicety: the +// `company` header scopes almost every InvoiceShelf route, and a wrong or missing value does NOT error — it +// silently returns another company's books. So the account pins one, chosen once, visibly. Two accounts on +// the same URL with the same token and different companies is an ordinary thing to have, which is why the +// LABEL is what has to be unique. +// +// The credential is never displayed, because Officer cannot display it: the token is encrypted at rest and +// the sidecar's GET has no field that could carry it back, and the password is not stored at all. + +const HINT = 'text-[11px] leading-relaxed text-muted-foreground'; + +/** InvoiceShelf's own default — its production compose publishes 8090, and the sidecar dials from this machine. */ +const DEFAULT_URL = 'http://localhost:8090'; + +const URL_HINT = + "The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " + + 'localhost here means the machine Officer runs on, and a local instance needs no TLS.'; + +const SIGN_IN_HINT = + 'Your InvoiceShelf login. It is used once, by the sidecar, to mint an API token — the password is never ' + + 'stored and never reaches the browser again. Each sign-in creates a new token in InvoiceShelf; revoke old ' + + 'ones there.'; + +const TOKEN_HINT = + 'A Sanctum token you already have. Paste it whole, including the leading "1|". It can read and write the ' + + 'entire books, so Officer stores it encrypted and never shows it again.'; + +type FieldProps = { + label: string; + hint?: string; + value: string; + onChange: (value: string) => void; + placeholder: string; + type?: string; + autoFocus?: boolean; +}; + +const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => ( + +); + +// ── credentials ────────────────────────────────────────────────────────────────────────────────── +// +// Two ways to prove the same thing, so they share one piece of state rather than two forms. Sign-in is the +// default because InvoiceShelf 2.4.2 ships no screen that issues tokens — before this, connecting Officer +// meant running a curl command against /auth/login by hand, which is exactly what the sidecar now does. + +type CredentialMode = 'login' | 'token'; + +function useCredential() { + const [mode, setMode] = useState('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [token, setToken] = useState(''); + + const filled = mode === 'token' ? !!token.trim() : !!email.trim() && !!password; + const payload: Credential = mode === 'token' ? { token: token.trim() } : { email: email.trim(), password }; + + const reset = () => { + setEmail(''); + setPassword(''); + setToken(''); + }; + + return { mode, setMode, email, setEmail, password, setPassword, token, setToken, filled, payload, reset }; +} + +type CredentialState = ReturnType; + +const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors'; + +const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => ( +
+
+ Credential +
+ + +
+
+ + {cred.mode === 'login' ? ( + <> + + + + ) : ( + + )} +
+); + +type CompanyPickerProps = { + companies: CompanyOption[]; + value: number | null; + onChange: (id: number) => void; + disabled?: boolean; +}; + +/** A plain select — the list is short, and a combobox would be machinery for four options. */ +const CompanyPicker = ({ companies, value, onChange, disabled }: CompanyPickerProps) => ( + +); + +type AccountRowProps = { account: InvoiceShelfAccount }; + +/** + * One stored account. The active one carries the live health line, because health only ever describes the + * account actually being used — showing a status next to the others would be inventing one. + */ +const AccountRow = ({ account }: AccountRowProps) => { + const { data: health, refetch: recheck, isFetching: checking } = useInvoiceShelfHealth(); + const { edit, activate, remove } = useInvoiceShelfAccountActions(); + + const cred = useCredential(); + const [error, setError] = useState(null); + const [replacing, setReplacing] = useState(false); + const [changingCompany, setChangingCompany] = useState(false); + // Removing an account throws away a token Officer can never show again, so the bin asks once. + const [confirming, setConfirming] = useState(false); + + const companies = useAccountCompanies(changingCompany ? account.id : null); + + const run = async (action: Promise) => { + setError(null); + try { + await action; + cred.reset(); + setReplacing(false); + setChangingCompany(false); + } catch (err) { + setError(parseAccountError(err).message); + } + }; + + return ( +
+
+ {account.isActive ? : } + {account.label} + {account.isActive && ( + in use + )} +
+ {!account.isActive && ( + + )} + {account.isActive && ( + + )} + +
+
+ +

{account.url}

+ +
+ + {changingCompany ? ( + <> + {companies.isLoading ? ( + + ) : ( + void run(edit.mutateAsync({ id: account.id, companyId }))} + /> + )} + + + ) : ( + <> + {account.companyName ?? `Company ${account.companyId ?? '?'}`} + + + )} +
+ + {account.isActive && ( +
+ {health?.ok ? ( + + ) : ( + + )} + {health?.ok ? `InvoiceShelf ${health.version ?? '?'}` : (health?.error ?? 'not checked yet')} +
+ )} + + {replacing ? ( +
+ +
+ + +
+
+ ) : ( + + )} + + {error &&

{error}

} +
+ ); +}; + +export const ConnectionSection = () => { + const { data, isLoading } = useInvoiceShelfAccounts(); + const { add } = useInvoiceShelfAccountActions(); + + const cred = useCredential(); + const [label, setLabel] = useState(''); + const [url, setUrl] = useState(DEFAULT_URL); + const [error, setError] = useState(null); + // Populated only when the instance answers with more than one company — see parseAccountError. + const [choices, setChoices] = useState([]); + const [companyId, setCompanyId] = useState(null); + + const accounts = data?.accounts ?? []; + const hasAccounts = accounts.length > 0; + + const submit = async (withCompany: number | null) => { + setError(null); + if (!url.trim()) return setError('The InvoiceShelf URL is required'); + if (!cred.filled) { + return setError(cred.mode === 'token' ? 'An API token is required' : 'An email and password are required'); + } + + try { + await add.mutateAsync({ label: label.trim(), url: url.trim(), companyId: withCompany, ...cred.payload }); + setLabel(''); + setUrl(DEFAULT_URL); + cred.reset(); + setChoices([]); + setCompanyId(null); + } catch (err) { + const parsed = parseAccountError(err); + // A 409 is not a failure — the credential can act for several companies and one has to be picked. Keep + // the form filled in and show the list rather than making them type it all again. + if (parsed.needsChoice && parsed.companies.length) { + setChoices(parsed.companies); + setCompanyId(parsed.companies[0]?.id ?? null); + setError(null); + return; + } + setError(parsed.message); + } + }; + + return ( +
+
+
+
+ +
+
+

{hasAccounts ? 'InvoiceShelf accounts' : 'Connect InvoiceShelf'}

+

+ {hasAccounts + ? 'Officer stores each instance and its token encrypted, for this account only. One is in use at a time.' + : 'Invoices needs an InvoiceShelf instance and a login before it can show anything.'} +

+
+
+ + {!isLoading && accounts.map((account) => )} + +
{ + ev.preventDefault(); + void submit(companyId); + }} + className="flex flex-col gap-3 rounded-lg border p-4" + > +

{hasAccounts ? 'Add another account' : 'Add an account'}

+ + + + + {choices.length > 0 && ( + + )} + + {error &&

{error}

} + +
+ + {add.isPending && Checking the instance and the credential…} +
+ +
+
+ ); +}; diff --git a/web/CustomersListView.tsx b/web/CustomersListView.tsx new file mode 100644 index 0000000..958fdf7 --- /dev/null +++ b/web/CustomersListView.tsx @@ -0,0 +1,348 @@ +import type { Column } from './components'; +import type { ConfirmState } from './ConfirmDialog'; +import type { Address, Customer } from './shared'; +import { useState } from 'react'; +import { Link } from 'react-router'; +import { Filter, MoreHorizontal, Plus, Trash2, Users, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { EmptyState, ErrorState, Field, Pagination, SearchBox, SectionShell, SimpleTable } from './components'; +import { ConfirmDialog } from './ConfirmDialog'; +import { formatDate, formatMoney } from './format'; +import { invoicesCustomerPath, PAGE_SIZE } from './shared'; +import { useListFilters } from './useInvoicesSection'; +import { useCurrency, useCustomerStats, useResourceList, useResourceMutations } from './useInvoiceShelfData'; + +// Customers. Upstream splits the search into three separate fields rather than one box — display name, +// contact name and phone are distinct API filters — so the toolbar search maps to display name and the +// other two live behind the filter row, which is where upstream puts them too. + +type Props = { onEdit: (id: number | 'new') => void }; + +export const CustomersListView = ({ onEdit }: Props) => { + const { filters, patch, select, setPage, clearFilters } = useListFilters(); + const currency = useCurrency(); + const [showFilters, setShowFilters] = useState(false); + const [selection, setSelection] = useState>(new Set()); + const [confirm, setConfirm] = useState(null); + + const { rows, meta, isLoading, error } = useResourceList('customers', { + page: filters.page, + limit: PAGE_SIZE, + display_name: filters.search || undefined, + // `status` and `from` carry contact name and phone here — one query-string slot per filter, and + // customers have no status or date range to compete for them. + contact_name: filters.status || undefined, + phone: filters.fromDate || undefined, + orderByField: 'created_at', + orderBy: 'desc', + }); + + const { remove, removeMany } = useResourceMutations('customers'); + const selected = rows.find((r) => r.id === filters.selected) ?? null; + + const toggleRow = (id: number) => + setSelection((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id)); + + const columns: Column[] = [ + { + key: 'check', + label: '', + width: '36px', + render: (row) => ( + e.stopPropagation()} className="flex items-center"> + toggleRow(row.id)} /> + + ), + }, + { + key: 'name', + label: 'Name', + width: 'minmax(150px, 2fr)', + render: (row) => ( +
+
{row.name}
+ {row.contact_name &&
{row.contact_name}
} +
+ ), + }, + { + key: 'email', + label: 'Email', + width: 'minmax(120px, 1.4fr)', + render: (row) => {row.email ?? '—'}, + }, + { + key: 'phone', + label: 'Phone', + width: 'minmax(100px, 1fr)', + render: (row) => row.phone ?? '—', + }, + { + key: 'due_amount', + label: 'Amount due', + width: 'minmax(100px, 1fr)', + align: 'right', + render: (row) => formatMoney(row.due_amount, currency), + }, + { + key: 'created_at', + label: 'Added', + width: 'minmax(90px, 0.9fr)', + render: (row) => formatDate(row.created_at, row.formatted_created_at), + }, + ]; + + const rowMenu = (row: Customer) => ( + + + + + + onEdit(row.id)}>Edit + select(row.id)}>View + + {/* Both go somewhere and change nothing, so they are anchors wearing a menu item — `asChild` hands + Radix's item semantics to the `` rather than nesting one inside the other. Middle-click + opens the filtered list in a tab, which is the point of the filter living in the URL. */} + + Their invoices + + + Their payments + + + + setConfirm({ + title: `Delete ${row.name}?`, + description: + 'InvoiceShelf refuses this while the customer still has invoices, estimates or payments — delete those first.', + confirmLabel: 'Delete', + destructive: true, + onConfirm: () => { + remove.mutate(row.id); + if (filters.selected === row.id) select(null); + }, + }) + } + > + + Delete + + + + ); + + if (error) return ; + + const toolbar = ( + <> + setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))} + /> + patch({ q: v })} placeholder="Display name…" /> + +
+ {selection.size > 0 && ( + + )} + +
+ + ); + + return ( + <> +
+
+ }> + {showFilters && ( +
+ + + {filters.isFiltered && ( + + )} +
+ )} + + r.id} + selectedKey={filters.selected} + onRowClick={(r) => select(r.id === filters.selected ? null : r.id)} + rowMenu={rowMenu} + empty={ + isLoading ? ( + + ) : ( + + ) + } + /> +
+
+ + {selected && ( +
+ onEdit(selected.id)} onClose={() => select(null)} /> +
+ )} +
+ + setConfirm(null)} /> + + ); +}; + +const CustomerDetail = ({ + customer, + onEdit, + onClose, +}: { + customer: Customer; + onEdit: () => void; + onClose: () => void; +}) => { + const currency = useCurrency(); + const { stats } = useCustomerStats(customer.id); + const chart = stats?.meta?.chartData; + + return ( +
+
+
+
{customer.name}
+ {customer.email &&
{customer.email}
} +
+ + +
+ +
+ {chart && ( +
+ + + + +
+ )} + +
+ {formatMoney(customer.due_amount, currency)} + {customer.contact_name && {customer.contact_name}} + {customer.phone && {customer.phone}} + {customer.website && {customer.website}} + {customer.tax_id && {customer.tax_id}} + {formatDate(customer.created_at, customer.formatted_created_at)} +
+ + + +
+
+ ); +}; + +const Totals = ({ label, value }: { label: string; value: string }) => ( +
+
{label}
+
+ {value} +
+
+); + +const AddressCard = ({ title, address }: { title: string; address?: Address | null }) => { + const lines = [ + address?.address_street_1, + address?.address_street_2, + [address?.city, address?.state, address?.zip].filter(Boolean).join(' '), + address?.country?.name, + ].filter((l): l is string => Boolean(l && l.trim())); + + if (!lines.length) return null; + + return ( +
+
{title}
+
{lines.join('\n')}
+
+ ); +}; diff --git a/web/DashboardView.tsx b/web/DashboardView.tsx new file mode 100644 index 0000000..b4d6524 --- /dev/null +++ b/web/DashboardView.tsx @@ -0,0 +1,279 @@ +import type { Estimate, Invoice } from './shared'; +import { useMemo } from 'react'; +import { Link } from 'react-router'; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { ArrowDownRight, ArrowUpRight, CircleDollarSign, FileText, Receipt, Wallet } from 'lucide-react'; +import { toneFill } from '@/components/Data/tone'; +import { formatAmount, formatDate, formatMoney, relativeDue, toMajor } from './format'; +import { EmptyState, ErrorState, LoadingState, StatusBadge } from './components'; +import { invoicesRecordPath, invoicesSectionPath } from './shared'; +import { useSummary } from './useInvoiceShelfData'; + +// The /invoices landing section — InvoiceShelf's own dashboard, rebuilt. +// +// Every figure here comes from the single /summary call the nav already makes, so arriving on this section +// costs no extra request. Upstream hands the chart over as five parallel arrays (months + four series) +// rather than as rows, so they are zipped into recharts' row shape below. + +export const DashboardView = () => { + const { dashboard, currency, isLoading, error } = useSummary(); + + const chartRows = useMemo(() => { + const chart = dashboard?.chart_data; + if (!chart?.months?.length) return []; + return chart.months.map((month, i) => ({ + month, + // recharts needs major units or the axis reads in cents; the tooltip re-formats from these. + // toMajor divides by 100, matching formatMoney — see the note on MINOR_PER_MAJOR in format.ts. + sales: toMajor(chart.invoice_totals?.[i]), + receipts: toMajor(chart.receipt_totals?.[i]), + expenses: toMajor(chart.expense_totals?.[i]), + net: toMajor(chart.net_income_totals?.[i]), + })); + }, [dashboard]); + + if (error) return ; + if (isLoading) return ; + if (!dashboard) return ; + + const hasChart = chartRows.some((r) => r.sales || r.receipts || r.expenses || r.net); + + return ( +
+
+ + + + +
+ + {hasChart && ( +
+
+ Last 12 months +
+ + + +
+
+
+ + + + + + + + + + compact(v)} + /> + [ + formatAmount(Math.round((value ?? 0) * 100), currency), + name ?? '', + ]} + /> + + + + + +
+
+ )} + +
+ invoicesRecordPath('invoices', row.id)} + render={(row: Invoice) => ({ + primary: row.invoice_number, + secondary: row.customer?.name ?? '—', + amount: formatMoney(row.due_amount, currency), + status: row.overdue ? 'OVERDUE' : row.paid_status, + note: relativeDue(row.due_date), + })} + /> + invoicesRecordPath('estimates', row.id)} + render={(row: Estimate) => ({ + primary: row.estimate_number, + secondary: row.customer?.name ?? '—', + amount: formatMoney(row.total, currency), + status: row.status, + note: formatDate(row.estimate_date, row.formatted_estimate_date), + })} + /> +
+
+ ); +}; + +const Gradient = ({ id, color }: { id: string; color: string }) => ( + + + + +); + +const LegendDot = ({ className, label }: { className: string; label: string }) => ( + + + {label} + +); + +/** Axis labels only — a full currency render would not fit and the tooltip carries the exact figure. */ +const compact = (v: number): string => { + const abs = Math.abs(v); + if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (abs >= 1_000) return `${(v / 1_000).toFixed(0)}k`; + return String(v); +}; + +// Every tile here only ever *goes somewhere* — no tile mutates anything — so they are anchors, not +// buttons. That is what makes the figures middle-clickable into a new tab and copyable as a link, +// which is the whole point of the section reading its filter out of the query string. +const MetricCard = ({ + icon: Icon, + tone, + label, + value, + hint, + to, +}: { + icon: typeof Wallet; + tone: string; + label: string; + value: string; + hint?: string; + to: string; +}) => ( + +
+ + + + {label} +
+
+ {value} +
+ {hint &&
{hint}
} + +); + +type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string }; + +const RecentPanel = ({ + title, + rows, + render, + href, + empty, +}: { + title: string; + rows: T[]; + render: (row: T) => RecentRender; + href: (row: T) => string; + empty: string; +}) => ( +
+
{title}
+ {rows.length === 0 ? ( +
{empty}
+ ) : ( +
+ {rows.map((row) => { + const r = render(row); + return ( + +
+
+ {r.primary} + +
+
+ {r.secondary} + {r.note && ` · ${r.note}`} +
+
+ {r.amount} + + ); + })} +
+ )} +
+); + +export { ArrowDownRight, ArrowUpRight }; diff --git a/web/DocumentEditor.tsx b/web/DocumentEditor.tsx new file mode 100644 index 0000000..a417728 --- /dev/null +++ b/web/DocumentEditor.tsx @@ -0,0 +1,732 @@ +import type { DocumentItem, DocumentTax, Estimate, Invoice, RecurringInvoice, TaxType } from './shared'; +import { useEffect, useMemo, useState } from 'react'; +import { Plus, Trash2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { ErrorState, LoadingState } from './components'; +import { formatMoney, fromMajor, toInputDate, toMajor } from './format'; +import { FREQUENCY_PRESETS } from './shared'; +import { + useCurrency, + useLookups, + useNextNumber, + useResourceDetail, + useResourceMutations, + useTemplates, +} from './useInvoiceShelfData'; + +// The invoice / estimate / recurring-invoice form. One component for all three: they share every hard part +// — the line-item table, the discounts, the taxes and the totals — and differ only in a header strip. +// An invoice has a number and two dates; an estimate the same with different labels; a recurring invoice +// has no number at all (each generated invoice takes the next one) and carries a schedule instead of dates. +// +// ── the arithmetic ─────────────────────────────────────────────────────────────────────────────── +// Ported from upstream's `use-document-calculations.ts`, and the rounding points are load-bearing because +// the server re-validates the totals we send: +// +// line sub_total = round(price × quantity) (price already in minor units) +// line discount = percentage → round(|sub_total| × d / 100) +// fixed → min(round(d × 100), |sub_total|) ← d is entered in MAJOR units +// line total = sub_total − discount +// document sub_total = Σ line totals +// tax base = document sub_total − document discount +// compound tax base = tax base + simple taxes +// total = tax_included ? tax base : tax base + total tax +// +// Every value posted is an integer of minor units. Amounts are shown to the user in major units and +// converted at the edges only — a float never survives past a keystroke. + +type Resource = 'invoices' | 'estimates' | 'recurring-invoices'; + +type Line = { + key: string; + item_id: number | null; + name: string; + description: string; + quantity: number; + /** Minor units. */ + price: number; + discount: number; + discount_type: 'fixed' | 'percentage'; + tax_type_ids: number[]; +}; + +type Props = { resource: Resource; id: number | 'new'; onClose: () => void }; + +const KIND_NOUN: Record = { + invoices: 'invoice', + estimates: 'estimate', + 'recurring-invoices': 'recurring invoice', +}; + +const newLine = (index: number): Line => ({ + key: `new-${index}`, + item_id: null, + name: '', + description: '', + quantity: 1, + price: 0, + discount: 0, + discount_type: 'fixed', + tax_type_ids: [], +}); + +export const DocumentEditor = ({ resource, id, onClose }: Props) => { + const isNew = id === 'new'; + const isRecurring = resource === 'recurring-invoices'; + const isInvoice = resource === 'invoices'; + const currency = useCurrency(); + const { customers, items, taxTypes } = useLookups(); + // A recurring invoice renders through the invoice templates — it has no gallery of its own. + const templates = useTemplates(isRecurring ? 'invoices' : resource); + const nextNumber = useNextNumber(isInvoice ? 'invoice' : 'estimate', isNew && !isRecurring); + const { record, isLoading, error } = useResourceDetail( + resource, + isNew ? null : (id as number), + ); + const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource); + + const [customerId, setCustomerId] = useState(null); + const [number, setNumber] = useState(''); + const [primaryDate, setPrimaryDate] = useState(() => toInputDate(new Date().toISOString())); + const [secondaryDate, setSecondaryDate] = useState(''); + const [reference, setReference] = useState(''); + const [template, setTemplate] = useState(''); + const [notes, setNotes] = useState(''); + const [lines, setLines] = useState([newLine(0)]); + const [discount, setDiscount] = useState(0); + const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed'); + const [docTaxIds, setDocTaxIds] = useState([]); + const [taxPerItem, setTaxPerItem] = useState<'YES' | 'NO'>('NO'); + const [taxIncluded, setTaxIncluded] = useState(false); + const [loaded, setLoaded] = useState(false); + + // Recurring-only. `frequency` is a raw cron expression upstream; a schedule authored outside the preset + // list keeps its own expression and is offered back as an extra option rather than silently rewritten. + const [frequency, setFrequency] = useState(FREQUENCY_PRESETS[2].cron); + const [limitBy, setLimitBy] = useState<'NONE' | 'COUNT' | 'DATE'>('NONE'); + const [limitCount, setLimitCount] = useState(1); + const [limitDate, setLimitDate] = useState(''); + const [sendAutomatically, setSendAutomatically] = useState(false); + const [recurringStatus, setRecurringStatus] = useState<'ACTIVE' | 'ON_HOLD'>('ACTIVE'); + + // Seed the form once, from either the loaded record or the reserved next number. Guarded by `loaded` + // rather than by a dependency list: react-query re-delivers `record` on every background refetch, and + // without the guard a refetch mid-edit would silently discard what the user had typed. + useEffect(() => { + if (loaded) return; + + if (isNew) { + // A recurring invoice has no number of its own, so it does not wait on one. + if (!isRecurring && !nextNumber) return; + if (nextNumber) setNumber(nextNumber); + setTemplate(templates[0]?.name ?? (isInvoice || isRecurring ? 'invoice1' : 'estimate1')); + setLoaded(true); + return; + } + + if (!record) return; + setCustomerId(record.customer_id ?? null); + setNumber(isInvoice ? (record.invoice_number ?? '') : (record.estimate_number ?? '')); + if (isRecurring) { + setPrimaryDate(toInputDate(record.starts_at)); + setFrequency(record.frequency ?? FREQUENCY_PRESETS[2].cron); + setLimitBy(record.limit_by === 'COUNT' || record.limit_by === 'DATE' ? record.limit_by : 'NONE'); + setLimitCount(record.limit_count ?? 1); + setLimitDate(toInputDate(record.limit_date)); + setSendAutomatically(Boolean(record.send_automatically)); + setRecurringStatus(record.status === 'ON_HOLD' ? 'ON_HOLD' : 'ACTIVE'); + } else { + setPrimaryDate(toInputDate(isInvoice ? record.invoice_date : record.estimate_date)); + setSecondaryDate(toInputDate(isInvoice ? record.due_date : record.expiry_date)); + } + setReference(record.reference_number ?? ''); + setTemplate(record.template_name ?? templates[0]?.name ?? ''); + setNotes(record.notes ?? ''); + setTaxPerItem(record.tax_per_item === 'YES' ? 'YES' : 'NO'); + setTaxIncluded(Boolean((record as { tax_included?: boolean }).tax_included)); + setDiscountType(record.discount_type === 'percentage' ? 'percentage' : 'fixed'); + // A fixed document discount is stored in minor units but entered in major ones; a percentage is a + // plain number either way. + setDiscount(record.discount_type === 'percentage' ? Number(record.discount) : toMajor(record.discount_val)); + setDocTaxIds((record.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null)); + setLines( + (record.items ?? []).map((line, i) => ({ + key: `row-${line.id ?? i}`, + item_id: line.item_id ?? null, + name: line.name, + description: line.description ?? '', + quantity: Number(line.quantity) || 0, + price: Number(line.price) || 0, + discount: line.discount_type === 'percentage' ? Number(line.discount) : toMajor(line.discount_val), + discount_type: line.discount_type === 'percentage' ? 'percentage' : 'fixed', + tax_type_ids: (line.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null), + })), + ); + setLoaded(true); + }, [record, isNew, nextNumber, templates, isInvoice, isRecurring, loaded]); + + const totals = useMemo( + () => computeTotals({ lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes }), + [lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes], + ); + + if (error) return ; + if (!isNew && isLoading) return ; + + const patchLine = (key: string, changes: Partial) => + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...changes } : l))); + + const pickItem = (key: string, itemId: number) => { + const source = items.find((i) => i.id === itemId); + if (!source) return; + patchLine(key, { + item_id: source.id, + name: source.name, + description: source.description ?? '', + price: Number(source.price) || 0, + }); + }; + + const canSave = + customerId != null && + (isRecurring || number.trim() !== '') && + primaryDate !== '' && + template !== '' && + lines.some((l) => l.name.trim()); + + const save = () => { + // The three resources agree on everything below the header, and disagree on nothing but it. + const header = isRecurring + ? { + starts_at: primaryDate, + frequency, + limit_by: limitBy, + limit_count: limitBy === 'COUNT' ? limitCount : null, + limit_date: limitBy === 'DATE' ? limitDate : null, + send_automatically: sendAutomatically, + status: recurringStatus, + } + : { + [isInvoice ? 'invoice_date' : 'estimate_date']: primaryDate, + [isInvoice ? 'due_date' : 'expiry_date']: secondaryDate || null, + [isInvoice ? 'invoice_number' : 'estimate_number']: number.trim(), + }; + + const payload = { + ...header, + customer_id: customerId, + reference_number: reference || null, + template_name: template, + notes: notes || null, + currency_id: record?.currency_id ?? currency?.id ?? null, + exchange_rate: 1, + tax_per_item: taxPerItem, + discount_per_item: lines.some((l) => l.discount) ? 'YES' : 'NO', + // The raw figure the user typed; `discount_val` alongside it is the resolved minor-unit amount. + discount, + discount_type: discountType, + discount_val: totals.documentDiscount, + sub_total: totals.subTotal, + total: totals.total, + tax: totals.totalTax, + taxes: taxPerItem === 'NO' ? docTaxIds.map((tid) => taxLine(tid, taxTypes, totals.taxBase)) : [], + items: lines + .filter((l) => l.name.trim()) + .map((l) => { + const line = computeLine(l, taxTypes, taxPerItem); + return { + item_id: l.item_id, + name: l.name.trim(), + description: l.description || null, + quantity: l.quantity, + price: l.price, + discount: l.discount, + discount_type: l.discount_type, + discount_val: line.discountVal, + tax: line.tax, + total: line.total, + taxes: taxPerItem === 'YES' ? l.tax_type_ids.map((tid) => taxLine(tid, taxTypes, line.total)) : [], + }; + }), + }; + + if (isNew) create.mutate(payload, { onSuccess: onClose }); + else update.mutate({ id: id as number, payload }, { onSuccess: onClose }); + }; + + const busy = create.isPending || update.isPending; + + return ( +
+
+
+ {isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`} +
+ + + +
+ +
+
+ + + + {!isRecurring && ( + + setNumber(ev.target.value)} className="h-8 text-xs" /> + + )} + + setPrimaryDate(ev.target.value)} + className="h-8 text-xs" + /> + + {!isRecurring && ( + + setSecondaryDate(ev.target.value)} + className="h-8 text-xs" + /> + + )} + + {isRecurring && ( + <> + + + + + + + {limitBy === 'COUNT' && ( + + setLimitCount(Number(ev.target.value))} + className="h-8 text-xs" + /> + + )} + {limitBy === 'DATE' && ( + + setLimitDate(ev.target.value)} + className="h-8 text-xs" + /> + + )} + + + + + )} + + setReference(ev.target.value)} className="h-8 text-xs" /> + + + {/* Only the names: each template also carries a `path` that is a full base64-encoded PNG + preview, several hundred KB apiece, and nothing here needs to render one. */} + + + + + + + + +
+ + {isRecurring && ( + + )} + +
+
+ Item + Qty + Price + Discount + Total + +
+ + {lines.map((line) => { + const computed = computeLine(line, taxTypes, taxPerItem); + return ( +
+
+
+
+ patchLine(line.key, { name: ev.target.value, item_id: null })} + placeholder="Description of work" + className="h-8 text-xs" + /> + +
+ patchLine(line.key, { description: ev.target.value })} + placeholder="Notes (optional)" + className="h-7 text-[11px]" + /> + {taxPerItem === 'YES' && ( + patchLine(line.key, { tax_type_ids: ids })} + /> + )} +
+ + patchLine(line.key, { quantity: Number(ev.target.value) })} + className="h-8 text-right text-xs" + /> + patchLine(line.key, { price: fromMajor(ev.target.value) })} + className="h-8 text-right text-xs" + /> +
+ patchLine(line.key, { discount: Number(ev.target.value) })} + className="h-8 text-right text-xs" + /> + +
+
{formatMoney(computed.total, currency)}
+ +
+
+ ); + })} + +
+ +
+
+ +
+
+ +