invoices, extracted from the platform as a plugin

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.
This commit is contained in:
officer
2026-08-19 03:47:44 +00:00
commit 6c70eb8eb3
38 changed files with 8556 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules
+93
View File
@@ -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<string, Tone>`, 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.**
+32
View File
@@ -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`.
+20
View File
@@ -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;
+12
View File
@@ -0,0 +1,12 @@
export {
listInvoiceshelfAccounts,
getActiveInvoiceshelfCredentials,
getInvoiceshelfCredentials,
createInvoiceshelfAccount,
updateInvoiceshelfAccount,
setActiveInvoiceshelfAccount,
deleteInvoiceshelfAccount,
recordInvoiceshelfProbe,
} from './queries';
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries';
+212
View File
@@ -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<InvoiceshelfAccount[]> {
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<InvoiceshelfCredentials | null> {
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<InvoiceshelfCredentials | null> {
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<InvoiceshelfAccount> {
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<InvoiceshelfAccount | null> {
const set: Record<string, unknown> = { 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<InvoiceshelfAccount | null> {
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<boolean> {
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<void> {
await db
.update(invoicesAccounts)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id)));
}
+58
View File
@@ -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}`),
],
);
+48
View File
@@ -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',
},
],
};
+326
View File
@@ -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<ProbeResult> {
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<Response> {
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<void> {
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<AccountBody> =>
((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<string> {
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<Response> {
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<Response> {
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<Response> {
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 '/<id>', '/<id>/activate', '/<id>/companies'. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
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);
}
+177
View File
@@ -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<string, unknown>).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'));
+322
View File
@@ -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<string, Capabilities> = {
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<string, readonly string[]> = {
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<string, string> = {
'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<Response> {
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<ArrayBuffer>): Uint8Array<ArrayBuffer> | 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<Response> {
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<string | null> {
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<unknown> {
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<Response> {
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<Response> {
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<Response | null> {
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();
}
+202
View File
@@ -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<number, { cfg: UpstreamConfig | null; at: number }>();
/**
* 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<UpstreamConfig | null> {
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<string> {
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<Company[]> {
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<number, Promise<string>>();
/**
* 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<string> {
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<Response> {
const headers: Record<string, string> = {
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;
}
+88
View File
@@ -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 (
<Link
to={invoicesSectionPath('settings')}
className="block truncate text-xs text-muted-foreground hover:text-foreground"
>
{status}
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
<span className="truncate">{status}</span>
{activate.isPending ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : (
<ChevronsUpDown className="h-3 w-3 shrink-0" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
InvoiceShelf accounts
</DropdownMenuLabel>
{accounts.map((account) => (
<DropdownMenuItem
key={account.id}
disabled={account.isActive || activate.isPending}
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
className="gap-2"
>
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1">
<span className="block truncate">{account.label}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{account.companyName ?? account.url}
</span>
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={invoicesSectionPath('settings')} className="gap-2">
<Settings2 className="h-3.5 w-3.5" />
Manage accounts
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
+50
View File
@@ -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 (
<AlertDialog open onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
<AlertDialogDescription>{state.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
state.onConfirm();
onClose();
}}
className={state.destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
>
{state.confirmLabel ?? 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
+449
View File
@@ -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) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">{label}</span>
<Input
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
type={type}
autoFocus={autoFocus}
autoComplete="off"
spellCheck={false}
/>
{hint && <span className={HINT}>{hint}</span>}
</label>
);
// ── 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<CredentialMode>('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<typeof useCredential>;
const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors';
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1">
<span className="text-xs font-medium">Credential</span>
<div className="ml-auto flex items-center gap-1 rounded-lg bg-muted p-0.5">
<button
type="button"
onClick={() => cred.setMode('login')}
className={`${TAB} ${cred.mode === 'login' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
>
Sign in
</button>
<button
type="button"
onClick={() => cred.setMode('token')}
className={`${TAB} ${cred.mode === 'token' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
>
API token
</button>
</div>
</div>
{cred.mode === 'login' ? (
<>
<Field
label="Email"
value={cred.email}
onChange={cred.setEmail}
placeholder="you@example.com"
type="email"
autoFocus={autoFocus}
/>
<Field
label="Password"
value={cred.password}
onChange={cred.setPassword}
placeholder="••••••••"
type="password"
hint={SIGN_IN_HINT}
/>
</>
) : (
<Field
label="API token"
value={cred.token}
onChange={cred.setToken}
placeholder="1|xxxxxxxx"
type="password"
hint={TOKEN_HINT}
autoFocus={autoFocus}
/>
)}
</div>
);
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) => (
<select
value={value ?? ''}
disabled={disabled}
onChange={(ev) => onChange(Number(ev.target.value))}
className="h-8 rounded-md border bg-background px-2 text-xs"
>
<option value="" disabled>
Choose a company…
</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name ?? `Company ${company.id}`}
</option>
))}
</select>
);
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<string | null>(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<unknown>) => {
setError(null);
try {
await action;
cred.reset();
setReplacing(false);
setChangingCompany(false);
} catch (err) {
setError(parseAccountError(err).message);
}
};
return (
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${account.isActive ? 'border-primary/40' : ''}`}>
<div className="flex items-center gap-2">
{account.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
<span className="truncate font-medium">{account.label}</span>
{account.isActive && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
)}
<div className="ml-auto flex items-center gap-1">
{!account.isActive && (
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={activate.isPending}
onClick={() => void run(activate.mutateAsync(account.id))}
>
Use
</Button>
)}
{account.isActive && (
<Button variant="ghost" size="sm" className="h-7" onClick={() => void recheck()}>
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
</Button>
)}
<Button
variant="ghost"
size="sm"
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
disabled={remove.isPending}
onClick={() => {
if (!confirming) return setConfirming(true);
setConfirming(false);
void run(remove.mutateAsync(account.id));
}}
>
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
<p className="truncate text-muted-foreground">{account.url}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-3.5 w-3.5 shrink-0" />
{changingCompany ? (
<>
{companies.isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<CompanyPicker
companies={companies.data?.companies ?? []}
value={account.companyId}
disabled={edit.isPending}
onChange={(companyId) => void run(edit.mutateAsync({ id: account.id, companyId }))}
/>
)}
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(false)}>
Cancel
</Button>
</>
) : (
<>
<span className="truncate">{account.companyName ?? `Company ${account.companyId ?? '?'}`}</span>
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(true)}>
Change
</Button>
</>
)}
</div>
{account.isActive && (
<div className="flex items-center gap-1.5 text-muted-foreground">
{health?.ok ? (
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
) : (
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
)}
<span>{health?.ok ? `InvoiceShelf ${health.version ?? '?'}` : (health?.error ?? 'not checked yet')}</span>
</div>
)}
{replacing ? (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<CredentialFields cred={cred} autoFocus />
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-8"
disabled={!cred.filled || edit.isPending}
onClick={() => void run(edit.mutateAsync({ id: account.id, ...cred.payload }))}
>
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Save
</Button>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
cred.reset();
setReplacing(false);
}}
>
Cancel
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setReplacing(true)}
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Replace credential
</button>
)}
{error && <p className="text-destructive">{error}</p>}
</div>
);
};
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<string | null>(null);
// Populated only when the instance answers with more than one company — see parseAccountError.
const [choices, setChoices] = useState<CompanyOption[]>([]);
const [companyId, setCompanyId] = useState<number | null>(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 (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Building2 className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold">{hasAccounts ? 'InvoiceShelf accounts' : 'Connect InvoiceShelf'}</h2>
<p className="text-xs text-muted-foreground">
{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.'}
</p>
</div>
</div>
{!isLoading && accounts.map((account) => <AccountRow key={account.id} account={account} />)}
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit(companyId);
}}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<p className="text-xs font-medium">{hasAccounts ? 'Add another account' : 'Add an account'}</p>
<Field
label="Label"
value={label}
onChange={setLabel}
placeholder="Optional — defaults to the company name"
hint="What the switcher calls this account. Two accounts cannot share a label; two can share a URL, a login and everything but the company."
autoFocus={hasAccounts}
/>
<Field
label="InvoiceShelf URL"
value={url}
onChange={setUrl}
placeholder={DEFAULT_URL}
hint={URL_HINT}
autoFocus={!hasAccounts}
/>
<CredentialFields cred={cred} />
{choices.length > 0 && (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium">Company</span>
<CompanyPicker companies={choices} value={companyId} onChange={setCompanyId} disabled={add.isPending} />
<span className={HINT}>
This login can act for more than one company. Whichever you pick is pinned to this account — add a
second account for the other one.
</span>
</label>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<Button type="submit" size="sm" disabled={add.isPending || (choices.length > 0 && companyId == null)}>
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{add.isPending ? 'Verifying…' : hasAccounts ? 'Add account' : 'Connect'}
</Button>
{add.isPending && <span className={HINT}>Checking the instance and the credential…</span>}
</div>
</form>
</div>
</div>
);
};
+348
View File
@@ -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<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<Customer>('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<Customer>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'name',
label: 'Name',
width: 'minmax(150px, 2fr)',
render: (row) => (
<div className="min-w-0">
<div className="truncate font-medium">{row.name}</div>
{row.contact_name && <div className="truncate text-[10px] text-muted-foreground">{row.contact_name}</div>}
</div>
),
},
{
key: 'email',
label: 'Email',
width: 'minmax(120px, 1.4fr)',
render: (row) => <span className="truncate text-muted-foreground">{row.email ?? '—'}</span>,
},
{
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) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Both go somewhere and change nothing, so they are anchors wearing a menu item — `asChild` hands
Radix's item semantics to the `<Link>` 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. */}
<DropdownMenuItem asChild>
<Link to={invoicesCustomerPath('invoices', row.id)}>Their invoices</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={invoicesCustomerPath('payments', row.id)}>Their payments</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
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);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Display name…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} customers?`,
description: 'Any that still have documents against them are refused.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New customer
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Contact name
<Input
value={filters.status ?? ''}
onChange={(e) => patch({ status: e.target.value || null })}
className="h-8 w-40 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Phone
<Input
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-40 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Users} title="Loading…" />
) : (
<EmptyState
icon={Users}
title={filters.isFiltered ? 'No customers match' : 'No customers yet'}
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Every invoice needs one — add the first.'}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[38%] min-w-[320px] shrink-0 lg:block">
<CustomerDetail customer={selected} onEdit={() => onEdit(selected.id)} onClose={() => select(null)} />
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => 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 (
<div className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background">
<div className="flex shrink-0 items-start gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{customer.name}</div>
{customer.email && <div className="truncate text-[10px] text-muted-foreground">{customer.email}</div>}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onEdit}>
Edit
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{chart && (
<div className="grid grid-cols-2 gap-2">
<Totals label="Sales" value={formatMoney(chart.salesTotal, currency)} />
<Totals label="Receipts" value={formatMoney(chart.totalReceipts, currency)} />
<Totals label="Expenses" value={formatMoney(chart.totalExpenses, currency)} />
<Totals label="Net profit" value={formatMoney(chart.netProfit, currency)} />
</div>
)}
<div className="mt-3 rounded-lg border border-border px-3 py-1">
<Field label="Amount due">{formatMoney(customer.due_amount, currency)}</Field>
{customer.contact_name && <Field label="Contact">{customer.contact_name}</Field>}
{customer.phone && <Field label="Phone">{customer.phone}</Field>}
{customer.website && <Field label="Website">{customer.website}</Field>}
{customer.tax_id && <Field label="Tax ID">{customer.tax_id}</Field>}
<Field label="Customer since">{formatDate(customer.created_at, customer.formatted_created_at)}</Field>
</div>
<AddressCard title="Billing address" address={customer.billing} />
<AddressCard title="Shipping address" address={customer.shipping} />
</div>
</div>
);
};
const Totals = ({ label, value }: { label: string; value: string }) => (
<div className="rounded-lg border border-border p-2">
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className="truncate text-sm font-semibold tabular-nums" title={value}>
{value}
</div>
</div>
);
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 (
<div className="mt-3 rounded-lg border border-border p-3">
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{title}</div>
<div className="whitespace-pre-line text-xs">{lines.join('\n')}</div>
</div>
);
};
+279
View File
@@ -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 <ErrorState error={error} />;
if (isLoading) return <LoadingState />;
if (!dashboard) return <EmptyState title="No dashboard data" />;
const hasChart = chartRows.some((r) => r.sales || r.receipts || r.expenses || r.net);
return (
<div className="h-full overflow-y-auto p-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
icon={CircleDollarSign}
tone={toneFill.warning}
label="Amount due"
value={formatMoney(dashboard.total_amount_due, currency)}
hint={`${dashboard.total_invoice_count} invoices`}
to={`${invoicesSectionPath('invoices')}?status=UNPAID`}
/>
<MetricCard
icon={FileText}
tone={toneFill.info}
label="Sales"
value={formatMoney(dashboard.total_sales, currency)}
to={invoicesSectionPath('invoices')}
/>
<MetricCard
icon={Wallet}
tone={toneFill.success}
label="Receipts"
value={formatMoney(dashboard.total_receipts, currency)}
to={invoicesSectionPath('payments')}
/>
<MetricCard
icon={Receipt}
tone="bg-primary/15 text-primary"
label="Net income"
value={formatMoney(dashboard.total_net_income, currency)}
hint={`${formatMoney(dashboard.total_expenses, currency)} expenses`}
to={invoicesSectionPath('expenses')}
/>
</div>
{hasChart && (
<div className="mt-4 rounded-xl border border-border p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium">Last 12 months</span>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<LegendDot className="bg-info" label="Sales" />
<LegendDot className="bg-success" label="Receipts" />
<LegendDot className="bg-destructive" label="Expenses" />
</div>
</div>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<defs>
<Gradient id="isSales" color="var(--info)" />
<Gradient id="isReceipts" color="var(--success)" />
<Gradient id="isExpenses" color="var(--destructive)" />
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis
tick={{ fontSize: 10 }}
tickLine={false}
axisLine={false}
width={56}
tickFormatter={(v: number) => compact(v)}
/>
<Tooltip
contentStyle={{ fontSize: 11, borderRadius: 8 }}
// recharts types both arguments as possibly-undefined — a series can have gaps, and a
// datum can carry no name — so neither may be annotated as required here.
formatter={(value: number | undefined, name: string | undefined) => [
formatAmount(Math.round((value ?? 0) * 100), currency),
name ?? '',
]}
/>
<Area
type="monotone"
dataKey="sales"
name="Sales"
stroke="var(--info)"
fill="url(#isSales)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="receipts"
name="Receipts"
stroke="var(--success)"
fill="url(#isReceipts)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="expenses"
name="Expenses"
stroke="var(--destructive)"
fill="url(#isExpenses)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<RecentPanel
title="Due invoices"
empty="Nothing outstanding."
rows={dashboard.recent_due_invoices ?? []}
href={(row) => 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),
})}
/>
<RecentPanel
title="Recent estimates"
empty="No estimates yet."
rows={dashboard.recent_estimates ?? []}
href={(row) => 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),
})}
/>
</div>
</div>
);
};
const Gradient = ({ id, color }: { id: string; color: string }) => (
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
);
const LegendDot = ({ className, label }: { className: string; label: string }) => (
<span className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${className}`} />
{label}
</span>
);
/** 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;
}) => (
<Link
to={to}
className="flex flex-col gap-2 rounded-xl border border-border p-3 text-left transition-colors hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<span className={`flex h-7 w-7 items-center justify-center rounded-lg ${tone}`}>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
<div className="truncate text-lg font-semibold tabular-nums" title={value}>
{value}
</div>
{hint && <div className="truncate text-[10px] text-muted-foreground">{hint}</div>}
</Link>
);
type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string };
const RecentPanel = <T extends { id: number }>({
title,
rows,
render,
href,
empty,
}: {
title: string;
rows: T[];
render: (row: T) => RecentRender;
href: (row: T) => string;
empty: string;
}) => (
<div className="rounded-xl border border-border">
<div className="border-b border-border px-3 py-2 text-xs font-medium">{title}</div>
{rows.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">{empty}</div>
) : (
<div className="divide-y divide-border/50">
{rows.map((row) => {
const r = render(row);
return (
<Link
key={row.id}
to={href(row)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium">{r.primary}</span>
<StatusBadge value={r.status} />
</div>
<div className="truncate text-[10px] text-muted-foreground">
{r.secondary}
{r.note && ` · ${r.note}`}
</div>
</div>
<span className="shrink-0 text-xs tabular-nums">{r.amount}</span>
</Link>
);
})}
</div>
)}
</div>
);
export { ArrowDownRight, ArrowUpRight };
+732
View File
@@ -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<Resource, string> = {
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<Invoice & Estimate & RecurringInvoice>(
resource,
isNew ? null : (id as number),
);
const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource);
const [customerId, setCustomerId] = useState<number | null>(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<Line[]>([newLine(0)]);
const [discount, setDiscount] = useState(0);
const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed');
const [docTaxIds, setDocTaxIds] = useState<number[]>([]);
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<string>(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 <ErrorState error={error} />;
if (!isNew && isLoading) return <LoadingState />;
const patchLine = (key: string, changes: Partial<Line>) =>
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 (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1 text-xs font-semibold">
{isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`}
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="sm" className="h-7 text-xs" onClick={save} disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Labelled label="Customer">
<select
value={customerId ?? ''}
onChange={(ev) => setCustomerId(ev.target.value ? Number(ev.target.value) : null)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">Select…</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Labelled>
{!isRecurring && (
<Labelled label={isInvoice ? 'Invoice number' : 'Estimate number'}>
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
</Labelled>
)}
<Labelled label={isRecurring ? 'Starts on' : isInvoice ? 'Invoice date' : 'Estimate date'}>
<Input
type="date"
value={primaryDate}
onChange={(ev) => setPrimaryDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
{!isRecurring && (
<Labelled label={isInvoice ? 'Due date' : 'Expiry date'}>
<Input
type="date"
value={secondaryDate}
onChange={(ev) => setSecondaryDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
)}
{isRecurring && (
<>
<Labelled label="Repeats">
<select
value={frequency}
onChange={(ev) => setFrequency(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{FREQUENCY_PRESETS.map((f) => (
<option key={f.cron} value={f.cron}>
{f.label}
</option>
))}
{!FREQUENCY_PRESETS.some((f) => f.cron === frequency) && (
<option value={frequency}>Custom ({frequency})</option>
)}
</select>
</Labelled>
<Labelled label="Ends">
<select
value={limitBy}
onChange={(ev) => setLimitBy(ev.target.value as 'NONE' | 'COUNT' | 'DATE')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="NONE">Never</option>
<option value="COUNT">After a number of invoices</option>
<option value="DATE">On a date</option>
</select>
</Labelled>
{limitBy === 'COUNT' && (
<Labelled label="How many">
<Input
type="number"
min={1}
value={limitCount}
onChange={(ev) => setLimitCount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Labelled>
)}
{limitBy === 'DATE' && (
<Labelled label="Until">
<Input
type="date"
value={limitDate}
onChange={(ev) => setLimitDate(ev.target.value)}
className="h-8 text-xs"
/>
</Labelled>
)}
<Labelled label="Status">
<select
value={recurringStatus}
onChange={(ev) => setRecurringStatus(ev.target.value as 'ACTIVE' | 'ON_HOLD')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="ACTIVE">Active</option>
<option value="ON_HOLD">On hold</option>
</select>
</Labelled>
</>
)}
<Labelled label="Reference">
<Input value={reference} onChange={(ev) => setReference(ev.target.value)} className="h-8 text-xs" />
</Labelled>
<Labelled label="Template">
{/* 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. */}
<select
value={template}
onChange={(ev) => setTemplate(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{templates.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
{template && !templates.some((t) => t.name === template) && <option value={template}>{template}</option>}
</select>
</Labelled>
<Labelled label="Tax">
<select
value={taxPerItem}
onChange={(ev) => setTaxPerItem(ev.target.value as 'YES' | 'NO')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="NO">On the whole document</option>
<option value="YES">Per line</option>
</select>
</Labelled>
<Labelled label="Prices">
<select
value={taxIncluded ? 'incl' : 'excl'}
onChange={(ev) => setTaxIncluded(ev.target.value === 'incl')}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
<option value="excl">Tax exclusive</option>
<option value="incl">Tax inclusive</option>
</select>
</Labelled>
</div>
{isRecurring && (
<label className="mt-3 flex items-center gap-2 text-xs">
<Checkbox
checked={sendAutomatically}
onCheckedChange={(checked) => setSendAutomatically(checked === true)}
/>
Email each generated invoice to the customer automatically
</label>
)}
<div className="mt-4 rounded-lg border border-border">
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-center gap-2 border-b border-border px-2 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<span>Item</span>
<span className="text-right">Qty</span>
<span className="text-right">Price</span>
<span className="text-right">Discount</span>
<span className="text-right">Total</span>
<span />
</div>
{lines.map((line) => {
const computed = computeLine(line, taxTypes, taxPerItem);
return (
<div key={line.key} className="border-b border-border/40 px-2 py-2">
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-start gap-2">
<div className="min-w-0 space-y-1">
<div className="flex gap-1">
<Input
value={line.name}
onChange={(ev) => patchLine(line.key, { name: ev.target.value, item_id: null })}
placeholder="Description of work"
className="h-8 text-xs"
/>
<select
value=""
onChange={(ev) => ev.target.value && pickItem(line.key, Number(ev.target.value))}
title="Fill from the item catalogue"
className="h-8 w-8 shrink-0 rounded-md border border-input bg-background text-xs"
>
<option value="">+</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
<Input
value={line.description}
onChange={(ev) => patchLine(line.key, { description: ev.target.value })}
placeholder="Notes (optional)"
className="h-7 text-[11px]"
/>
{taxPerItem === 'YES' && (
<TaxPicker
taxTypes={taxTypes}
selected={line.tax_type_ids}
onChange={(ids) => patchLine(line.key, { tax_type_ids: ids })}
/>
)}
</div>
<Input
type="number"
value={line.quantity}
onChange={(ev) => patchLine(line.key, { quantity: Number(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<Input
type="number"
step="0.01"
value={toMajor(line.price)}
onChange={(ev) => patchLine(line.key, { price: fromMajor(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<div className="flex gap-1">
<Input
type="number"
step="0.01"
value={line.discount}
onChange={(ev) => patchLine(line.key, { discount: Number(ev.target.value) })}
className="h-8 text-right text-xs"
/>
<select
value={line.discount_type}
onChange={(ev) =>
patchLine(line.key, { discount_type: ev.target.value as 'fixed' | 'percentage' })
}
className="h-8 w-9 shrink-0 rounded-md border border-input bg-background text-xs"
>
<option value="fixed">{currency?.symbol ?? '#'}</option>
<option value="percentage">%</option>
</select>
</div>
<div className="pt-2 text-right text-xs tabular-nums">{formatMoney(computed.total, currency)}</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
disabled={lines.length === 1}
onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
<div className="px-2 py-2">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setLines((prev) => [...prev, newLine(prev.length)])}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add line
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
<div>
<Labelled label="Notes">
<Textarea
value={notes}
onChange={(ev) => setNotes(ev.target.value)}
rows={6}
className="text-xs"
placeholder="Shown on the document."
/>
</Labelled>
</div>
<div className="rounded-lg border border-border p-3">
<TotalRow label="Subtotal" value={formatMoney(totals.subTotal, currency)} />
<div className="flex items-center justify-between gap-2 py-1 text-xs">
<span className="text-muted-foreground">Discount</span>
<div className="flex items-center gap-1">
<Input
type="number"
step="0.01"
value={discount}
onChange={(ev) => setDiscount(Number(ev.target.value))}
className="h-7 w-24 text-right text-xs"
/>
<select
value={discountType}
onChange={(ev) => setDiscountType(ev.target.value as 'fixed' | 'percentage')}
className="h-7 w-10 rounded-md border border-input bg-background text-xs"
>
<option value="fixed">{currency?.symbol ?? '#'}</option>
<option value="percentage">%</option>
</select>
</div>
</div>
{taxPerItem === 'NO' && (
<div className="py-1">
<div className="mb-1 text-[10px] text-muted-foreground">Taxes</div>
<TaxPicker taxTypes={taxTypes} selected={docTaxIds} onChange={setDocTaxIds} />
</div>
)}
<TotalRow label="Tax" value={formatMoney(totals.totalTax, currency)} />
<div className="mt-1 border-t border-border pt-1">
<TotalRow label="Total" value={formatMoney(totals.total, currency)} strong />
</div>
</div>
</div>
</div>
</div>
);
};
const Labelled = ({ label, children }: { label: string; children: React.ReactNode }) => (
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
{label}
{children}
</label>
);
const TotalRow = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums ${strong ? 'text-sm font-semibold' : ''}`}>{value}</span>
</div>
);
/** Multi-select over the company's tax types, rendered as toggles because there are only ever a handful. */
const TaxPicker = ({
taxTypes,
selected,
onChange,
}: {
taxTypes: TaxType[];
selected: number[];
onChange: (ids: number[]) => void;
}) => {
if (!taxTypes.length) return <span className="text-[10px] text-muted-foreground">No tax types defined.</span>;
return (
<div className="flex flex-wrap gap-1">
{taxTypes.map((t) => {
const on = selected.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => onChange(on ? selected.filter((i) => i !== t.id) : [...selected, t.id])}
className={`rounded-full px-2 py-0.5 text-[10px] ring-1 ring-inset transition-colors ${
on ? 'bg-primary/10 text-primary ring-primary/30' : 'text-muted-foreground ring-border hover:bg-muted'
}`}
>
{t.name} {Number(t.percent)}%
</button>
);
})}
</div>
);
};
// ── arithmetic ───────────────────────────────────────────────────────────────────────────────────
type LineTotals = { subTotal: number; discountVal: number; total: number; tax: number };
function computeLine(line: Line, taxTypes: TaxType[], taxPerItem: 'YES' | 'NO'): LineTotals {
const subTotal = Math.round(line.price * (Number(line.quantity) || 0));
const abs = Math.abs(subTotal);
const discountVal =
line.discount_type === 'percentage'
? Math.round((abs * (Number(line.discount) || 0)) / 100)
: Math.min(fromMajor(line.discount), abs);
const total = subTotal - discountVal;
const tax =
taxPerItem === 'YES'
? line.tax_type_ids.reduce(
(sum, tid) =>
sum +
percentOf(
total,
taxTypes.find((t) => t.id === tid),
),
0,
)
: 0;
return { subTotal, discountVal, total, tax };
}
const percentOf = (base: number, taxType?: TaxType): number =>
taxType ? Math.round((base * (Number(taxType.percent) || 0)) / 100) : 0;
type TotalsInput = {
lines: Line[];
discount: number;
discountType: 'fixed' | 'percentage';
docTaxIds: number[];
taxPerItem: 'YES' | 'NO';
taxIncluded: boolean;
taxTypes: TaxType[];
};
function computeTotals(input: TotalsInput) {
const { lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes } = input;
const computed = lines.map((l) => computeLine(l, taxTypes, taxPerItem));
const subTotal = computed.reduce((sum, c) => sum + c.total, 0);
const documentDiscount =
discountType === 'percentage'
? Math.round((Math.abs(subTotal) * (Number(discount) || 0)) / 100)
: Math.min(fromMajor(discount), Math.abs(subTotal));
const taxBase = subTotal - documentDiscount;
let totalTax: number;
if (taxPerItem === 'YES') {
totalTax = computed.reduce((sum, c) => sum + c.tax, 0);
} else {
const chosen = docTaxIds.map((tid) => taxTypes.find((t) => t.id === tid)).filter((t): t is TaxType => Boolean(t));
const simple = chosen.filter((t) => !t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase, t), 0);
// A compound tax is charged on the base plus the simple taxes already added — the order matters and
// reversing it silently under-charges.
const compound = chosen.filter((t) => t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase + simple, t), 0);
totalTax = simple + compound;
}
return {
subTotal,
documentDiscount,
taxBase,
totalTax,
total: taxIncluded ? taxBase : taxBase + totalTax,
};
}
/** The tax row shape upstream persists alongside a document or a line. */
function taxLine(taxTypeId: number, taxTypes: TaxType[], base: number): Partial<DocumentTax> & { tax_type_id: number } {
const t = taxTypes.find((x) => x.id === taxTypeId);
return {
tax_type_id: taxTypeId,
name: t?.name,
percent: Number(t?.percent) || 0,
compound_tax: t?.compound_tax ?? false,
amount: percentOf(base, t),
};
}
+407
View File
@@ -0,0 +1,407 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { SendTarget } from './SendDocumentDialog';
import type { Estimate } from './shared';
import { useState } from 'react';
import { Check, Copy, FileSpreadsheet, FileUp, Filter, MoreHorizontal, Plus, Send, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
EmptyState,
ErrorState,
Pagination,
SearchBox,
SectionShell,
SimpleTable,
StatusBadge,
StatusTabs,
} from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { SendDocumentDialog } from './SendDocumentDialog';
import { PdfPane } from './PdfPane';
import { formatDate, formatMoney } from './format';
import { ESTIMATE_TABS, PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import {
useCurrency,
useDocumentAction,
useLookups,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// Estimates — upstream's EstimateIndexView. Same skeleton as the invoices list, different verbs: an estimate
// is accepted or rejected rather than paid, and its terminal move is converting into an invoice.
//
// Convert is one-way and upstream refuses it on a REJECTED estimate, so the item is hidden in that state
// rather than offered and rejected. After a successful convert the new invoice exists alongside this
// estimate; the toast says so, because nothing on this screen would otherwise show where it went.
type Props = { onEdit: (id: number | 'new') => void; onConverted: (invoiceId: number) => void };
export const EstimatesListView = ({ onEdit, onConverted }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
const { rows, meta, isLoading, error } = useResourceList<Estimate>('estimates', {
page: filters.page,
limit: PAGE_SIZE,
estimate_number: filters.search || undefined,
status: filters.status || undefined,
customer_id: filters.customerId || undefined,
from_date: filters.fromDate || undefined,
to_date: filters.toDate || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('estimates');
const action = useDocumentAction('estimates');
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<Estimate>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'estimate_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.estimate_date, row.formatted_estimate_date),
},
{
key: 'estimate_number',
label: 'Number',
width: 'minmax(90px, 0.8fr)',
render: (row) => <span className="font-medium">{row.estimate_number}</span>,
},
{
key: 'name',
label: 'Customer',
width: 'minmax(120px, 1.4fr)',
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
},
{
key: 'status',
label: 'Status',
width: 'minmax(90px, 0.9fr)',
render: (row) => <StatusBadge value={row.status} />,
},
{
key: 'total',
label: 'Total',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.total, currency),
},
];
const setStatus = (row: Estimate, status: string, label: string) =>
setConfirm({
title: `Mark ${row.estimate_number} as ${label.toLowerCase()}?`,
description: 'This changes the status only. Nothing is emailed to the customer.',
confirmLabel: `Mark as ${label.toLowerCase()}`,
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status } }),
});
const rowMenu = (row: Estimate) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
{row.status === 'DRAFT' && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'estimates',
id: row.id,
number: row.estimate_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Send estimate
</DropdownMenuItem>
)}
{(row.status === 'SENT' || row.status === 'VIEWED') && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'estimates',
id: row.id,
number: row.estimate_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
resend: true,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Resend estimate
</DropdownMenuItem>
)}
{row.status !== 'SENT' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'SENT', 'Sent')}>Mark as sent</DropdownMenuItem>
)}
{row.status !== 'ACCEPTED' && row.status !== 'REJECTED' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'ACCEPTED', 'Accepted')}>
<Check className="mr-2 h-3.5 w-3.5" />
Mark as accepted
</DropdownMenuItem>
)}
{row.status !== 'REJECTED' && row.status !== 'ACCEPTED' && (
<DropdownMenuItem onSelect={() => setStatus(row, 'REJECTED', 'Rejected')}>
<X className="mr-2 h-3.5 w-3.5" />
Mark as rejected
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
<Copy className="mr-2 h-3.5 w-3.5" />
Clone estimate
</DropdownMenuItem>
{row.status !== 'REJECTED' && (
<DropdownMenuItem
onSelect={() =>
setConfirm({
title: `Convert ${row.estimate_number} into an invoice?`,
description:
'A new invoice is created from this estimate. The estimate itself stays where it is — this is not a move.',
confirmLabel: 'Convert',
onConfirm: () =>
action.mutate(
{ id: row.id, action: 'convert-to-invoice' },
{
onSuccess: (data) => {
const newId = (data as { data?: { id?: number } })?.data?.id;
if (newId) onConverted(newId);
},
},
),
})
}
>
<FileUp className="mr-2 h-3.5 w-3.5" />
Convert into invoice
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
setConfirm({
title: `Delete estimate ${row.estimate_number}?`,
description: 'This permanently removes the estimate.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<StatusTabs
tabs={ESTIMATE_TABS}
value={filters.status}
onChange={(v) => {
patch({ status: v });
setSelection(new Set());
}}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Estimate number…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} estimates?`,
description: 'This permanently removes them.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New estimate
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={filters.toDate ?? ''}
onChange={(e) => patch({ to: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={FileSpreadsheet} title="Loading…" />
) : (
<EmptyState
icon={FileSpreadsheet}
title={filters.isFiltered ? 'No estimates match' : 'No estimates yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Estimates become invoices once a customer accepts.'
}
action={
<Button size="sm" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New estimate
</Button>
}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
<PdfPane
resource="estimates"
id={selected.id}
title={`Estimate ${selected.estimate_number}`}
subtitle={selected.customer?.name}
filename={`estimate-${selected.estimate_number}`}
onClose={() => select(null)}
/>
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
</>
);
};
+313
View File
@@ -0,0 +1,313 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { Expense } from './shared';
import { useState } from 'react';
import { Copy, Filter, MoreHorizontal, Paperclip, Plus, Receipt, Trash2 } 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, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { formatDate, formatMoney } from './format';
import { PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import {
useCurrency,
useDocumentAction,
useLookups,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// Expenses — money going the other way, and the only list with a file attached to its rows.
//
// The receipt link opens `attachment_receipt_url` directly. That URL is InvoiceShelf's own, rendered from the
// instance's APP_URL, so it is a browser-resolvable link rather than something proxied through the sidecar —
// which means it only works from a machine that can reach the InvoiceShelf host. Left as-is deliberately:
// proxying arbitrary uploaded files is a wider surface than this screen needs.
type Props = { onEdit: (id: number | 'new') => void };
export const ExpensesListView = ({ onEdit }: Props) => {
const { filters, patch, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers, categories } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<Expense>('expenses', {
page: filters.page,
limit: PAGE_SIZE,
search: filters.search || undefined,
customer_id: filters.customerId || undefined,
expense_category_id: filters.status || undefined,
from_date: filters.fromDate || undefined,
to_date: filters.toDate || undefined,
orderByField: 'expense_date',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('expenses');
const action = useDocumentAction('expenses');
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<Expense>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'expense_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.expense_date, row.formatted_expense_date),
},
{
key: 'expense_number',
label: 'Number',
width: 'minmax(90px, 0.8fr)',
render: (row) => <span className="font-medium">{row.expense_number ?? '—'}</span>,
},
{
key: 'category',
label: 'Category',
width: 'minmax(100px, 1fr)',
render: (row) => <span className="truncate">{row.expense_category?.name ?? '—'}</span>,
},
{
key: 'customer',
label: 'Customer',
width: 'minmax(100px, 1fr)',
render: (row) => <span className="truncate text-muted-foreground">{row.customer?.name ?? '—'}</span>,
},
{
key: 'notes',
label: 'Notes',
width: 'minmax(120px, 1.4fr)',
render: (row) => (
<span className="flex min-w-0 items-center gap-1.5">
{row.attachment_receipt_url && (
<a
href={row.attachment_receipt_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title="Open receipt"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-3 w-3" />
</a>
)}
<span className="truncate text-muted-foreground">{row.notes ?? '—'}</span>
</span>
),
},
{
key: 'amount',
label: 'Amount',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.amount, currency),
},
];
const rowMenu = (row: Expense) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'duplicate' })}>
<Copy className="mr-2 h-3.5 w-3.5" />
Duplicate
</DropdownMenuItem>
{row.attachment_receipt_url && (
<DropdownMenuItem onSelect={() => window.open(row.attachment_receipt_url ?? '', '_blank', 'noopener')}>
<Paperclip className="mr-2 h-3.5 w-3.5" />
Open receipt
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
setConfirm({
title: 'Delete this expense?',
description: 'It disappears from the profit & loss and expense reports.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => remove.mutate(row.id),
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Notes…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} expenses?`,
description: 'They disappear from the profit & loss and expense reports.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New expense
</Button>
</div>
</>
);
return (
<>
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Category
<select
value={filters.status ?? ''}
onChange={(e) => patch({ status: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={filters.toDate ?? ''}
onChange={(e) => patch({ to: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
onRowClick={(r) => onEdit(r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Receipt} title="Loading…" />
) : (
<EmptyState
icon={Receipt}
title={filters.isFiltered ? 'No expenses match' : 'No expenses yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Recording expenses is what makes the net income figure mean anything.'
}
/>
)
}
/>
</SectionShell>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
</>
);
};
+406
View File
@@ -0,0 +1,406 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { SendTarget } from './SendDocumentDialog';
import type { Invoice } from './shared';
import { useState } from 'react';
import { Link } from 'react-router';
import { Copy, FileText, Filter, MoreHorizontal, Plus, Send, Trash2, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
EmptyState,
ErrorState,
Pagination,
SearchBox,
SectionShell,
SimpleTable,
StatusBadge,
StatusTabs,
} from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { SendDocumentDialog } from './SendDocumentDialog';
import { PdfPane } from './PdfPane';
import { formatDate, formatMoney, relativeDue } from './format';
import { INVOICE_TABS, PAGE_SIZE, type Invoice as InvoiceType } from './shared';
import { useListFilters } from './useInvoicesSection';
import {
useCurrency,
useDocumentAction,
useLookups,
useResourceMutations,
useResourceList,
} from './useInvoiceShelfData';
// The invoices list — upstream's InvoiceIndexView, with its column order, its four tabs and its filter set.
//
// Two deliberate departures from the map of the Vue app:
//
// * "Convert to Estimate" is absent. That row exists in the 3.0 source, but this instance runs 2.4.2 and
// has no invoices/{id}/convert-to-estimate route — the sidecar's allow-list reflects the live routes, so
// offering it here would render a menu item that 404s.
// * "Edit" and "Record Payment" open the editors in this app rather than routing to InvoiceShelf.
//
// `allow_edit` is honoured rather than ignored: once an invoice has payments against it, upstream locks
// editing, and letting a user open a form whose save will be rejected is worse than hiding the button.
type Props = { onEdit: (id: number | 'new') => void; recordPaymentHref: (invoiceId: number) => string };
export const InvoicesListView = ({ onEdit, recordPaymentHref }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
const { rows, meta, isLoading, error } = useResourceList<InvoiceType>('invoices', {
page: filters.page,
limit: PAGE_SIZE,
invoice_number: filters.search || undefined,
status: filters.status || undefined,
customer_id: filters.customerId || undefined,
from_date: filters.fromDate || undefined,
to_date: filters.toDate || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { removeMany, remove } = useResourceMutations('invoices');
const action = useDocumentAction('invoices');
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<Invoice>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'invoice_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.invoice_date, row.formatted_invoice_date),
},
{
key: 'invoice_number',
label: 'Number',
width: 'minmax(90px, 0.8fr)',
render: (row) => <span className="font-medium">{row.invoice_number}</span>,
},
{
key: 'name',
label: 'Customer',
width: 'minmax(120px, 1.4fr)',
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
},
{
key: 'status',
label: 'Status',
width: 'minmax(90px, 0.9fr)',
render: (row) => <StatusBadge value={row.status} />,
},
{
key: 'due_amount',
label: 'Amount due',
width: 'minmax(120px, 1.1fr)',
align: 'right',
render: (row) => (
<div className="flex items-center justify-end gap-1.5">
{row.overdue && <StatusBadge value="OVERDUE" />}
<StatusBadge value={row.paid_status} />
<span>{formatMoney(row.due_amount, currency)}</span>
</div>
),
},
{
key: 'total',
label: 'Total',
width: 'minmax(90px, 0.9fr)',
align: 'right',
render: (row) => formatMoney(row.total, currency),
},
];
const rowMenu = (row: Invoice) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{row.allow_edit && <DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>}
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
{row.status === 'DRAFT' && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'invoices',
id: row.id,
number: row.invoice_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Send invoice
</DropdownMenuItem>
)}
{(row.status === 'SENT' || row.status === 'VIEWED') && (
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'invoices',
id: row.id,
number: row.invoice_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
resend: true,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Resend invoice
</DropdownMenuItem>
)}
{/* Opening the payment editor is an address (`?edit=new&pay=<id>`), not a side effect — the editor
prefills itself from those params — so it is an anchor, not a callback up to the parent and
back down through `navigate`. */}
{(row.status === 'SENT' || row.status === 'VIEWED') && (
<DropdownMenuItem asChild>
<Link to={recordPaymentHref(row.id)}>
<Wallet className="mr-2 h-3.5 w-3.5" />
Record payment
</Link>
</DropdownMenuItem>
)}
{row.status === 'DRAFT' && (
<DropdownMenuItem
onSelect={() =>
setConfirm({
title: `Mark ${row.invoice_number} as sent?`,
// Worth spelling out — the wording is one word away from the action that does email it.
description:
'This only changes the status to Sent. Nothing is emailed to the customer. Use “Send invoice” for that.',
confirmLabel: 'Mark as sent',
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status: 'SENT' } }),
})
}
>
Mark as sent
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
<Copy className="mr-2 h-3.5 w-3.5" />
Clone invoice
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
setConfirm({
title: `Delete invoice ${row.invoice_number}?`,
description: 'This permanently removes the invoice and any payments recorded against it.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
title="Select all on this page"
/>
<StatusTabs
tabs={INVOICE_TABS}
value={filters.status}
onChange={(v) => {
patch({ status: v });
setSelection(new Set());
}}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Invoice number…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} invoices?`,
description: 'This permanently removes them and any payments recorded against them.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New invoice
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={filters.fromDate ?? ''}
onChange={(e) => patch({ from: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={filters.toDate ?? ''}
onChange={(e) => patch({ to: e.target.value || null })}
className="h-8 w-36 text-xs"
/>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={FileText} title="Loading…" />
) : (
<EmptyState
icon={FileText}
title={filters.isFiltered ? 'No invoices match' : 'No invoices yet'}
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Create the first one to get started.'}
action={
<Button size="sm" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New invoice
</Button>
}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
<PdfPane
resource="invoices"
id={selected.id}
title={`Invoice ${selected.invoice_number}`}
subtitle={
<>
{selected.customer?.name}
{selected.due_date && ` · ${relativeDue(selected.due_date)}`}
</>
}
filename={`invoice-${selected.invoice_number}`}
onClose={() => select(null)}
/>
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
</>
);
};
+121
View File
@@ -0,0 +1,121 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import {
Building2,
FileText,
FileSpreadsheet,
LayoutDashboard,
Package,
PieChart,
Plug,
Receipt,
RefreshCw,
Users,
Wallet,
} from 'lucide-react';
import { INVOICES_SECTIONS, invoicesSectionPath, type InvoicesSectionId } from './shared';
import { AccountSwitcher } from './AccountSwitcher';
import { formatMoney } from './format';
import { useSummary } from './useInvoiceShelfData';
// Left panel of /invoices: the company it is pointed at, which account that is, then the sections.
//
// Sections are real links so cmd-click, back and reload behave. Counts come from the dashboard totals that
// useSummary already holds — no section fetches a count of its own just to render a badge.
//
// The subtitle is the account switcher rather than the amount outstanding, because with several sets of
// books on screen "whose books are these" is the question the header has to answer. Outstanding moved into
// the totals at the bottom, where it sits with the rest of the money.
const ICONS: Record<InvoicesSectionId, LucideIcon> = {
dashboard: LayoutDashboard,
invoices: FileText,
estimates: FileSpreadsheet,
recurring: RefreshCw,
payments: Wallet,
expenses: Receipt,
customers: Users,
items: Package,
reports: PieChart,
settings: Plug,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
export const InvoicesNav = () => {
const { company, currency, dashboard, isLoading } = useSummary();
const counts: Partial<Record<InvoicesSectionId, number>> = {
invoices: dashboard?.total_invoice_count,
estimates: dashboard?.total_estimate_count,
customers: dashboard?.total_customer_count,
};
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary ring-1 ring-inset ring-border">
<Building2 className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight" title={company?.name ?? undefined}>
{company?.name ?? (isLoading ? 'Loading…' : 'InvoiceShelf')}
</div>
<AccountSwitcher />
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2 pb-3">
{INVOICES_SECTIONS.map(({ id, label }) => {
const Icon = ICONS[id];
const count = counts[id];
return (
<NavLink
key={id}
to={invoicesSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
<span className="flex-1">{label}</span>
{count != null && count > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
)}
</>
)}
</NavLink>
);
})}
</nav>
{dashboard && (
<div className="mt-auto space-y-1.5 border-t border-border/60 px-4 py-3 text-[11px]">
<NavRowStat label="Outstanding" value={formatMoney(dashboard.total_amount_due, currency)} />
<NavRowStat label="Sales" value={formatMoney(dashboard.total_sales, currency)} />
<NavRowStat label="Received" value={formatMoney(dashboard.total_receipts, currency)} />
<NavRowStat label="Expenses" value={formatMoney(dashboard.total_expenses, currency)} />
<NavRowStat label="Net" value={formatMoney(dashboard.total_net_income, currency)} strong />
</div>
)}
</div>
);
};
const NavRowStat = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={`tabular-nums ${strong ? 'font-semibold text-foreground' : 'text-foreground/80'}`}>{value}</span>
</div>
);
+154
View File
@@ -0,0 +1,154 @@
import { useCallback } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { ConnectionSection } from './ConnectionSection';
import { CustomersListView } from './CustomersListView';
import { DashboardView } from './DashboardView';
import { DocumentEditor } from './DocumentEditor';
import { EstimatesListView } from './EstimatesListView';
import { ExpensesListView } from './ExpensesListView';
import { InvoicesListView } from './InvoicesListView';
import { ItemsListView } from './ItemsListView';
import { PaymentsListView } from './PaymentsListView';
import { RecurringListView } from './RecurringListView';
import { ReportsView } from './ReportsView';
import { CustomerEditor, ExpenseEditor, ItemEditor, PaymentEditor } from './RecordEditors';
import { invoicesNewPaymentPath, invoicesSectionPath } from './shared';
import { useInvoiceShelfHealth } from './useInvoiceShelfData';
import { useInvoicesSection } from './useInvoicesSection';
// Right panel of the /invoices workspace: renders the section named by the URL, and owns the one piece of
// state the sections share — which record is being edited.
//
// That lives in the query string (`?edit=42`, `?edit=new`) rather than in component state, for the same
// reason selection does: it is addressable. Back closes the editor instead of leaving the screen, a
// half-written form survives a reload of the tab, and "record a payment against invoice 12" is a link
// (`?edit=new&pay=12`) rather than a click path.
type EditTarget = number | 'new' | null;
const parseEdit = (raw: string | null): EditTarget => {
if (raw === 'new') return 'new';
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
};
export const InvoicesView = () => {
const section = useInvoicesSection();
const { data: health, isLoading: healthLoading } = useInvoiceShelfHealth();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const edit = parseEdit(params.get('edit'));
const payFor = Number(params.get('pay')) || null;
const openEditor = useCallback(
(id: number | 'new', extra?: Record<string, string>) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set('edit', String(id));
for (const [key, value] of Object.entries(extra ?? {})) next.set(key, value);
return next;
},
{ replace: false },
),
[setParams],
);
const closeEditor = useCallback(
() =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('edit');
next.delete('pay');
return next;
},
{ replace: true },
),
[setParams],
);
// Health gates every section, and its two failure modes are answered differently: nothing configured yet is
// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored
// instance that is failing keeps its own message and a way back, since replacing a working token by
// accident is worse than a wall of text.
if (section === 'settings') return <ConnectionSection />;
if (!healthLoading && health && !health.ok) {
if (health.configured === false) return <ConnectionSection />;
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">InvoiceShelf is not answering</p>
<p className="max-w-sm text-xs text-muted-foreground">
{health.error ?? 'The invoiceshelf sidecar could not reach the configured instance.'}
</p>
<Link to={invoicesSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
Check the connection
</Link>
</div>
);
}
// Documents get a full-height editor rather than a dialog — a line-item table does not fit in one, and
// upstream gives them a whole page too.
if (edit != null && (section === 'invoices' || section === 'estimates' || section === 'recurring')) {
const resource = section === 'recurring' ? 'recurring-invoices' : section;
return <DocumentEditor resource={resource} id={edit} onClose={closeEditor} />;
}
switch (section) {
case 'invoices':
return <InvoicesListView onEdit={openEditor} recordPaymentHref={invoicesNewPaymentPath} />;
case 'estimates':
return (
<EstimatesListView
onEdit={openEditor}
onConverted={(invoiceId) => navigate(`/invoices/invoices?selected=${invoiceId}`)}
/>
);
case 'recurring':
return <RecurringListView onEdit={openEditor} />;
case 'payments':
return (
<>
<PaymentsListView onEdit={openEditor} />
{edit != null && <PaymentEditor id={edit} forInvoiceId={payFor ?? undefined} onClose={closeEditor} />}
</>
);
case 'expenses':
return (
<>
<ExpensesListView onEdit={openEditor} />
{edit != null && <ExpenseEditor id={edit} onClose={closeEditor} />}
</>
);
case 'customers':
return (
<>
<CustomersListView onEdit={openEditor} />
{edit != null && <CustomerEditor id={edit} onClose={closeEditor} />}
</>
);
case 'items':
return (
<>
<ItemsListView onEdit={openEditor} />
{edit != null && <ItemEditor id={edit} onClose={closeEditor} />}
</>
);
case 'reports':
return <ReportsView />;
default:
return <DashboardView />;
}
};
+36
View File
@@ -0,0 +1,36 @@
import { AlertTriangle, Receipt } from 'lucide-react';
import { formatMoney } from './format';
import { INVOICES_SECTIONS } from './shared';
import { useInvoicesSection } from './useInvoicesSection';
import { useSummary } from './useInvoiceShelfData';
// Panel header for the right (invoices-view) panel: which section, which company, and the one figure that
// is worth seeing from every section — how much is still owed.
export const InvoicesViewHeader = () => {
const section = useInvoicesSection();
const { company, currency, dashboard, error } = useSummary();
const label = INVOICES_SECTIONS.find((s) => s.id === section)?.label ?? 'Invoices';
return (
<>
<Receipt className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{company?.name && <span className="ml-1.5 font-normal text-muted-foreground">· {company.name}</span>}
</span>
{error ? (
<span className="flex shrink-0 items-center gap-1 text-[10px] text-destructive">
<AlertTriangle className="h-3 w-3" />
unreachable
</span>
) : (
dashboard && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{formatMoney(dashboard.total_amount_due, currency)} due
</span>
)
)}
</>
);
};
+262
View File
@@ -0,0 +1,262 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { Item } from './shared';
import { useState } from 'react';
import { Filter, MoreHorizontal, Package, Plus, Trash2 } 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, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { formatDate, formatMoney, parseMoneyMinor } from './format';
import { PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import { useCurrency, useLookups, useResourceList, useResourceMutations } from './useInvoiceShelfData';
// Items — the reusable line-item catalogue. The shortest screen here, and the only list with no detail pane:
// an item is four fields, so clicking one opens the editor directly rather than a preview of itself.
type Props = { onEdit: (id: number | 'new') => void };
export const ItemsListView = ({ onEdit }: Props) => {
const { filters, patch, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { units } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [priceInput, setPriceInput] = useState('');
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<Item>('items', {
page: filters.page,
limit: PAGE_SIZE,
search: filters.search || undefined,
unit_id: filters.customerId || undefined,
// Upstream matches price EXACTLY, in minor units — it is a lookup, not a range. `1000` finds items
// priced at 10.00 and nothing else.
price: filters.status || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('items');
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<Item>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'name',
label: 'Name',
width: 'minmax(150px, 2fr)',
render: (row) => (
<div className="min-w-0">
<div className="truncate font-medium">{row.name}</div>
{row.description && <div className="truncate text-[10px] text-muted-foreground">{row.description}</div>}
</div>
),
},
{
key: 'unit',
label: 'Unit',
width: 'minmax(80px, 0.8fr)',
render: (row) => row.unit?.name ?? '—',
},
{
key: 'price',
label: 'Price',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.price, currency),
},
{
key: 'created_at',
label: 'Added',
width: 'minmax(90px, 0.9fr)',
render: (row) => formatDate(null, row.formatted_created_at),
},
];
const rowMenu = (row: Item) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
setConfirm({
title: `Delete ${row.name}?`,
description:
'Lines already written from this item stay as they are — an invoice keeps its own copy of the name and price.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => remove.mutate(row.id),
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const applyPrice = () => {
const trimmed = priceInput.trim();
patch({ status: trimmed ? String(parseMoneyMinor(trimmed, currency)) : null });
};
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Item name…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} items?`,
description: 'Lines already written from them are unaffected.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New item
</Button>
</div>
</>
);
return (
<>
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Unit
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{units.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Exact price
<Input
value={priceInput}
onChange={(e) => setPriceInput(e.target.value)}
onBlur={applyPrice}
onKeyDown={(e) => e.key === 'Enter' && applyPrice()}
placeholder="10.00"
className="h-8 w-28 text-xs"
/>
</label>
{filters.isFiltered && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setPriceInput('');
clearFilters();
}}
>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
onRowClick={(r) => onEdit(r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Package} title="Loading…" />
) : (
<EmptyState
icon={Package}
title={filters.isFiltered ? 'No items match' : 'No items yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Items are optional — they just save retyping the same line.'
}
/>
)
}
/>
</SectionShell>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
</>
);
};
+302
View File
@@ -0,0 +1,302 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { SendTarget } from './SendDocumentDialog';
import type { Payment } from './shared';
import { useState } from 'react';
import { Filter, MoreHorizontal, Plus, Send, Trash2, Wallet } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { EmptyState, ErrorState, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { SendDocumentDialog } from './SendDocumentDialog';
import { PdfPane } from './PdfPane';
import { formatDate, formatMoney } from './format';
import { PAGE_SIZE } from './shared';
import { useListFilters } from './useInvoicesSection';
import { useCurrency, useLookups, useResourceList, useResourceMutations } from './useInvoiceShelfData';
// Payments — upstream's PaymentIndexView. A payment is a receipt against an invoice, so the interesting
// columns are which invoice and by what method; the PDF is the receipt itself.
//
// There is no status here and so no tabs: a payment either exists or it does not.
type Props = { onEdit: (id: number | 'new') => void };
export const PaymentsListView = ({ onEdit }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const { customers, paymentMethods } = useLookups();
const [showFilters, setShowFilters] = useState(false);
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
const { rows, meta, isLoading, error } = useResourceList<Payment>('payments', {
page: filters.page,
limit: PAGE_SIZE,
payment_number: filters.search || undefined,
customer_id: filters.customerId || undefined,
payment_method_id: filters.status || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('payments');
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<Payment>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'payment_date',
label: 'Date',
width: 'minmax(90px, 0.8fr)',
render: (row) => formatDate(row.payment_date, row.formatted_payment_date),
},
{
key: 'payment_number',
label: 'Number',
width: 'minmax(100px, 0.9fr)',
render: (row) => <span className="font-medium">{row.payment_number}</span>,
},
{
key: 'name',
label: 'Customer',
width: 'minmax(120px, 1.3fr)',
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
},
{
key: 'payment_mode',
label: 'Mode',
width: 'minmax(90px, 0.9fr)',
render: (row) => row.payment_method?.name ?? '—',
},
{
key: 'invoice_number',
label: 'Invoice',
width: 'minmax(90px, 0.9fr)',
render: (row) => row.invoice?.invoice_number ?? '—',
},
{
key: 'amount',
label: 'Amount',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.amount, currency),
},
];
const rowMenu = (row: Payment) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View receipt</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
setSendTarget({
resource: 'payments',
id: row.id,
number: row.payment_number,
customerName: row.customer?.name ?? '',
customerEmail: row.customer?.email ?? null,
})
}
>
<Send className="mr-2 h-3.5 w-3.5" />
Send receipt
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
setConfirm({
title: `Delete payment ${row.payment_number}?`,
description:
'The amount goes back onto the linked invoice as due. This does not refund anything — it only removes the record.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Payment number…" />
<Button
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
size="sm"
className="h-8"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filter
</Button>
<div className="ml-auto flex items-center gap-2">
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} payments?`,
description: 'Their amounts go back onto the linked invoices as due.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New payment
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
{showFilters && (
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Customer
<select
value={filters.customerId ?? ''}
onChange={(e) => patch({ customer: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Payment mode
{/* Reuses the `status` slot in the query string — one filter key per list, and payments
have no status of their own to compete for it. */}
<select
value={filters.status ?? ''}
onChange={(e) => patch({ status: e.target.value || null })}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="">All</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</label>
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
</div>
)}
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Wallet} title="Loading…" />
) : (
<EmptyState
icon={Wallet}
title={filters.isFiltered ? 'No payments match' : 'No payments yet'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'Record one against a sent invoice to mark it paid.'
}
/>
)
}
/>
</SectionShell>
</div>
{selected && (
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
<PdfPane
resource="payments"
id={selected.id}
title={`Payment ${selected.payment_number}`}
subtitle={selected.customer?.name}
filename={`payment-${selected.payment_number}`}
onClose={() => select(null)}
/>
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
</>
);
};
+91
View File
@@ -0,0 +1,91 @@
import type { ReactNode } from 'react';
import { Download, ExternalLink, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadPdf, usePdfUrl } from './useInvoiceShelfData';
// The right-hand detail pane for invoices, estimates and payments — upstream's document view, which is
// itself just a PDF iframe with an action bar over it.
//
// The PDF is fetched as a blob and handed to the iframe as an object URL rather than being pointed at the
// API path directly. It has to be: the sidecar sits behind Officer's bearer auth and an <iframe src> sends
// no Authorization header, so a direct src would render a 401 body. usePdfUrl owns that fetch and revokes
// the object URL when the document changes, so clicking down a list does not leak a PDF per click.
//
// These PDFs are also the reason the sidecar repairs magic bytes: InvoiceShelf 2.4.2 prepends ~200 bytes of
// a serialized HTTP response to its own PDF body, which every renderer refuses. By the time it arrives here
// it is a clean %PDF.
export const PdfPane = ({
resource,
id,
title,
subtitle,
filename,
actions,
onClose,
}: {
resource: string;
id: number | null;
title: string;
subtitle?: ReactNode;
filename: string;
actions?: ReactNode;
onClose: () => void;
}) => {
const { url, isLoading, error } = usePdfUrl(resource, id);
if (id == null) return null;
return (
<div className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background">
<div className="flex shrink-0 items-start gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{title}</div>
{subtitle && <div className="truncate text-[10px] text-muted-foreground">{subtitle}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{actions}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={!url}
title="Download PDF"
onClick={() => url && downloadPdf(url, filename)}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={!url}
title="Open in new tab"
onClick={() => url && window.open(url, '_blank', 'noopener')}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 bg-muted/30">
{isLoading && (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Rendering PDF…
</div>
)}
{error && !isLoading && (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<div className="text-xs font-medium text-destructive">Could not load PDF</div>
<div className="max-w-xs text-[10px] text-muted-foreground">{error}</div>
</div>
)}
{url && !isLoading && <iframe src={url} title={title} className="h-full w-full border-0" />}
</div>
</div>
);
};
+734
View File
@@ -0,0 +1,734 @@
import type { ReactNode } from 'react';
import type { Address, Customer, Expense, Invoice, Item, Payment } from './shared';
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { formatMoney, fromMajor, toInputDate, toMajor } from './format';
import {
useCurrency,
useLookups,
useNextNumber,
useResourceDetail,
useResourceList,
useResourceMutations,
} from './useInvoiceShelfData';
// The four small record forms: customer, item, payment, expense. Dialogs rather than full screens —
// each is a handful of fields, and unlike a document none of them needs a line-item table.
//
// One shape for all four: `id === 'new'` creates, a number edits, and the form seeds itself once from the
// loaded record. The seed is guarded by a `loaded` flag rather than a dependency list because react-query
// re-delivers the record on every background refetch, and re-seeding mid-edit would silently discard what
// the user had typed.
//
// Money is entered in MAJOR units and written back in minor ones (`fromMajor`), the same convention as the
// document editor. Reading goes the other way through `toMajor`.
type EditorProps = { id: number | 'new'; onClose: () => void };
// ── shared bits ──────────────────────────────────────────────────────────────────────────────────
const Row = ({ children }: { children: ReactNode }) => <div className="grid gap-3 sm:grid-cols-2">{children}</div>;
const Cell = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">{label}</Label>
{children}
</div>
);
const Select = ({
value,
onChange,
children,
}: {
value: string | number;
onChange: (value: string) => void;
children: ReactNode;
}) => (
<select
value={value}
onChange={(ev) => onChange(ev.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
>
{children}
</select>
);
const EditorShell = ({
title,
description,
canSave,
busy,
onSave,
onClose,
children,
wide,
}: {
title: string;
description?: string;
canSave: boolean;
busy: boolean;
onSave: () => void;
onClose: () => void;
children: ReactNode;
wide?: boolean;
}) => (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className={wide ? 'sm:max-w-2xl' : 'sm:max-w-lg'}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="max-h-[65vh] space-y-3 overflow-y-auto pr-1">{children}</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="sm" onClick={onSave} disabled={!canSave || busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
// ── customer ─────────────────────────────────────────────────────────────────────────────────────
const EMPTY_ADDRESS = {
name: '',
address_street_1: '',
address_street_2: '',
city: '',
state: '',
zip: '',
phone: '',
country_id: '' as number | '',
};
type AddressForm = typeof EMPTY_ADDRESS;
const fromAddress = (a: Address | null | undefined): AddressForm =>
a
? {
name: a.name ?? '',
address_street_1: a.address_street_1 ?? '',
address_street_2: a.address_street_2 ?? '',
city: a.city ?? '',
state: a.state ?? '',
zip: a.zip ?? '',
phone: a.phone ?? '',
country_id: a.country_id ?? '',
}
: { ...EMPTY_ADDRESS };
export const CustomerEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const { currencies } = useLookups();
const currency = useCurrency();
const { record } = useResourceDetail<Customer>('customers', isNew ? null : (id as number));
const { create, update } = useResourceMutations('customers');
// Read-only upstream; the address selects are unusable without it.
const { rows: countries } = useResourceList<{ id: number; name: string }>('countries');
const [form, setForm] = useState({
name: '',
email: '',
phone: '',
contact_name: '',
company_name: '',
website: '',
prefix: '',
tax_id: '',
currency_id: '' as number | '',
enable_portal: false,
});
const [billing, setBilling] = useState<AddressForm>({ ...EMPTY_ADDRESS });
const [shipping, setShipping] = useState<AddressForm>({ ...EMPTY_ADDRESS });
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setForm({
name: record.name ?? '',
email: record.email ?? '',
phone: record.phone ?? '',
contact_name: record.contact_name ?? '',
company_name: record.company_name ?? '',
website: record.website ?? '',
prefix: record.prefix ?? '',
tax_id: record.tax_id ?? '',
currency_id: record.currency_id ?? '',
enable_portal: Boolean(record.enable_portal),
});
setBilling(fromAddress(record.billing));
setShipping(fromAddress(record.shipping));
setLoaded(true);
}, [record, isNew, loaded]);
const patch = (changes: Partial<typeof form>) => setForm((prev) => ({ ...prev, ...changes }));
const save = () => {
const payload = {
...form,
email: form.email || null,
currency_id: form.currency_id === '' ? (currency?.id ?? null) : form.currency_id,
billing: { ...billing, country_id: billing.country_id === '' ? null : billing.country_id },
shipping: { ...shipping, country_id: shipping.country_id === '' ? null : shipping.country_id },
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New customer' : `Edit ${form.name || 'customer'}`}
canSave={form.name.trim() !== ''}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
wide
>
<Row>
<Cell label="Display name">
<Input value={form.name} onChange={(ev) => patch({ name: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Contact name">
<Input
value={form.contact_name}
onChange={(ev) => patch({ contact_name: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Email">
<Input
type="email"
value={form.email}
onChange={(ev) => patch({ email: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Phone">
<Input value={form.phone} onChange={(ev) => patch({ phone: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Company">
<Input
value={form.company_name}
onChange={(ev) => patch({ company_name: ev.target.value })}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Website">
<Input value={form.website} onChange={(ev) => patch({ website: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Tax ID">
<Input value={form.tax_id} onChange={(ev) => patch({ tax_id: ev.target.value })} className="h-8 text-xs" />
</Cell>
<Cell label="Currency">
<Select value={form.currency_id} onChange={(v) => patch({ currency_id: v ? Number(v) : '' })}>
<option value="">Company default</option>
{currencies.map((c) => (
<option key={c.id} value={c.id}>
{c.code} — {c.name}
</option>
))}
</Select>
</Cell>
</Row>
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={form.enable_portal}
onCheckedChange={(checked) => patch({ enable_portal: checked === true })}
/>
Give this customer a portal login
</label>
<AddressFields label="Billing address" value={billing} onChange={setBilling} countries={countries} />
<AddressFields
label="Shipping address"
value={shipping}
onChange={setShipping}
countries={countries}
onCopy={() => setShipping({ ...billing })}
/>
</EditorShell>
);
};
const AddressFields = ({
label,
value,
onChange,
countries,
onCopy,
}: {
label: string;
value: AddressForm;
onChange: (next: AddressForm) => void;
countries: { id: number; name: string }[];
onCopy?: () => void;
}) => {
const patch = (changes: Partial<AddressForm>) => onChange({ ...value, ...changes });
return (
<div className="rounded-lg border border-border p-2">
<div className="mb-2 flex items-center justify-between">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
{onCopy && (
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={onCopy}>
Same as billing
</Button>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="Name"
value={value.name}
onChange={(ev) => patch({ name: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Phone"
value={value.phone}
onChange={(ev) => patch({ phone: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Street"
value={value.address_street_1}
onChange={(ev) => patch({ address_street_1: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="Street 2"
value={value.address_street_2}
onChange={(ev) => patch({ address_street_2: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="City"
value={value.city}
onChange={(ev) => patch({ city: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="State"
value={value.state}
onChange={(ev) => patch({ state: ev.target.value })}
className="h-8 text-xs"
/>
<Input
placeholder="ZIP"
value={value.zip}
onChange={(ev) => patch({ zip: ev.target.value })}
className="h-8 text-xs"
/>
<Select value={value.country_id} onChange={(v) => patch({ country_id: v ? Number(v) : '' })}>
<option value="">Country…</option>
{countries.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</div>
</div>
);
};
// ── item ─────────────────────────────────────────────────────────────────────────────────────────
export const ItemEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { units } = useLookups();
const { record } = useResourceDetail<Item>('items', isNew ? null : (id as number));
const { create, update } = useResourceMutations('items');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState(0);
const [unitId, setUnitId] = useState<number | ''>('');
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setName(record.name ?? '');
setDescription(record.description ?? '');
setPrice(toMajor(record.price));
setUnitId(record.unit_id ?? '');
setLoaded(true);
}, [record, isNew, loaded]);
const save = () => {
const payload = {
name: name.trim(),
description: description || null,
price: fromMajor(price),
unit_id: unitId === '' ? null : unitId,
currency_id: record?.currency_id ?? currency?.id ?? null,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New item' : `Edit ${name || 'item'}`}
description="A saved line you can drop onto any invoice or estimate."
canSave={name.trim() !== ''}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Cell label="Name">
<Input value={name} onChange={(ev) => setName(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Row>
<Cell label={`Price (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={price}
onChange={(ev) => setPrice(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Unit">
<Select value={unitId} onChange={(v) => setUnitId(v ? Number(v) : '')}>
<option value="">None</option>
{units.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</Select>
</Cell>
</Row>
<Cell label="Description">
<Textarea value={description} onChange={(ev) => setDescription(ev.target.value)} rows={3} className="text-xs" />
</Cell>
</EditorShell>
);
};
// ── payment ──────────────────────────────────────────────────────────────────────────────────────
/**
* `forInvoiceId` is the "Record payment" path from the invoices list: the invoice is known, its customer
* and outstanding amount are not, so they are fetched and filled in rather than made the user's problem.
*/
export const PaymentEditor = ({ id, forInvoiceId, onClose }: EditorProps & { forInvoiceId?: number }) => {
const isNew = id === 'new';
const currency = useCurrency();
const { customers, paymentMethods } = useLookups();
const { record } = useResourceDetail<Payment>('payments', isNew ? null : (id as number));
const { record: seedInvoice } = useResourceDetail<Invoice>('invoices', isNew ? (forInvoiceId ?? null) : null);
const { create, update } = useResourceMutations('payments');
const nextNumber = useNextNumber('payment', isNew);
const [customerId, setCustomerId] = useState<number | ''>('');
const [invoiceId, setInvoiceId] = useState<number | ''>('');
const [number, setNumber] = useState('');
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
const [amount, setAmount] = useState(0);
const [methodId, setMethodId] = useState<number | ''>('');
const [notes, setNotes] = useState('');
const [loaded, setLoaded] = useState(false);
// `status: 'DUE'` is upstream's own token for "paid_status in (UNPAID, PARTIALLY_PAID)" — the only
// invoices a payment can sensibly be attached to. Fetched only once a customer is chosen.
const { rows: openInvoices } = useResourceList<Invoice>(
'invoices',
{ customer_id: customerId || undefined, status: 'DUE', limit: 100 },
customerId !== '',
);
useEffect(() => {
if (loaded) return;
if (isNew) {
if (!nextNumber) return;
// Wait for the seed invoice too, or the form would open blank and then jump under the user's cursor.
if (forInvoiceId != null && !seedInvoice) return;
setNumber(nextNumber);
if (seedInvoice) {
setCustomerId(seedInvoice.customer_id);
setInvoiceId(seedInvoice.id);
setAmount(toMajor(seedInvoice.due_amount));
}
setLoaded(true);
return;
}
if (!record) return;
setCustomerId(record.customer_id ?? '');
setInvoiceId(record.invoice_id ?? '');
setNumber(record.payment_number ?? '');
setDate(toInputDate(record.payment_date));
setAmount(toMajor(record.amount));
setMethodId(record.payment_method_id ?? '');
setNotes(record.notes ?? '');
setLoaded(true);
}, [record, isNew, nextNumber, seedInvoice, forInvoiceId, loaded]);
// The pre-selected invoice may not be in the customer's open list yet (that query runs a beat later), so
// fall back to the seed for the over-payment warning.
const selected =
openInvoices.find((inv) => inv.id === invoiceId) ??
(seedInvoice && seedInvoice.id === invoiceId ? seedInvoice : undefined);
const save = () => {
const payload = {
payment_date: date,
payment_number: number.trim(),
customer_id: customerId === '' ? null : customerId,
invoice_id: invoiceId === '' ? null : invoiceId,
payment_method_id: methodId === '' ? null : methodId,
amount: fromMajor(amount),
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'Record a payment' : `Edit payment ${number}`}
description="Recording a payment reduces the linked invoice's due amount. It does not email anyone."
canSave={customerId !== '' && number.trim() !== '' && date !== '' && amount > 0}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Row>
<Cell label="Date">
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Payment number">
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Customer">
<Select
value={customerId}
onChange={(v) => {
setCustomerId(v ? Number(v) : '');
// The old invoice belongs to the old customer; keeping it would post the payment to a
// document the new customer does not own.
setInvoiceId('');
}}
>
<option value="">Select…</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label="Invoice">
<Select
value={invoiceId}
onChange={(v) => {
const next = v ? Number(v) : '';
setInvoiceId(next);
// Default the amount to whatever is still outstanding — the overwhelmingly common case, and
// still editable for a part payment.
const inv = openInvoices.find((i) => i.id === next);
if (inv) setAmount(toMajor(inv.due_amount));
}}
>
<option value="">{customerId === '' ? 'Pick a customer first' : 'Unlinked'}</option>
{/* The pre-selected invoice is listed explicitly in case the open-invoice query has not landed
yet — otherwise the select would show a blank for a value it does hold. */}
{seedInvoice && !openInvoices.some((inv) => inv.id === seedInvoice.id) && (
<option value={seedInvoice.id}>
{seedInvoice.invoice_number} — {formatMoney(seedInvoice.due_amount, currency)} due
</option>
)}
{openInvoices.map((inv) => (
<option key={inv.id} value={inv.id}>
{inv.invoice_number} — {formatMoney(inv.due_amount, currency)} due
</option>
))}
</Select>
</Cell>
<Cell label={`Amount (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={amount}
onChange={(ev) => setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Method">
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
<option value="">None</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Cell>
</Row>
{selected && fromMajor(amount) > Number(selected.due_amount ?? 0) && (
<p className="text-[10px] text-warning">
More than the {formatMoney(selected.due_amount, currency)} outstanding on {selected.invoice_number}.
InvoiceShelf will refuse this.
</p>
)}
<Cell label="Notes">
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
</Cell>
</EditorShell>
);
};
// ── expense ──────────────────────────────────────────────────────────────────────────────────────
export const ExpenseEditor = ({ id, onClose }: EditorProps) => {
const isNew = id === 'new';
const currency = useCurrency();
const { customers, categories, paymentMethods } = useLookups();
const { record } = useResourceDetail<Expense>('expenses', isNew ? null : (id as number));
const { create, update } = useResourceMutations('expenses');
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
const [number, setNumber] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>('');
const [customerId, setCustomerId] = useState<number | ''>('');
const [methodId, setMethodId] = useState<number | ''>('');
const [amount, setAmount] = useState(0);
const [notes, setNotes] = useState('');
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (loaded || isNew || !record) return;
setDate(toInputDate(record.expense_date));
setNumber(record.expense_number ?? '');
setCategoryId(record.expense_category_id ?? '');
setCustomerId(record.customer_id ?? '');
setMethodId(record.payment_method_id ?? '');
setAmount(toMajor(record.amount));
setNotes(record.notes ?? '');
setLoaded(true);
}, [record, isNew, loaded]);
const save = () => {
const payload = {
expense_date: date,
expense_number: number || null,
expense_category_id: categoryId === '' ? null : categoryId,
customer_id: customerId === '' ? null : customerId,
payment_method_id: methodId === '' ? null : methodId,
amount: fromMajor(amount),
notes: notes || null,
currency_id: record?.currency_id ?? currency?.id ?? null,
exchange_rate: 1,
};
if (isNew) create.mutate(payload, { onSuccess: onClose });
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
};
return (
<EditorShell
title={isNew ? 'New expense' : 'Edit expense'}
canSave={categoryId !== '' && date !== '' && amount !== 0}
busy={create.isPending || update.isPending}
onSave={save}
onClose={onClose}
>
<Row>
<Cell label="Date">
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
</Cell>
<Cell label="Number">
<Input
value={number}
onChange={(ev) => setNumber(ev.target.value)}
placeholder="Optional"
className="h-8 text-xs"
/>
</Cell>
<Cell label="Category">
<Select value={categoryId} onChange={(v) => setCategoryId(v ? Number(v) : '')}>
<option value="">Select…</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label={`Amount (${currency?.code ?? ''})`}>
<Input
type="number"
step="0.01"
value={amount}
onChange={(ev) => setAmount(Number(ev.target.value))}
className="h-8 text-xs"
/>
</Cell>
<Cell label="Customer">
<Select value={customerId} onChange={(v) => setCustomerId(v ? Number(v) : '')}>
<option value="">None</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</Cell>
<Cell label="Paid with">
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
<option value="">None</option>
{paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Cell>
</Row>
<Cell label="Notes">
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
</Cell>
{/* Receipts are read-only here. Attaching one is a multipart upload to a separate endpoint that the
sidecar's JSON allow-list does not carry; an existing receipt is linked from the expenses list. */}
{record?.attachment_receipt_url && (
<p className="text-[10px] text-muted-foreground">
This expense has a receipt attached.{' '}
<a
href={record.attachment_receipt_url}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Open it
</a>
. Replacing it has to be done in InvoiceShelf.
</p>
)}
</EditorShell>
);
};
+322
View File
@@ -0,0 +1,322 @@
import type { Column } from './components';
import type { ConfirmState } from './ConfirmDialog';
import type { Invoice, RecurringInvoice, RecurringStatus } from './shared';
import { useState } from 'react';
import { MoreHorizontal, Plus, Repeat, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
EmptyState,
ErrorState,
Field,
Pagination,
SearchBox,
SectionShell,
SimpleTable,
StatusBadge,
} from './components';
import { ConfirmDialog } from './ConfirmDialog';
import { formatDate, formatMoney } from './format';
import { PAGE_SIZE, RECURRING_TABS, frequencyLabel } from './shared';
import { StatusTabs } from './components';
import { useListFilters } from './useInvoicesSection';
import { useCurrency, useResourceDetail, useResourceList, useResourceMutations } from './useInvoiceShelfData';
// Recurring invoices — a schedule, not a document.
//
// This is the one section whose detail pane is NOT a PDF, and it is not an omission: a recurring invoice has
// no unique_hash and no PDF route upstream, because there is nothing to render. What it has is a schedule and
// a list of the invoices that schedule has already generated, so that is what the pane shows.
//
// The schedule's `frequency` is a cron expression; frequencyLabel turns the ones upstream's own form can
// produce back into words and leaves anything else alone.
type Props = { onEdit: (id: number | 'new') => void };
export const RecurringListView = ({ onEdit }: Props) => {
const { filters, patch, select, setPage, clearFilters } = useListFilters();
const currency = useCurrency();
const [selection, setSelection] = useState<Set<number>>(new Set());
const [confirm, setConfirm] = useState<ConfirmState>(null);
const { rows, meta, isLoading, error } = useResourceList<RecurringInvoice>('recurring-invoices', {
page: filters.page,
limit: PAGE_SIZE,
search: filters.search || undefined,
status: filters.status || undefined,
customer_id: filters.customerId || undefined,
orderByField: 'created_at',
orderBy: 'desc',
});
const { remove, removeMany } = useResourceMutations('recurring-invoices', 'recurring invoices');
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<RecurringInvoice>[] = [
{
key: 'check',
label: '',
width: '36px',
render: (row) => (
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
</span>
),
},
{
key: 'starts_at',
label: 'Starts at',
width: 'minmax(90px, 0.9fr)',
render: (row) => formatDate(row.starts_at, row.formatted_starts_at),
},
{
key: 'customer',
label: 'Customer',
width: 'minmax(140px, 1.6fr)',
render: (row) => (
<div className="min-w-0">
<div className="truncate">{row.customer?.name ?? '—'}</div>
{row.customer?.contact_name && (
<div className="truncate text-[10px] text-muted-foreground">{row.customer.contact_name}</div>
)}
</div>
),
},
{
key: 'frequency',
label: 'Frequency',
width: 'minmax(100px, 1.1fr)',
render: (row) => <span className="truncate">{frequencyLabel(row.frequency)}</span>,
},
{
key: 'status',
label: 'Status',
width: 'minmax(90px, 0.9fr)',
render: (row) => <StatusBadge value={row.status} />,
},
{
key: 'total',
label: 'Total',
width: 'minmax(100px, 1fr)',
align: 'right',
render: (row) => formatMoney(row.total, currency),
},
];
const rowMenu = (row: RecurringInvoice) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
<DropdownMenuItem onSelect={() => select(row.id)}>View schedule</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onSelect={() =>
setConfirm({
title: 'Delete this schedule?',
description:
'Invoices it has already generated are kept — only the schedule stops. Nothing further will be issued.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
remove.mutate(row.id);
if (filters.selected === row.id) select(null);
},
})
}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
if (error) return <ErrorState error={error} />;
const toolbar = (
<>
<Checkbox
checked={allChecked}
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
/>
<StatusTabs
tabs={RECURRING_TABS}
value={(filters.status as RecurringStatus | null) ?? null}
onChange={(v) => patch({ status: v })}
/>
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Customer…" />
<div className="ml-auto flex items-center gap-2">
{filters.isFiltered && (
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
Clear
</Button>
)}
{selection.size > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() =>
setConfirm({
title: `Delete ${selection.size} schedules?`,
description: 'Invoices they have already generated are kept.',
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => {
removeMany.mutate([...selection]);
setSelection(new Set());
},
})
}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selection.size}
</Button>
)}
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New schedule
</Button>
</div>
</>
);
return (
<>
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1">
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
<SimpleTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
selectedKey={filters.selected}
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
rowMenu={rowMenu}
empty={
isLoading ? (
<EmptyState icon={Repeat} title="Loading…" />
) : (
<EmptyState
icon={Repeat}
title={filters.isFiltered ? 'No schedules match' : 'No recurring invoices'}
hint={
filters.isFiltered
? 'Try clearing the filters.'
: 'A schedule issues the same invoice to a customer on a fixed cadence.'
}
/>
)
}
/>
</SectionShell>
</div>
{filters.selected != null && (
<div className="hidden w-[42%] min-w-[340px] shrink-0 lg:block">
<RecurringDetail id={filters.selected} onClose={() => select(null)} />
</div>
)}
</div>
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
</>
);
};
/**
* The schedule pane. Fetched by id rather than taken from the list row because `invoices` is only present on
* the singular read — the index resource omits the relation.
*/
const RecurringDetail = ({ id, onClose }: { id: number; onClose: () => void }) => {
const { record, isLoading } = useResourceDetail<RecurringInvoice>('recurring-invoices', id);
const currency = useCurrency();
const generated: Invoice[] = record?.invoices ?? [];
return (
<div className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background">
<div className="flex shrink-0 items-start gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{record?.customer?.name ?? 'Schedule'}</div>
<div className="truncate text-[10px] text-muted-foreground">{frequencyLabel(record?.frequency)}</div>
</div>
<StatusBadge value={record?.status} />
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
{record && (
<>
<div className="rounded-lg border border-border px-3 py-1">
<Field label="Starts at">{formatDate(record.starts_at, record.formatted_starts_at)}</Field>
<Field label="Next invoice">{formatDate(record.next_invoice_at, record.formatted_next_invoice_at)}</Field>
<Field label="Frequency">{frequencyLabel(record.frequency)}</Field>
<Field label="Limit">{limitLabel(record)}</Field>
<Field label="Sends automatically">{record.send_automatically ? 'Yes' : 'No'}</Field>
<Field label="Total">{formatMoney(record.total, currency)}</Field>
</div>
<div className="mt-3">
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Generated invoices
</div>
{generated.length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
Nothing issued yet.
</div>
) : (
<div className="divide-y divide-border/50 rounded-lg border border-border">
{generated.map((inv) => (
<div key={inv.id} className="flex items-center gap-2 px-3 py-2 text-xs">
<span className="font-medium">{inv.invoice_number}</span>
<StatusBadge value={inv.status} />
<span className="ml-auto tabular-nums">{formatMoney(inv.total, currency)}</span>
</div>
))}
</div>
)}
</div>
{record.notes && (
<div className="mt-3 rounded-lg border border-border p-3 text-xs text-muted-foreground">
{record.notes}
</div>
)}
</>
)}
</div>
</div>
);
};
const limitLabel = (row: RecurringInvoice): string => {
if (row.limit_by === 'COUNT') return `${row.limit_count ?? 0} invoices`;
if (row.limit_by === 'DATE') return formatDate(row.limit_date, row.formatted_limit_date);
return 'None';
};
+211
View File
@@ -0,0 +1,211 @@
import type { ReportKind } from './shared';
import { useMemo, useState } from 'react';
import { BarChart3, Download, ExternalLink, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EmptyState } from './components';
import { REPORT_KINDS } from './shared';
import { downloadPdf, useReportUrl } from './useInvoiceShelfData';
// Reports. Every one of these is a server-rendered PDF — InvoiceShelf builds them in Blade and there is no
// JSON equivalent, so this screen is a range picker and a viewer, which is exactly what upstream's is.
//
// from_date and to_date are MANDATORY, not optional filters: each controller does
// `Carbon::createFromFormat('Y-m-d', $request->from_date)` unguarded, so omitting either is a 500 rather than
// an unbounded report. The range therefore always has a value here and the presets always resolve to a pair.
type PresetId =
| 'today'
| 'this-week'
| 'this-month'
| 'this-quarter'
| 'this-year'
| 'previous-week'
| 'previous-month'
| 'previous-quarter'
| 'previous-year'
| 'custom';
const PRESETS: { id: PresetId; label: string }[] = [
{ id: 'today', label: 'Today' },
{ id: 'this-week', label: 'This week' },
{ id: 'this-month', label: 'This month' },
{ id: 'this-quarter', label: 'This quarter' },
{ id: 'this-year', label: 'This year' },
{ id: 'previous-week', label: 'Previous week' },
{ id: 'previous-month', label: 'Previous month' },
{ id: 'previous-quarter', label: 'Previous quarter' },
{ id: 'previous-year', label: 'Previous year' },
{ id: 'custom', label: 'Custom' },
];
const iso = (d: Date): string => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
/** Monday-start, matching Carbon's default `startOfWeek()` on this instance. */
const startOfWeek = (d: Date): Date => {
const out = new Date(d);
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
};
function resolvePreset(id: PresetId): { from: string; to: string } | null {
if (id === 'custom') return null;
const now = new Date();
const y = now.getFullYear();
const q = Math.floor(now.getMonth() / 3);
switch (id) {
case 'today':
return { from: iso(now), to: iso(now) };
case 'this-week': {
const start = startOfWeek(now);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { from: iso(start), to: iso(end) };
}
case 'previous-week': {
const start = startOfWeek(now);
start.setDate(start.getDate() - 7);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { from: iso(start), to: iso(end) };
}
case 'this-month':
return { from: iso(new Date(y, now.getMonth(), 1)), to: iso(new Date(y, now.getMonth() + 1, 0)) };
case 'previous-month':
return { from: iso(new Date(y, now.getMonth() - 1, 1)), to: iso(new Date(y, now.getMonth(), 0)) };
case 'this-quarter':
return { from: iso(new Date(y, q * 3, 1)), to: iso(new Date(y, q * 3 + 3, 0)) };
case 'previous-quarter':
return { from: iso(new Date(y, q * 3 - 3, 1)), to: iso(new Date(y, q * 3, 0)) };
case 'this-year':
return { from: iso(new Date(y, 0, 1)), to: iso(new Date(y, 11, 31)) };
case 'previous-year':
return { from: iso(new Date(y - 1, 0, 1)), to: iso(new Date(y - 1, 11, 31)) };
default:
return null;
}
}
export const ReportsView = () => {
const [kind, setKind] = useState<ReportKind>('sales-customers');
const [preset, setPreset] = useState<PresetId>('this-month');
// Seeded from the default preset so the first render already has a valid range to request.
const [custom, setCustom] = useState(() => resolvePreset('this-month') ?? { from: '', to: '' });
const range = useMemo(() => resolvePreset(preset) ?? custom, [preset, custom]);
const ready = Boolean(range.from && range.to);
const { url, isLoading, error } = useReportUrl(kind, { from_date: range.from, to_date: range.to }, ready);
const label = REPORT_KINDS.find((r) => r.id === kind)?.label ?? 'Report';
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-border px-3 py-2">
{REPORT_KINDS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setKind(r.id)}
className={`shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors ${
kind === r.id ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60'
}`}
>
{r.label}
</button>
))}
</div>
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Range
<select
value={preset}
onChange={(e) => {
const next = e.target.value as PresetId;
// Carry the resolved dates into the custom fields so switching to Custom starts from what is
// already on screen instead of blanking the report.
const resolved = resolvePreset(next);
if (resolved) setCustom(resolved);
setPreset(next);
}}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
{PRESETS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={range.from}
disabled={preset !== 'custom'}
onChange={(e) => setCustom((c) => ({ ...c, from: e.target.value }))}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={range.to}
disabled={preset !== 'custom'}
onChange={(e) => setCustom((c) => ({ ...c, to: e.target.value }))}
className="h-8 w-36 text-xs"
/>
</label>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
disabled={!url}
onClick={() => url && downloadPdf(url, `${kind}-${range.from}_${range.to}`)}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Download
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={!url}
title="Open in new tab"
onClick={() => url && window.open(url, '_blank', 'noopener')}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 bg-muted/30">
{!ready && (
<EmptyState icon={BarChart3} title="Pick a date range" hint="Reports need both a start and an end." />
)}
{ready && isLoading && (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Building {label.toLowerCase()}…
</div>
)}
{ready && error && !isLoading && (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<div className="text-xs font-medium text-destructive">Could not build the report</div>
<div className="max-w-md text-[10px] text-muted-foreground">{error}</div>
</div>
)}
{ready && url && !isLoading && <iframe src={url} title={label} className="h-full w-full border-0" />}
</div>
</div>
);
};
+147
View File
@@ -0,0 +1,147 @@
import type { Currency } from './shared';
import { useEffect, useState } from 'react';
import { Loader2, Send } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useDocumentAction, useSummary } from './useInvoiceShelfData';
// The one dialog in /invoices that causes something irreversible to leave the building: `send` hands the
// document to InvoiceShelf's mailer, which posts it to the customer. There is no unsend.
//
// So it is deliberately not a menu item that fires on click. The recipient is shown, editable, and
// pre-filled from the customer record rather than assumed; the button says who it is emailing; and nothing
// else in this app calls the send action. Everything that *looks* like sending but isn't — "mark as sent",
// which only moves the status — goes through a plain confirm instead, and says so.
export type SendTarget = {
/** Sidecar resource slug: invoices | estimates | payments. */
resource: string;
id: number;
/** Shown in the title, e.g. "000145". */
number: string;
customerName: string;
customerEmail: string | null;
/** Whether this is a first send or a resend, purely for wording. */
resend?: boolean;
};
const DEFAULT_BODY = [
'<p>Hi {CUSTOMER_NAME},</p>',
'<p>Please find the attached document.</p>',
'<p>Thanks,<br/>{COMPANY_NAME}</p>',
].join('\n');
const KIND_LABEL: Record<string, string> = {
invoices: 'invoice',
estimates: 'estimate',
payments: 'payment receipt',
};
export const SendDocumentDialog = ({
target,
currency: _currency,
onClose,
}: {
target: SendTarget | null;
currency?: Currency | null;
onClose: () => void;
}) => {
const { company, summary } = useSummary();
const action = useDocumentAction(target?.resource ?? 'invoices');
const kind = KIND_LABEL[target?.resource ?? ''] ?? 'document';
const [to, setTo] = useState('');
const [from, setFrom] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
// Re-seed whenever a different document is opened. Keyed on id so reopening the same one keeps nothing
// stale from a previous edit of another document's mail.
useEffect(() => {
if (!target) return;
setTo(target.customerEmail ?? '');
setFrom(summary?.me?.email ?? '');
setSubject(`${company?.name ?? 'Invoice'} — ${kind} ${target.number}`);
setBody(
DEFAULT_BODY.replace('{CUSTOMER_NAME}', target.customerName || 'there').replace(
'{COMPANY_NAME}',
company?.name ?? '',
),
);
}, [target?.id, target?.resource, company?.name, summary?.me?.email, kind, target]);
if (!target) return null;
const missingRecipient = !to.trim();
const submit = () => {
action.mutate({ id: target.id, action: 'send', payload: { subject, body, from, to } }, { onSuccess: onClose });
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{target.resend ? 'Resend' : 'Send'} {kind} {target.number}
</DialogTitle>
<DialogDescription>
This emails {target.customerName} through InvoiceShelf. It cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">To</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} className="h-8 text-xs" type="email" />
</div>
<div className="space-y-1">
<Label className="text-xs">From</Label>
<Input value={from} onChange={(e) => setFrom(e.target.value)} className="h-8 text-xs" type="email" />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-8 text-xs" />
</div>
<div className="space-y-1">
<Label className="text-xs">Message</Label>
<Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={7} className="text-xs" />
<p className="text-[10px] text-muted-foreground">
HTML. InvoiceShelf attaches the PDF itself — do not link it here.
</p>
</div>
{missingRecipient && (
<p className="text-[10px] text-warning">This customer has no email address on file. Enter one to send.</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onClose} disabled={action.isPending}>
Cancel
</Button>
<Button size="sm" onClick={submit} disabled={action.isPending || missingRecipient}>
{action.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Send to {to || '—'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+282
View File
@@ -0,0 +1,282 @@
import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import type { PageMeta } from './shared';
import { ChevronLeft, ChevronRight, Inbox, Loader2, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toneFill } from '@/components/Data/tone';
import { STATUS_TONE, humanStatus } from './shared';
// Presentational furniture shared by every /invoices section. Nothing here fetches or mutates — these are
// the pieces the section views arrange.
/** Status pill. Unknown tokens fall through to the neutral tone rather than rendering unstyled. */
export const StatusBadge = ({ value, className = '' }: { value: string | null | undefined; className?: string }) => {
if (!value) return null;
const fill = toneFill[STATUS_TONE[value] ?? 'neutral'];
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${fill} ${className}`}
>
{humanStatus(value)}
</span>
);
};
export const EmptyState = ({
icon: Icon = Inbox,
title,
hint,
action,
}: {
icon?: LucideIcon;
title: string;
hint?: string;
action?: ReactNode;
}) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Icon className="h-6 w-6" />
</div>
<div>
<div className="text-sm font-medium">{title}</div>
{hint && <div className="mt-1 max-w-sm text-xs text-muted-foreground">{hint}</div>}
</div>
{action}
</div>
);
export const LoadingState = ({ label = 'Loading…' }: { label?: string }) => (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{label}
</div>
);
export const ErrorState = ({ error, hint }: { error: unknown; hint?: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-10 text-center">
<div className="text-sm font-medium text-destructive">Could not reach InvoiceShelf</div>
<div className="max-w-md text-xs text-muted-foreground">
{hint ?? 'Check the connection under Invoices → Connection, and that officer-invoiceshelf is running.'}
</div>
{error != null && (
<pre className="max-w-md overflow-hidden text-ellipsis text-[10px] text-muted-foreground/70">
{String((error as Error)?.message ?? error).slice(0, 300)}
</pre>
)}
</div>
);
/**
* Search box that writes straight through on every keystroke.
*
* Not debounced, deliberately: the query string is the source of truth, and react-query keys each distinct
* search as its own cache entry, so backspacing through a term replays cached responses rather than
* refiring requests. A debounce would only add lag to the first character.
*/
export const SearchBox = ({
value,
onChange,
placeholder = 'Search…',
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) => (
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-8 pl-8 text-xs"
/>
</div>
);
/** Horizontal status tabs above a list, each carrying the count when one is known. */
export const StatusTabs = <T extends string>({
tabs,
value,
onChange,
}: {
tabs: readonly { id: T | null; label: string; count?: number }[];
value: T | null;
onChange: (v: T | null) => void;
}) => (
<div className="flex items-center gap-1 overflow-x-auto">
{tabs.map((tab) => {
const active = value === tab.id;
return (
<button
key={tab.id ?? 'all'}
type="button"
onClick={() => onChange(tab.id)}
className={`shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60'
}`}
>
{tab.label}
{tab.count != null && <span className="ml-1.5 tabular-nums opacity-60">{tab.count}</span>}
</button>
);
})}
</div>
);
/**
* Pager for a Laravel-paginated list. Renders nothing on a single page — a lone disabled "1 of 1" is noise.
*/
export const Pagination = ({ meta, onPage }: { meta: PageMeta | null; onPage: (page: number) => void }) => {
if (!meta || meta.last_page <= 1) return null;
const { current_page, last_page, from, to, total } = meta;
return (
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
{from ?? 0}–{to ?? 0} of {total}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={current_page <= 1}
onClick={() => onPage(current_page - 1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="tabular-nums">
{current_page} / {last_page}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={current_page >= last_page}
onClick={() => onPage(current_page + 1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
};
/** Column contract for SimpleTable. `width` is a grid track ('1fr', '120px', 'minmax(0,2fr)'). */
export type Column<T> = {
key: string;
label: string;
width: string;
align?: 'left' | 'right' | 'center';
render: (row: T) => ReactNode;
};
/**
* The list table.
*
* A CSS grid rather than a <table>: every list here shares a sticky header and a single scroller, and grid
* tracks keep the two aligned without the colgroup gymnastics a real table needs. Not virtualised — these
* lists are server-paginated, so the row count on screen is bounded by `limit`.
*/
export const SimpleTable = <T,>({
rows,
columns,
rowKey,
selectedKey,
onRowClick,
rowMenu,
empty,
}: {
rows: T[];
columns: Column<T>[];
rowKey: (row: T) => number | string;
selectedKey?: number | string | null;
onRowClick?: (row: T) => void;
rowMenu?: (row: T) => ReactNode;
empty?: ReactNode;
}) => {
const template = columns.map((c) => c.width).join(' ') + (rowMenu ? ' 40px' : '');
if (!rows.length && empty) return <>{empty}</>;
return (
<div className="h-full overflow-auto">
<div
className="sticky top-0 z-10 grid border-b border-border bg-background/95 backdrop-blur"
style={{ gridTemplateColumns: template }}
>
{columns.map((col) => (
<div
key={col.key}
className={`overflow-hidden px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground ${
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : ''
}`}
>
<span className="truncate">{col.label}</span>
</div>
))}
{rowMenu && <div />}
</div>
<div>
{rows.map((row) => {
const key = rowKey(row);
const isSelected = selectedKey != null && key === selectedKey;
return (
<div
key={key}
onClick={() => onRowClick?.(row)}
className={`grid items-center border-b border-border/40 text-xs transition-colors ${
onRowClick ? 'cursor-pointer' : ''
} ${isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'}`}
style={{ gridTemplateColumns: template }}
>
{columns.map((col) => (
<div
key={col.key}
className={`min-w-0 overflow-hidden px-3 py-2 ${
col.align === 'right' ? 'text-right tabular-nums' : col.align === 'center' ? 'text-center' : ''
}`}
>
{col.render(row)}
</div>
))}
{rowMenu && (
<div className="flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
{rowMenu(row)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
/** Label/value row for the detail panes. */
export const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right">{children}</span>
</div>
);
export const SectionShell = ({
toolbar,
children,
footer,
}: {
toolbar?: ReactNode;
children: ReactNode;
footer?: ReactNode;
}) => (
<div className="flex h-full min-h-0 flex-col">
{toolbar && (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">{toolbar}</div>
)}
<div className="min-h-0 flex-1">{children}</div>
{footer}
</div>
);
+152
View File
@@ -0,0 +1,152 @@
import type { Currency, Money } from './shared';
// Presentation helpers. The money one is the load-bearing piece: InvoiceShelf stores minor units as
// integers and describes how to render them in the company's `current_company_currency` record, so the
// separators, the precision and the symbol side all come from data rather than from the browser locale.
// Intl.NumberFormat is deliberately NOT used — it would apply the *viewer's* locale to the *company's*
// currency and quietly render "1,234.56 €" for an install configured the other way round.
/**
* Coerce an API money field to a number of minor units.
*
* Upstream is genuinely inconsistent about the JSON type here — `invoice.total` arrives as the number
* 450000 while `customer.due_amount` arrives as the string "1830000" — so every read goes through this
* rather than trusting either. Non-finite input collapses to 0 so a missing total renders as "0,00 €"
* instead of "NaN".
*/
export const toMinor = (value: Money): number => {
if (value == null) return 0;
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
};
const groupDigits = (digits: string, separator: string): string => digits.replace(/\B(?=(\d{3})+(?!\d))/g, separator);
/**
* The minor-unit divisor.
*
* Deliberately the constant 100 and NOT 10**precision, because that is what upstream does — its
* `utils/format-money.ts` opens with `amount = amountInCents / 100` and then uses `precision` only to
* decide how many decimal digits to *print*. On a 3-decimal currency the two rules disagree, and matching
* InvoiceShelf's own arithmetic matters more here than being independently right: a figure that differs
* from the one its web UI shows for the same invoice would read as a bug in this screen.
*/
const MINOR_PER_MAJOR = 100;
/**
* Render minor units the way InvoiceShelf renders them.
*
* Ported from `resources/scripts/utils/format-money.ts` and matched on the details that are visible:
* there is always a single space between symbol and number, the minus sign sits inside the number rather
* than before the symbol (so "€ -1.234,56", not "-€ 1.234,56"), and `precision: 0` prints no decimal part
* at all rather than a bare separator.
*/
export const formatMoney = (value: Money, currency?: Currency | null): string => {
let precision = Math.abs(Number(currency?.precision ?? 2));
if (!Number.isFinite(precision)) precision = 2;
const amount = toMinor(value) / MINOR_PER_MAJOR;
const negativeSign = amount < 0 ? '-' : '';
const abs = Math.abs(amount);
const thousands = currency?.thousand_separator ?? ',';
const decimal = currency?.decimal_separator ?? '.';
const symbol = currency?.symbol ?? '';
const fixed = abs.toFixed(precision);
const [whole = '0', frac = ''] = fixed.split('.');
const combined = negativeSign + groupDigits(whole, thousands) + (precision ? decimal + frac : '');
if (!symbol) return combined;
// `swap_currency_symbol` arrives as a boolean from the API and as 0/1 from a settings write.
return currency?.swap_currency_symbol ? `${combined} ${symbol}` : `${symbol} ${combined}`;
};
/** Bare number, no symbol — for chart axes and form inputs where the symbol lives elsewhere. */
export const formatAmount = (value: Money, currency?: Currency | null): string =>
formatMoney(value, currency ? { ...currency, symbol: '' } : null);
/** Minor units → the major-unit number an editable input holds (450000 → 4500). */
export const toMajor = (value: Money): number => toMinor(value) / MINOR_PER_MAJOR;
/** The inverse, for writes. Rounds because floats reaching here have been through an <input>. */
export const fromMajor = (value: number | string): number => {
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0;
};
/** Strips symbol and separators back to minor units — the paste-into-a-money-field path. */
export const parseMoneyMinor = (text: string, currency?: Currency | null): number => {
const thousands = currency?.thousand_separator ?? ',';
const decimal = currency?.decimal_separator ?? '.';
const cleaned = text
.replace(currency?.symbol ?? '', '')
.split(thousands)
.join('')
.replace(decimal, '.')
.replace(/[^\d.-]/g, '');
const n = Number(cleaned);
return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0;
};
// ── dates ────────────────────────────────────────────────────────────────────────────────────────
/**
* Parse the several date shapes the API mixes: "2026-06-21", "2026-06-21 00:00:00" and full ISO.
* The space form is not valid ISO-8601, so it is normalised before Date sees it.
*/
const parseDate = (value: string | null | undefined): Date | null => {
if (!value) return null;
const normalised = value.includes('T') ? value : value.replace(' ', 'T');
const d = new Date(normalised);
return Number.isNaN(d.getTime()) ? null : d;
};
/**
* Prefer upstream's own `formatted_*` field when the caller has one: it already reflects the company's
* configured date format (dd/mm/yyyy here), which we cannot reconstruct from the raw value alone.
*/
export const formatDate = (raw: string | null | undefined, preformatted?: string | null): string => {
if (preformatted) return preformatted;
const d = parseDate(raw);
if (!d) return '—';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
};
/** ISO yyyy-mm-dd, the form every write endpoint expects and `<input type="date">` speaks. */
export const toInputDate = (raw: string | null | undefined): string => {
const d = parseDate(raw);
if (!d) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
/** Whole days until `raw`; negative once past. Used for the overdue hint next to a due date. */
export const daysUntil = (raw: string | null | undefined): number | null => {
const d = parseDate(raw);
if (!d) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
d.setHours(0, 0, 0, 0);
return Math.round((d.getTime() - today.getTime()) / 86_400_000);
};
export const relativeDue = (raw: string | null | undefined): string => {
const days = daysUntil(raw);
if (days == null) return '';
if (days === 0) return 'due today';
if (days > 0) return days === 1 ? 'due tomorrow' : `due in ${days} days`;
const overdue = Math.abs(days);
return overdue === 1 ? '1 day overdue' : `${overdue} days overdue`;
};
/** Strip the HTML upstream stores in note fields, for one-line previews. */
export const stripHtml = (html: string | null | undefined): string =>
(html ?? '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
+11
View File
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'invoices-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'invoices-nav', appType: 'invoices-nav' }, size: 20 },
{ node: { type: 'panel', id: 'invoices-view', appType: 'invoices-view' }, size: 80 },
],
};
+24
View File
@@ -0,0 +1,24 @@
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
import type { AppRegistryMeta } from 'officerdev';
import { LayoutGrid, PanelLeft } from 'lucide-react';
import { InvoicesNav } from './InvoicesNav';
import { InvoicesView } from './InvoicesView';
import { InvoicesViewHeader } from './InvoicesViewHeader';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'invoices-nav',
name: 'Invoices',
icon: PanelLeft,
component: InvoicesNav,
availableOnPanel: false,
},
{
key: 'invoices-view',
name: 'Invoices',
icon: LayoutGrid,
component: InvoicesView,
header: InvoicesViewHeader,
availableOnPanel: false,
},
];
+464
View File
@@ -0,0 +1,464 @@
// Domain vocabulary for /invoices — the Officer front end over the officer-invoiceshelf sidecar.
//
// Types are written against the LIVE 2.4.2 instance's actual responses, not the 3.0.0-alpha.1 checkout in
// _references. Two things about those responses drive most of the odd choices here:
//
// 1. Money is an integer count of minor units (cents), and the API is inconsistent about whether it hands
// it over as a number or a decimal string — `invoice.total` is 450000 but `customer.due_amount` is
// "1830000". Hence the `Money` alias and `toMinor()` in format.ts. Never do arithmetic on these
// without going through it.
// 2. Every record embeds its whole company, including the role/ability list — a single invoice row carries
// ~40KB of super-admin abilities nobody asked for. That is upstream's shape and we cannot change it,
// so the types below deliberately declare `company?: unknown`: naming those fields would invite code
// that reads them, and the interesting data is never in there.
import type { Tone } from '@/components/Data/tone';
export const INVOICES_SECTIONS = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'invoices', label: 'Invoices' },
{ id: 'estimates', label: 'Estimates' },
{ id: 'recurring', label: 'Recurring' },
{ id: 'payments', label: 'Payments' },
{ id: 'expenses', label: 'Expenses' },
{ id: 'customers', label: 'Customers' },
{ id: 'items', label: 'Items' },
{ id: 'reports', label: 'Reports' },
{ id: 'settings', label: 'Connection' },
] as const;
export type InvoicesSectionId = (typeof INVOICES_SECTIONS)[number]['id'];
export const DEFAULT_INVOICES_SECTION: InvoicesSectionId = 'dashboard';
export const isInvoicesSection = (value: string | undefined): value is InvoicesSectionId =>
INVOICES_SECTIONS.some((s) => s.id === value);
export const invoicesSectionPath = (id: InvoicesSectionId) => `/invoices/${id}`;
/**
* A record open inside a list section — `/invoices/invoices?selected=12`. `useInvoicesSection` reads
* `selected` out of the query string, so this is the addressable form of "this row is open" and the
* thing a row anchor points at.
*/
export const invoicesRecordPath = (section: InvoicesSectionId, id: number) =>
`${invoicesSectionPath(section)}?selected=${id}`;
/** A list section filtered to one customer — `useListFilters` reads `customer`. */
export const invoicesCustomerPath = (section: InvoicesSectionId, customerId: number) =>
`${invoicesSectionPath(section)}?customer=${customerId}`;
/**
* The payment editor, opened blank against an invoice — `/invoices/payments?edit=new&pay=12`.
* `InvoicesView` reads both params (`edit` → which editor, `pay` → which invoice it prefills from), so
* "record a payment for this invoice" is a whole address and not a callback.
*/
export const invoicesNewPaymentPath = (invoiceId: number) =>
`${invoicesSectionPath('payments')}?edit=new&pay=${invoiceId}`;
/** The sidecar resource slug behind each list section. `dashboard` and `reports` have none. */
export const SECTION_RESOURCE = {
invoices: 'invoices',
estimates: 'estimates',
recurring: 'recurring-invoices',
payments: 'payments',
expenses: 'expenses',
customers: 'customers',
items: 'items',
} as const satisfies Partial<Record<InvoicesSectionId, string>>;
export type ListSectionId = keyof typeof SECTION_RESOURCE;
export const isListSection = (id: InvoicesSectionId): id is ListSectionId => id in SECTION_RESOURCE;
// ── money ────────────────────────────────────────────────────────────────────────────────────────
/** An integer count of minor units, which upstream may hand over as either a number or a decimal string. */
export type Money = number | string | null | undefined;
export type Currency = {
id: number;
name: string;
code: string;
symbol: string;
precision: number | string;
thousand_separator: string;
decimal_separator: string;
/** true → "1.234,56 €"; false → "€1.234,56". */
swap_currency_symbol: boolean | number;
};
// ── status vocabulary ────────────────────────────────────────────────────────────────────────────
// The real persisted statuses. DUE and OVERDUE are NOT among them — upstream computes those for filters
// and badges (`overdue` is a derived boolean on the invoice, `DUE` is a filter-only token), so they appear
// in the tone map and the filter vocabulary but never as a value you can write back.
export type InvoiceStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'COMPLETED';
export type PaidStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
export type EstimateStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'EXPIRED' | 'ACCEPTED' | 'REJECTED';
export type RecurringStatus = 'ACTIVE' | 'ON_HOLD' | 'COMPLETED';
/**
* The one place a status token becomes a colour. Every badge, tab and detail header reads it, so a status
* cannot mean success in the list and warning in the preview.
*
* Mapped by MEANING onto the platform's closed six-tone set (`components/Data/tone.ts`), not hue-for-hue
* onto upstream's palette. Two consequences worth knowing:
*
* - SENT and VIEWED both resolve to `info`. Upstream separates them (blue vs indigo); the tone set has no
* sixth shade of "in flight, not yet actioned" and inventing one would put the plugin back on palette
* classes. The words still differ in the badge — only the colour is shared.
* - PARTIALLY_PAID is `busy` rather than `warning`, which keeps it distinct from UNPAID in the same
* column and is what it actually means: payment underway. Likewise EXPIRED is `warning` (an offer that
* lapsed and can be re-issued) where REJECTED is `danger` (declined outright).
*
* Unknown tokens are the caller's problem — `StatusBadge` falls back to `neutral`.
*/
export const STATUS_TONE: Record<string, Tone> = {
DRAFT: 'neutral',
SENT: 'info',
VIEWED: 'info',
COMPLETED: 'success',
DUE: 'warning',
OVERDUE: 'danger',
UNPAID: 'warning',
PARTIALLY_PAID: 'busy',
PAID: 'success',
EXPIRED: 'warning',
ACCEPTED: 'success',
REJECTED: 'danger',
ACTIVE: 'info',
ON_HOLD: 'warning',
};
/** Status filter vocabulary per list, in upstream's own order. */
export const INVOICE_STATUS_FILTERS = ['DRAFT', 'DUE', 'SENT', 'VIEWED', 'COMPLETED'] as const;
export const INVOICE_PAID_FILTERS = ['UNPAID', 'PAID', 'PARTIALLY_PAID'] as const;
export const ESTIMATE_STATUS_FILTERS = ['DRAFT', 'SENT', 'VIEWED', 'EXPIRED', 'ACCEPTED', 'REJECTED'] as const;
export const RECURRING_STATUS_FILTERS = ['ACTIVE', 'ON_HOLD', 'COMPLETED'] as const;
/** Tabs above each list — `null` is "All". Mirrors upstream's tab sets exactly. */
export const INVOICE_TABS = [
{ id: null, label: 'All' },
{ id: 'DRAFT', label: 'Draft' },
{ id: 'SENT', label: 'Sent' },
{ id: 'DUE', label: 'Due' },
] as const;
export const ESTIMATE_TABS = [
{ id: null, label: 'All' },
{ id: 'DRAFT', label: 'Draft' },
{ id: 'SENT', label: 'Sent' },
] as const;
export const RECURRING_TABS = [
{ id: null, label: 'All' },
{ id: 'ACTIVE', label: 'Active' },
{ id: 'ON_HOLD', label: 'On hold' },
] as const;
/** Upstream paginates every list at 10. Matched so page numbers line up between the two UIs. */
export const PAGE_SIZE = 10;
export const humanStatus = (value: string | null | undefined): string =>
(value ?? '')
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^./, (c) => c.toUpperCase());
// ── records ──────────────────────────────────────────────────────────────────────────────────────
export type Address = {
id: number;
name: string | null;
address_street_1: string | null;
address_street_2: string | null;
city: string | null;
state: string | null;
zip: string | null;
phone: string | null;
country_id: number | null;
country?: { id: number; code: string; name: string } | null;
};
export type Customer = {
id: number;
name: string;
email: string | null;
phone: string | null;
contact_name: string | null;
company_name: string | null;
website: string | null;
prefix: string | null;
tax_id: string | null;
currency_id: number | null;
enable_portal: boolean;
due_amount: Money;
formatted_created_at: string | null;
created_at: string | null;
billing?: Address | null;
shipping?: Address | null;
// Deliberately unnamed — see the header note about embedded company payloads.
company?: unknown;
};
export type Unit = { id: number; name: string };
export type TaxType = { id: number; name: string; percent: number | string; compound_tax: boolean | number };
export type Category = { id: number; name: string };
export type PaymentMethod = { id: number; name: string };
export type Item = {
id: number;
name: string;
description: string | null;
price: Money;
unit_id: number | null;
currency_id: number | null;
tax_per_item: boolean;
formatted_created_at: string | null;
unit?: Unit | null;
};
/** A line on an invoice or estimate. Same shape either way. */
export type DocumentItem = {
id: number;
item_id: number | null;
name: string;
description: string | null;
quantity: number | string;
price: Money;
discount_type: 'fixed' | 'percentage';
discount: number | string;
discount_val: Money;
tax: Money;
total: Money;
unit_name: string | null;
taxes?: DocumentTax[];
};
export type DocumentTax = {
id?: number;
tax_type_id: number | null;
name?: string;
percent: number | string;
amount: Money;
compound_tax?: boolean | number;
};
/** Shared spine of invoices, estimates and recurring invoices — they differ only at the edges. */
type DocumentBase = {
id: number;
status: string;
notes: string | null;
reference_number: string | null;
customer_id: number;
currency_id: number;
unique_hash: string;
template_name: string | null;
sub_total: Money;
total: Money;
tax: Money;
discount: number | string;
discount_type: 'fixed' | 'percentage';
discount_val: Money;
tax_per_item: 'YES' | 'NO';
discount_per_item: 'YES' | 'NO';
sent: boolean;
viewed: boolean;
items: DocumentItem[];
taxes?: DocumentTax[];
customer?: Customer | null;
formatted_created_at: string | null;
};
export type Invoice = DocumentBase & {
invoice_number: string;
invoice_date: string;
due_date: string | null;
paid_status: PaidStatus;
due_amount: Money;
overdue: boolean;
allow_edit: boolean;
invoice_pdf_url: string | null;
formatted_invoice_date: string | null;
formatted_due_date: string | null;
recurring_invoice_id: number | null;
};
export type Estimate = DocumentBase & {
estimate_number: string;
estimate_date: string;
expiry_date: string | null;
estimate_pdf_url: string | null;
formatted_estimate_date: string | null;
formatted_expiry_date: string | null;
/** Set once converted; the convert action is refused a second time. */
invoice_id?: number | null;
};
export type RecurringInvoice = DocumentBase & {
starts_at: string | null;
next_invoice_at: string | null;
/** A five-field cron expression, not a keyword — see FREQUENCY_PRESETS. */
frequency: string | null;
limit_by: 'NONE' | 'COUNT' | 'DATE' | string | null;
limit_count: number | null;
limit_date: string | null;
send_automatically: boolean;
due_amount: Money;
formatted_starts_at: string | null;
formatted_next_invoice_at: string | null;
formatted_limit_date: string | null;
/** Present only once the schedule has generated something. */
invoices?: Invoice[];
};
/**
* A recurring invoice's `frequency` is a raw cron expression, which is unreadable in a table column.
*
* These are upstream's own presets, cron string for cron string (`recurring-invoices/store.ts` →
* `initFrequencies`), in its order — common business intervals first, then the short ones. A schedule
* created outside these presets keeps a cron expression we do not recognise, and `frequencyLabel` shows
* it verbatim rather than guessing.
*/
export const FREQUENCY_PRESETS = [
{ cron: '0 0 * * 0', label: 'Every week' },
{ cron: '0 0 */14 * *', label: 'Every 2 weeks' },
{ cron: '0 0 1 * *', label: 'Every month' },
{ cron: '0 0 1 */2 *', label: 'Every 2 months' },
{ cron: '0 0 1 */3 *', label: 'Every quarter' },
{ cron: '0 0 1 */6 *', label: 'Every 6 months' },
{ cron: '0 0 1 1 *', label: 'Every year' },
{ cron: '0 0 * * *', label: 'Every day' },
{ cron: '0 5 */15 * *', label: 'Every 15 days' },
{ cron: '0 * * * *', label: 'Every hour' },
{ cron: '* * * * *', label: 'Every minute' },
] as const;
export const frequencyLabel = (cron: string | null | undefined): string =>
FREQUENCY_PRESETS.find((f) => f.cron === cron)?.label ?? cron ?? '—';
export type Payment = {
id: number;
payment_number: string;
payment_date: string;
amount: Money;
notes: string | null;
unique_hash: string;
invoice_id: number | null;
customer_id: number;
payment_method_id: number | null;
currency_id: number;
transaction_id: string | null;
payment_pdf_url: string | null;
formatted_payment_date: string | null;
formatted_created_at: string | null;
customer?: Customer | null;
invoice?: Invoice | null;
payment_method?: PaymentMethod | null;
};
export type Expense = {
id: number;
expense_date: string;
expense_number: string | null;
amount: Money;
notes: string | null;
expense_category_id: number | null;
payment_method_id: number | null;
customer_id: number | null;
currency_id: number;
/** Absolute, rendered from the instance's APP_URL — usable in a browser, not from this process. */
attachment_receipt_url: string | null;
formatted_expense_date: string | null;
formatted_created_at: string | null;
/** `expense_category`, not `category` — the column is `expense_category_id` but the relation keeps the prefix. */
expense_category?: Category | null;
customer?: Customer | null;
payment_method?: PaymentMethod | null;
};
// ── envelopes ────────────────────────────────────────────────────────────────────────────────────
export type PageMeta = {
current_page: number;
last_page: number;
per_page: number;
from: number | null;
to: number | null;
total: number;
};
export type Paginated<T> = { data: T[]; meta?: PageMeta };
export type Summary = {
me: { id: number; name: string; email: string } | null;
company: { id: number; name: string; unique_hash: string; address?: Address | null; logo?: string | null } | null;
currency: Currency | null;
settings: Record<string, string> | null;
dashboard: Dashboard | null;
};
export type Dashboard = {
total_amount_due: Money;
total_customer_count: number;
total_invoice_count: number;
total_estimate_count: number;
total_sales: Money;
total_receipts: Money;
total_expenses: Money;
total_net_income: Money;
recent_due_invoices: Invoice[];
recent_estimates: Estimate[];
chart_data: {
months: string[];
invoice_totals: Money[];
expense_totals: Money[];
receipt_totals: Money[];
net_income_totals: Money[];
} | null;
};
/**
* `GET /customers/{id}/stats` — the customer plus a twelve-month chart.
*
* The chart keys are camelCase here (`invoiceTotals`) where the company dashboard's are snake_case
* (`invoice_totals`). That is upstream's inconsistency, not a transcription slip; the two shapes cannot share
* a type.
*/
export type CustomerStats = {
data?: Customer;
meta?: {
chartData?: {
months: string[];
invoiceTotals: Money[];
expenseTotals: Money[];
receiptTotals: Money[];
netProfits: Money[];
netProfit: Money;
salesTotal: Money;
totalReceipts: Money;
totalExpenses: Money;
};
};
};
export type Lookups = {
customers: Customer[];
items: Item[];
units: Unit[];
taxTypes: TaxType[];
categories: Category[];
paymentMethods: PaymentMethod[];
currencies: Currency[];
};
export const REPORT_KINDS = [
{ id: 'sales-customers', label: 'Sales by customer' },
{ id: 'sales-items', label: 'Sales by item' },
{ id: 'tax-summary', label: 'Tax summary' },
{ id: 'profit-loss', label: 'Profit & loss' },
{ id: 'expenses', label: 'Expenses' },
] as const;
export type ReportKind = (typeof REPORT_KINDS)[number]['id'];
+574
View File
@@ -0,0 +1,574 @@
import type { Currency, CustomerStats, Dashboard, Lookups, Paginated, ReportKind, Summary } from './shared';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
// Data layer for the /invoices panels. Everything goes through the /api/invoiceshelf auth proxy to the
// officer-invoiceshelf sidecar, which holds the InvoiceShelf URL, the Sanctum token and the company header.
//
// Unlike the Transmission panels this does NOT poll. InvoiceShelf is a bookkeeping app whose records only
// change when someone in this UI changes them — a background refetch would spend requests to re-render
// identical rows. Freshness comes from invalidating after writes instead, plus a refetch when the window
// regains focus in case the change was made in InvoiceShelf's own UI.
//
// Mutations invalidate the whole ['invoiceshelf'] prefix rather than patching caches. The totals are deeply
// entangled — recording a payment moves the invoice's due_amount AND its paid_status AND the customer's
// due_amount AND four dashboard figures — so a targeted patch would be subtly wrong far more often than it
// would be cheap.
const ROOT = 'invoiceshelf';
const BASE = '/invoices/_officer';
/** Records are stable between writes; this only bounds how long a remount reuses the cache. */
const STALE_MS = 30_000;
const EMPTY: never[] = [];
const qs = (params?: Record<string, string | number | undefined | null>): string => {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.set(key, String(value));
}
const out = search.toString();
return out ? `?${out}` : '';
};
/**
* Pull a human message out of a thrown client error.
*
* useClient throws `{status, message}` where message is the raw response body, so the useful text is
* usually one JSON level down. Laravel reports validation failures as `{message, errors: {field: [msg]}}`
* and the field-level message is the specific one — "The due date must be a date after invoice date" beats
* "The given data was invalid."
*/
function errorMessage(err: unknown, fallback: string): string {
const raw = typeof err === 'object' && err !== null && 'message' in err ? String((err as Error).message) : '';
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as { error?: string; message?: string; errors?: Record<string, string[]> };
const firstField = parsed.errors && Object.values(parsed.errors)[0]?.[0];
return firstField || parsed.error || parsed.message || fallback;
} catch {
return raw || fallback;
}
}
// ── company-wide context ─────────────────────────────────────────────────────────────────────────
/**
* The one call every panel needs: who we are, the company, its currency and the dashboard totals.
*
* The currency matters beyond the dashboard — it carries precision, separators and symbol placement, and
* every money value in the UI is unrenderable without it. Panels take it from here rather than each
* fetching it, so there is a single cache entry keeping them consistent.
*/
export function useSummary() {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'summary'] as const,
queryFn: () => get<Summary>(`${BASE}/summary`),
staleTime: STALE_MS,
});
return {
summary: query.data ?? null,
currency: query.data?.currency ?? null,
company: query.data?.company ?? null,
dashboard: query.data?.dashboard ?? null,
isLoading: query.isLoading,
error: query.error,
};
}
/** Convenience for the many components that need only the currency to render money. */
export function useCurrency(): Currency | null {
return useSummary().currency;
}
/**
* Every select-box option in one call: customers, items, units, tax types, categories, payment methods,
* currencies. Held long because these are small, near-static reference lists that half the forms need.
*/
export function useLookups() {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'lookups'] as const,
queryFn: () => get<Lookups>(`${BASE}/lookups`),
staleTime: 5 * 60_000,
});
const data = query.data;
return {
customers: data?.customers ?? EMPTY,
items: data?.items ?? EMPTY,
units: data?.units ?? EMPTY,
taxTypes: data?.taxTypes ?? EMPTY,
categories: data?.categories ?? EMPTY,
paymentMethods: data?.paymentMethods ?? EMPTY,
currencies: data?.currencies ?? EMPTY,
isLoading: query.isLoading,
};
}
export function useDashboard(): { dashboard: Dashboard | null; isLoading: boolean } {
const { dashboard, isLoading } = useSummary();
return { dashboard, isLoading };
}
// ── generic resource access ──────────────────────────────────────────────────────────────────────
export type ListParams = Record<string, string | number | undefined | null>;
/**
* A page of any allow-listed resource. `params` is passed to upstream verbatim, which is what makes one
* hook enough for seven list screens — InvoiceShelf's index endpoints share a filter vocabulary
* (`page`, `limit`, `search`, `customer_id`, `status`, `from_date`, `to_date`, `orderByField`, `orderBy`).
*
* `params` is spread into the query key, so changing a filter is a new cache entry rather than a refetch
* of the old one — paging back and forth is then free.
*/
export function useResourceList<T>(resource: string, params?: ListParams, enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, resource, 'list', params ?? {}] as const,
queryFn: () => get<Paginated<T>>(`${BASE}/${resource}${qs(params)}`),
enabled,
staleTime: STALE_MS,
// Keeps the previous page on screen while the next one loads, instead of flashing an empty table.
placeholderData: (prev) => prev,
});
return {
rows: query.data?.data ?? (EMPTY as T[]),
meta: query.data?.meta ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
};
}
/**
* One record by id. Upstream wraps singular reads in a `data` key for most resources but returns some bare,
* so the response is unwrapped defensively rather than assuming either.
*/
export function useResourceDetail<T>(resource: string, id: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, resource, 'detail', id] as const,
queryFn: async () => {
const res = await get<{ data?: T } | T>(`${BASE}/${resource}/${id}`);
return (res && typeof res === 'object' && 'data' in res ? (res as { data: T }).data : (res as T)) ?? null;
},
enabled: id != null,
staleTime: STALE_MS,
});
return { record: query.data ?? null, isLoading: query.isLoading, error: query.error };
}
/**
* Create / update / delete / bulk-delete for one resource.
*
* All four invalidate the entire ['invoiceshelf'] prefix on success — see the header note on why targeted
* cache patching is the wrong trade here.
*/
export function useResourceMutations(resource: string, label = resource) {
const { post, put, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
const create = useMutation({
mutationFn: (payload: unknown) => post<{ data?: unknown }>(`${BASE}/${resource}`, payload),
onSuccess: () => {
invalidate();
toast.success(`${singular(label)} created`);
},
onError: (err) => toast.error(errorMessage(err, `Could not create ${singular(label).toLowerCase()}`)),
});
const update = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: unknown }) =>
put<{ data?: unknown }>(`${BASE}/${resource}/${id}`, payload),
onSuccess: () => {
invalidate();
toast.success(`${singular(label)} saved`);
},
onError: (err) => toast.error(errorMessage(err, `Could not save ${singular(label).toLowerCase()}`)),
});
const remove = useMutation({
mutationFn: (id: number) => del<unknown>(`${BASE}/${resource}/${id}`),
onSuccess: () => {
invalidate();
toast.success(`${singular(label)} deleted`);
},
onError: (err) => toast.error(errorMessage(err, `Could not delete ${singular(label).toLowerCase()}`)),
});
const removeMany = useMutation({
mutationFn: (ids: number[]) => post<unknown>(`${BASE}/${resource}/delete`, { ids }),
onSuccess: (_data, ids) => {
invalidate();
toast.success(`${ids.length} ${ids.length === 1 ? singular(label).toLowerCase() : label} deleted`);
},
onError: (err) => toast.error(errorMessage(err, `Could not delete ${label}`)),
});
return { create, update, remove, removeMany };
}
const singular = (label: string): string => {
const base = label.endsWith('ies') ? `${label.slice(0, -3)}y` : label.endsWith('s') ? label.slice(0, -1) : label;
return base.charAt(0).toUpperCase() + base.slice(1);
};
/**
* The per-document verbs: status, clone, send, convert-to-invoice, duplicate.
*
* `send` genuinely emails the customer. It is reachable from here because the UI needs it, but nothing in
* this module calls it on its own — it must always be behind an explicit confirm step, never a side effect
* of saving or of any other action.
*/
export function useDocumentAction(resource: string) {
const { post } = useClient();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, action, payload }: { id: number; action: string; payload?: unknown }) =>
post<unknown>(`${BASE}/${resource}/${id}/${action}`, payload ?? {}),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: [ROOT] });
toast.success(ACTION_TOAST[vars.action] ?? 'Done');
},
onError: (err) => toast.error(errorMessage(err, 'Action failed')),
});
}
const ACTION_TOAST: Record<string, string> = {
status: 'Status updated',
clone: 'Copy created',
send: 'Sent',
'convert-to-invoice': 'Converted to invoice',
duplicate: 'Duplicated',
};
/** The number the next document of this kind would take, for the create form's placeholder. */
export function useNextNumber(key: 'invoice' | 'estimate' | 'payment', enabled = true) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'next-number', key] as const,
queryFn: () => get<{ nextNumber?: string; nextSequenceNumber?: number }>(`${BASE}/next-number?key=${key}`),
enabled,
// Never cached: two create forms opened in sequence must not both claim the same number.
staleTime: 0,
gcTime: 0,
});
return query.data?.nextNumber ?? null;
}
/**
* The PDF templates a document can be rendered with. `template_name` is a *required* field on every
* invoice/estimate write, so the create form needs this before it can save anything — it is not decoration.
*/
export function useTemplates(resource: 'invoices' | 'estimates') {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, resource, 'templates'] as const,
queryFn: () => get<{ [key: string]: unknown }>(`${BASE}/${resource}/templates`),
staleTime: 60 * 60_000,
});
// Upstream keys the payload by resource — `{invoiceTemplates: […]}` / `{estimateTemplates: […]}` — so it
// is read positionally rather than by a name that differs per endpoint.
const list = Object.values(query.data ?? {}).find(Array.isArray) as { name: string; path?: string }[] | undefined;
return list ?? (EMPTY as { name: string; path?: string }[]);
}
export function useCustomerStats(id: number | null) {
const { get } = useClient();
const query = useQuery({
queryKey: [ROOT, 'customers', 'stats', id] as const,
queryFn: () => get<CustomerStats>(`${BASE}/customers/${id}/stats`),
enabled: id != null,
staleTime: STALE_MS,
});
return { stats: query.data ?? null, isLoading: query.isLoading };
}
// ── PDFs ─────────────────────────────────────────────────────────────────────────────────────────
type PdfState = { url: string | null; isLoading: boolean; error: string | null };
/**
* Fetch a PDF and expose it as an object URL an <iframe> can render.
*
* It has to be done this way round: the sidecar sits behind Officer's bearer auth, and a naked
* `<iframe src="/api/...">` cannot carry an Authorization header — it would fetch unauthenticated and
* render a 401 body. So the bytes are pulled with the authenticated client and handed to the iframe as a
* blob: URL instead.
*
* The previous URL is revoked whenever the target changes and on unmount; without that, every document a
* user clicks through leaks its PDF into memory until the tab closes.
*/
export function usePdfUrl(resource: string, id: number | null, enabled = true) {
const { getBlob } = useClient();
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
useEffect(() => {
if (id == null || !enabled) {
setState({ url: null, isLoading: false, error: null });
return;
}
let objectUrl: string | null = null;
// Guards against an out-of-order response overwriting a newer one when the user clicks quickly.
let cancelled = false;
setState({ url: null, isLoading: true, error: null });
getBlob(`${BASE}/${resource}/${id}/pdf`)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setState({ url: objectUrl, isLoading: false, error: null });
})
.catch((err: unknown) => {
if (cancelled) return;
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not load PDF') });
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
// getBlob is recreated on every useClient() call, so depending on it would refetch forever.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resource, id, enabled]);
return state;
}
/** Same trick for the report PDFs, which are parameterised by date range rather than by id. */
export function useReportUrl(kind: ReportKind | null, params: ListParams, enabled = true) {
const { getBlob } = useClient();
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
const key = `${kind ?? ''}${qs(params)}`;
useEffect(() => {
if (!kind || !enabled) {
setState({ url: null, isLoading: false, error: null });
return;
}
let objectUrl: string | null = null;
let cancelled = false;
setState({ url: null, isLoading: true, error: null });
getBlob(`${BASE}/reports/${kind}${qs(params)}`)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setState({ url: objectUrl, isLoading: false, error: null });
})
.catch((err: unknown) => {
if (cancelled) return;
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not build report') });
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
// `key` collapses kind+params into one comparable string; see the note in usePdfUrl about getBlob.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, enabled]);
return state;
}
/** Save a fetched PDF to disk under a readable name. */
export function downloadPdf(url: string, filename: string) {
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
}
export { errorMessage };
// ── accounts ─────────────────────────────────────────────────────────────────────────────────────
//
// The InvoiceShelf URL, its Sanctum token and the company it acts for are the owner's to set from
// /invoices/settings; nothing reads them from the environment any more. It is a REGISTRY — any number of
// labelled accounts, one of them selected — and two accounts differing only by company is the normal case,
// because the `company` header silently returns another company's books rather than erroring.
//
// The token is WRITE-ONLY across this boundary: an account carries its label, URL and pinned company, and
// has no field that could carry the token back to the browser.
const CONFIG = '/invoices/_config';
export type InvoiceShelfHealth = {
ok: boolean;
/** False only when no account is stored. It separates "set this up" from "this used to work". */
configured?: boolean;
/** Label of the selected account, so a failure names which books did not answer. */
account?: string;
version?: string | null;
company?: number | null;
error?: string;
};
export type InvoiceShelfAccount = {
id: number;
label: string;
url: string;
companyId: number | null;
companyName: string | null;
version: string | null;
isActive: boolean;
lastSeenAt: string | null;
createdAt: string;
};
export type InvoiceShelfAccounts = { configured: boolean; activeId: number | null; accounts: InvoiceShelfAccount[] };
export type CompanyOption = { id: number; name: string | null };
/**
* Unwrap a thrown client error into the shape the connection form needs.
*
* `needsChoice` is the interesting one: adding an account whose token can see several companies is a 409
* carrying the list, because nothing is wrong with what was sent — it just is not enough to decide. The form
* turns that into a picker rather than an error.
*/
export function parseAccountError(err: unknown): { message: string; needsChoice: boolean; companies: CompanyOption[] } {
const raw = (err as { message?: unknown } | null)?.message;
const fallback = { message: 'Something went wrong', needsChoice: false, companies: [] as CompanyOption[] };
if (typeof raw !== 'string' || !raw) return fallback;
try {
const body = JSON.parse(raw) as { error?: string; needsChoice?: boolean; companies?: CompanyOption[] };
return {
message: body.error || raw.slice(0, 300),
needsChoice: !!body.needsChoice,
companies: body.companies ?? [],
};
} catch {
return { ...fallback, message: raw.slice(0, 300) };
}
}
/**
* Health, including its failure bodies.
*
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
* so the throw is turned back into the answer rather than an error state.
*/
export function useInvoiceShelfHealth() {
const { get } = useClient();
return useQuery({
queryKey: [ROOT, 'health'] as const,
queryFn: async (): Promise<InvoiceShelfHealth> => {
try {
return await get<InvoiceShelfHealth>('/invoices/_health');
} catch (err) {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw === 'string') {
try {
const body = JSON.parse(raw) as InvoiceShelfHealth;
if (body && body.ok === false) return body;
} catch {
/* not the sidecar's body */
}
}
// Anything else — the platform proxy, auth, the sidecar being down — is a configured instance that is
// failing, not an unconfigured one. Never offer the setup form on a guess.
return { ok: false, configured: true, error: parseAccountError(err).message };
}
},
staleTime: STALE_MS,
retry: false,
});
}
export function useInvoiceShelfAccounts() {
const { get } = useClient();
return useQuery({
queryKey: [ROOT, 'accounts'] as const,
queryFn: () => get<InvoiceShelfAccounts>(CONFIG),
staleTime: STALE_MS,
retry: false,
});
}
/** The companies a stored account's token can act for — what the company picker is populated from. */
export function useAccountCompanies(id: number | null) {
const { get } = useClient();
return useQuery({
queryKey: [ROOT, 'accounts', id, 'companies'] as const,
queryFn: () => get<{ companies: CompanyOption[]; companyId: number | null }>(`${CONFIG}/${id}/companies`),
enabled: id != null,
staleTime: STALE_MS,
retry: false,
});
}
/**
* Either a pasted Sanctum token or a sign-in the sidecar trades for one.
*
* The password goes to the sidecar, which uses it for the single `/auth/login` call and stores only the token
* it gets back. It exists because InvoiceShelf 2.4.2 has no screen that issues tokens at all.
*/
export type Credential = { token: string } | { email: string; password: string };
export type AddAccountInput = { label: string; url: string; companyId?: number | null } & Credential;
export type EditAccountInput = { id: number; label?: string; url?: string; companyId?: number | null } & Partial<
Record<'token' | 'email' | 'password', string>
>;
export function useInvoiceShelfAccountActions() {
const { post, patch, delete: del } = useClient();
const qc = useQueryClient();
// Adding, editing, switching and removing all change what every other query here can even answer — a
// switch in particular changes the answer to all of them without changing any of their inputs.
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
const add = useMutation({
mutationFn: (input: AddAccountInput) => post<{ account: InvoiceShelfAccount }>(CONFIG, input),
onSuccess: invalidate,
});
const edit = useMutation({
mutationFn: ({ id, ...rest }: EditAccountInput) => patch<{ account: InvoiceShelfAccount }>(`${CONFIG}/${id}`, rest),
onSuccess: invalidate,
});
const activate = useMutation({
mutationFn: (id: number) => post<InvoiceShelfAccounts>(`${CONFIG}/${id}/activate`, {}),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: number) => del<InvoiceShelfAccounts>(`${CONFIG}/${id}`),
onSuccess: invalidate,
});
return { add, edit, activate, remove };
}
+94
View File
@@ -0,0 +1,94 @@
import { useCallback, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { DEFAULT_INVOICES_SECTION, isInvoicesSection, type InvoicesSectionId } from './shared';
// The URL names the open section and the selected record — not a panel channel. See docs/navigation-audit.md.
// InvoicesScreen redirects anything unrecognised, so the fallback here only covers the instant before that
// lands.
export function useInvoicesSection(): InvoicesSectionId {
const { section } = useParams();
return isInvoicesSection(section) ? section : DEFAULT_INVOICES_SECTION;
}
export type ListFilters = {
/** The record whose detail pane is open. `?selected=` because the list stays on screen beside it. */
selected: number | null;
search: string;
status: string | null;
customerId: number | null;
fromDate: string | null;
toDate: string | null;
page: number;
isFiltered: boolean;
};
/**
* Read/write the list query string.
*
* Every setter drops `page` back to 1 — changing a filter while on page 4 of the old result set otherwise
* lands you on a page that may not exist, and upstream answers that with an empty array rather than an
* error, which reads as "no results" for a filter that has plenty.
*
* Selection is `replace: true` so clicking through a list does not bury the previous screen under a dozen
* history entries; filter changes push normally, because backing out of a filter is a thing people want.
*/
export function useListFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo<ListFilters>(() => {
const num = (key: string) => {
const raw = searchParams.get(key);
const n = raw == null ? NaN : Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
};
const status = searchParams.get('status');
const search = searchParams.get('q') ?? '';
const customerId = num('customer');
const fromDate = searchParams.get('from');
const toDate = searchParams.get('to');
return {
selected: num('selected'),
search,
status,
customerId,
fromDate,
toDate,
page: num('page') ?? 1,
isFiltered: Boolean(search || status || customerId || fromDate || toDate),
};
}, [searchParams]);
const patch = useCallback(
(changes: Record<string, string | number | null>, opts?: { replace?: boolean; keepPage?: boolean }) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') next.delete(key);
else next.set(key, String(value));
}
if (!opts?.keepPage && !('page' in changes)) next.delete('page');
return next;
},
{ replace: opts?.replace ?? false },
);
},
[setSearchParams],
);
const select = useCallback(
(id: number | null) => patch({ selected: id }, { replace: true, keepPage: true }),
[patch],
);
const setPage = useCallback((page: number) => patch({ page: page <= 1 ? null : page }, { keepPage: true }), [patch]);
const clearFilters = useCallback(
() => patch({ q: null, status: null, customer: null, from: null, to: null }),
[patch],
);
return { filters, patch, select, setPage, clearFilters };
}