invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Closes the same data hazard as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix, frontend route, table prefix, sidecar suffix, install key — and before extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted apart, which was harmless only while they were separate hardcoded strings. The route is the part a person sees, and the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost three call sites and a table rename to invoices_accounts, which was free only because the table existed on no server — verified before deciding, not after. members.test.ts re-anchored again, this time to caldav and memos: services with no extraction planned. That assertion has now been re-pointed four times by services leaving for plugins/, which is the whole reason installer.test.ts was made literal earlier tonight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
"src/workspaces/officerdev/src/apps/Dav": 7,
|
||||
"src/workspaces/officerdev/src/apps/FileBrowser": 15,
|
||||
"src/workspaces/officerdev/src/apps/FileViewer": 38,
|
||||
"src/workspaces/officerdev/src/apps/Invoices": 95,
|
||||
"src/workspaces/officerdev/src/apps/Preview": 4,
|
||||
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
|
||||
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
|
||||
|
||||
@@ -107,8 +107,6 @@ export function App() {
|
||||
<Route path="/gitea/repo/:owner/:name" element={<Dashboard.GiteaScreen />} />
|
||||
<Route path="/gitea/repo/:owner/:name/:tab" element={<Dashboard.GiteaScreen />} />
|
||||
<Route path="/gitea/repo/:owner/:name/:tab/:item" element={<Dashboard.GiteaScreen />} />
|
||||
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
|
||||
<Route path="/invoices/:section" element={<Dashboard.InvoicesScreen />} />
|
||||
<Route path="/wallet" element={<Dashboard.WalletScreen />} />
|
||||
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
|
||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { Navigate, useParams } from 'react-router';
|
||||
import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /invoices uses the Workspace/Panel system (like /command-hq): the section nav on the
|
||||
// left, the section itself on the right. Both panels talk to the officer-invoiceshelf sidecar through the
|
||||
// /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the
|
||||
// sidecar and never reach the browser.
|
||||
//
|
||||
// The open section is :section in the URL; list filters, the selected record and the open editor are query
|
||||
// params. So the panels read the URL rather than passing state to each other, and this screen is the single
|
||||
// place that decides what an absent or bogus section means.
|
||||
|
||||
export const InvoicesScreen = () => {
|
||||
const { section } = useParams();
|
||||
const workspace = useDashboardState<LayoutNode>('screens/invoices', defaultLayout);
|
||||
|
||||
// Bare /invoices, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||
// default while the address bar says something else — the nav highlight is derived from the URL.
|
||||
if (!isInvoicesSection(section)) {
|
||||
return <Navigate to={invoicesSectionPath(DEFAULT_INVOICES_SECTION)} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['invoices-nav', 'invoices-view'], fallback: 'invoices-view' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './InvoicesScreen';
|
||||
@@ -15,7 +15,6 @@ export * from './Calendar';
|
||||
export * from './Contacts';
|
||||
export * from './CommandHQ';
|
||||
export * from './Gitea';
|
||||
export * from './Invoices';
|
||||
export * from './Wallet';
|
||||
export * from './SystemMonitor';
|
||||
export * from './Activity';
|
||||
|
||||
@@ -25,7 +25,6 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
|
||||
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
||||
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
||||
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
|
||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
|
||||
|
||||
@@ -46,7 +46,6 @@ export * from './user-data';
|
||||
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||
|
||||
export * from './dav';
|
||||
export * from './invoiceshelf';
|
||||
export * from './notify';
|
||||
export * from './wallet';
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export {
|
||||
listInvoiceshelfAccounts,
|
||||
getActiveInvoiceshelfCredentials,
|
||||
getInvoiceshelfCredentials,
|
||||
createInvoiceshelfAccount,
|
||||
updateInvoiceshelfAccount,
|
||||
setActiveInvoiceshelfAccount,
|
||||
deleteInvoiceshelfAccount,
|
||||
recordInvoiceshelfProbe,
|
||||
} from './queries';
|
||||
|
||||
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries';
|
||||
@@ -1,212 +0,0 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { invoiceshelfAccounts } from './schema';
|
||||
import { encryptSecret, decryptSecret } from '../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: invoiceshelfAccounts.id,
|
||||
label: invoiceshelfAccounts.label,
|
||||
url: invoiceshelfAccounts.url,
|
||||
companyId: invoiceshelfAccounts.companyId,
|
||||
companyName: invoiceshelfAccounts.companyName,
|
||||
version: invoiceshelfAccounts.version,
|
||||
isActive: invoiceshelfAccounts.isActive,
|
||||
lastSeenAt: invoiceshelfAccounts.lastSeenAt,
|
||||
createdAt: invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(eq(invoiceshelfAccounts.userId, userId))
|
||||
.orderBy(desc(invoiceshelfAccounts.isActive), desc(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(invoiceshelfAccounts)
|
||||
.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(invoiceshelfAccounts)
|
||||
.set(set)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
||||
const [row] = await tx
|
||||
.update(invoiceshelfAccounts)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)))
|
||||
.returning({ id: invoiceshelfAccounts.id, wasActive: invoiceshelfAccounts.isActive });
|
||||
if (!deleted) return false;
|
||||
|
||||
if (deleted.wasActive) {
|
||||
const [next] = await tx
|
||||
.select({ id: invoiceshelfAccounts.id })
|
||||
.from(invoiceshelfAccounts)
|
||||
.where(eq(invoiceshelfAccounts.userId, userId))
|
||||
.orderBy(desc(invoiceshelfAccounts.createdAt))
|
||||
.limit(1);
|
||||
if (next) {
|
||||
await tx
|
||||
.update(invoiceshelfAccounts)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.set({ version, lastSeenAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from '../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 invoiceshelfAccounts = pgTable(
|
||||
'invoiceshelf_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_invoiceshelf_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_invoiceshelf_accounts_one_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
);
|
||||
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
|
||||
|
||||
// export * from './notify/schema'; // push_devices officer-notify
|
||||
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
||||
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
|
||||
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
||||
|
||||
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// /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',
|
||||
prefix: '/api/invoiceshelf',
|
||||
});
|
||||
|
||||
export const invoiceshelfRouter = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getInvoiceshelfServerUrl = proxy.getHttpUrl;
|
||||
@@ -184,21 +184,6 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
{ key: 'secret', label: 'Access token', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'invoiceshelf',
|
||||
ui: { name: 'Invoices', icon: 'Receipt', color: '#0891b2', rootRoute: '/invoices', routes: ['/invoices'] },
|
||||
process: 'officer-invoiceshelf',
|
||||
label: 'Invoices',
|
||||
summary: 'InvoiceShelf — clients, estimates and invoices',
|
||||
members: 'accounts',
|
||||
modes: ['existing', 'provisioned'],
|
||||
permission: 'invoices',
|
||||
composeTemplate: 'invoiceshelf',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true },
|
||||
{ key: 'secret', label: 'API token', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
// Vault was here until 2026-08-18, when it became `plugins/vault/`.
|
||||
//
|
||||
// Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having
|
||||
|
||||
@@ -18,8 +18,11 @@ describe('what adding a member would do', () => {
|
||||
|
||||
describe('which installed services have work to do', () => {
|
||||
it('skips single-tenant daemons entirely', () => {
|
||||
const ids = servicesNeedingMemberProvision(['invoiceshelf', 'vnc', 'memos']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['invoiceshelf', 'memos']);
|
||||
// caldav and memos provision accounts; vnc has no user concept. Both survivors are services with no
|
||||
// extraction planned — this assertion has been re-pointed four times by services leaving for
|
||||
// `plugins/`, so it now names ones that are staying.
|
||||
const ids = servicesNeedingMemberProvision(['caldav', 'vnc', 'memos']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['caldav', 'memos']);
|
||||
});
|
||||
|
||||
it('ignores services that are not installed', () => {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router';
|
||||
import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||
// import { walletRouter } from './api/wallet/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
// import { caldavRouter } from './api/dav/sidecar-server';
|
||||
@@ -147,7 +146,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
|
||||
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
||||
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
||||
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
|
||||
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
||||
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
|
||||
['/system-monitor', systemMonitorRouter],
|
||||
|
||||
@@ -191,15 +191,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
||||
personal: ['/devices', '/subscriptions'],
|
||||
},
|
||||
{
|
||||
key: 'invoices',
|
||||
label: 'Invoices',
|
||||
description: 'InvoiceShelf books',
|
||||
kind: 'app',
|
||||
// api: ['/invoiceshelf'], // plugin — switched off 2026-08-13
|
||||
api: [],
|
||||
routes: ['/invoices'],
|
||||
},
|
||||
// `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale,
|
||||
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
|
||||
// OffScale app gates it on `embedded`, which it never sets, and it never will — the app is permanently
|
||||
|
||||
@@ -191,6 +191,27 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// than one Jellyfin is a thing someone has.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'invoices',
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Invoices',
|
||||
summary: 'InvoiceShelf books — invoices, estimates, customers and payments',
|
||||
color: '#0ea5e9',
|
||||
icon: 'ReceiptText',
|
||||
source: 'https://gitea.officer.dev/plugins/invoices.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'invoices',
|
||||
label: 'Invoices',
|
||||
description: 'InvoiceShelf books',
|
||||
},
|
||||
],
|
||||
// `invoices`, not `invoiceshelf` — the appName drives the URL, and it should name what the plugin
|
||||
// shows rather than the daemon behind it. See its PLUGIN.md for what that cost.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'vault',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import type { Company, UpstreamTarget } from './upstream';
|
||||
import {
|
||||
createInvoiceshelfAccount,
|
||||
deleteInvoiceshelfAccount,
|
||||
getInvoiceshelfCredentials,
|
||||
listInvoiceshelfAccounts,
|
||||
recordInvoiceshelfProbe,
|
||||
setActiveInvoiceshelfAccount,
|
||||
updateInvoiceshelfAccount,
|
||||
} from 'officerdb';
|
||||
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);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../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'));
|
||||
@@ -1,322 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
// 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 'officerdb';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
@@ -32,7 +31,6 @@ export const apps = [
|
||||
...widgetMetas,
|
||||
...desktopMetas,
|
||||
...giteaMetas,
|
||||
...invoicesMetas,
|
||||
...walletMetas,
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
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-red-600 text-white hover:bg-red-700' : ''}
|
||||
>
|
||||
{state.confirmLabel ?? 'Confirm'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,449 +0,0 @@
|
||||
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-emerald-500" />
|
||||
) : (
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
|
||||
)}
|
||||
<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-sky-500/15 text-sky-500">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,348 +0,0 @@
|
||||
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-red-600"
|
||||
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-red-600"
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,278 +0,0 @@
|
||||
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 { 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="text-amber-500 bg-amber-500/10"
|
||||
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="text-sky-500 bg-sky-500/10"
|
||||
label="Sales"
|
||||
value={formatMoney(dashboard.total_sales, currency)}
|
||||
to={invoicesSectionPath('invoices')}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Wallet}
|
||||
tone="text-emerald-500 bg-emerald-500/10"
|
||||
label="Receipts"
|
||||
value={formatMoney(dashboard.total_receipts, currency)}
|
||||
to={invoicesSectionPath('payments')}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Receipt}
|
||||
tone="text-violet-500 bg-violet-500/10"
|
||||
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-sky-500" label="Sales" />
|
||||
<LegendDot className="bg-emerald-500" label="Receipts" />
|
||||
<LegendDot className="bg-red-500" 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="#0ea5e9" />
|
||||
<Gradient id="isReceipts" color="#10b981" />
|
||||
<Gradient id="isExpenses" color="#ef4444" />
|
||||
</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="#0ea5e9"
|
||||
fill="url(#isSales)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="receipts"
|
||||
name="Receipts"
|
||||
stroke="#10b981"
|
||||
fill="url(#isReceipts)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
name="Expenses"
|
||||
stroke="#ef4444"
|
||||
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 };
|
||||
@@ -1,732 +0,0 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
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-red-600"
|
||||
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-red-600"
|
||||
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)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,313 +0,0 @@
|
||||
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-red-600"
|
||||
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-red-600"
|
||||
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)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,406 +0,0 @@
|
||||
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-red-600"
|
||||
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-red-600"
|
||||
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)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
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-sky-500/15 text-sky-500 ring-1 ring-black/5">
|
||||
<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>
|
||||
);
|
||||
@@ -1,154 +0,0 @@
|
||||
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 />;
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
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-black/50">· {company.name}</span>}
|
||||
</span>
|
||||
{error ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-red-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
unreachable
|
||||
</span>
|
||||
) : (
|
||||
dashboard && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-black/60">
|
||||
{formatMoney(dashboard.total_amount_due, currency)} due
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,262 +0,0 @@
|
||||
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-red-600"
|
||||
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-red-600"
|
||||
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)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,302 +0,0 @@
|
||||
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-red-600"
|
||||
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-red-600"
|
||||
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)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
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-red-500">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>
|
||||
);
|
||||
};
|
||||
@@ -1,734 +0,0 @@
|
||||
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-amber-600">
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,322 +0,0 @@
|
||||
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-red-600"
|
||||
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-red-600"
|
||||
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';
|
||||
};
|
||||
@@ -1,211 +0,0 @@
|
||||
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-red-500">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>
|
||||
);
|
||||
};
|
||||
@@ -1,147 +0,0 @@
|
||||
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-amber-600">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>
|
||||
);
|
||||
};
|
||||
@@ -1,281 +0,0 @@
|
||||
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 { 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 tone = STATUS_TONE[value] ?? 'bg-muted text-muted-foreground ring-border';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ring-1 ring-inset ${tone} ${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-red-500">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>
|
||||
);
|
||||
@@ -1,152 +0,0 @@
|
||||
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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { LayoutGrid, PanelLeft } from 'lucide-react';
|
||||
import { InvoicesNav } from './InvoicesNav';
|
||||
import { InvoicesView } from './InvoicesView';
|
||||
import { InvoicesViewHeader } from './InvoicesViewHeader';
|
||||
|
||||
export { InvoicesNav, InvoicesView };
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -1,454 +0,0 @@
|
||||
// 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.
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Tailwind classes per status token, matched hue-for-hue to upstream's badge components
|
||||
* (InvoiceStatusBadge / EstimateStatusBadge / PaidStatusBadge / RecurringInvoiceStatusBadge).
|
||||
*
|
||||
* The distinctions are finer than they look and are worth keeping: VIEWED is indigo where SENT is blue,
|
||||
* UNPAID is orange where OVERDUE is red, and PARTIALLY_PAID is cyan — so a glance down the column tells
|
||||
* you which of two similar states a row is in without reading the text.
|
||||
*/
|
||||
export const STATUS_TONE: Record<string, string> = {
|
||||
DRAFT: 'bg-muted text-muted-foreground ring-border',
|
||||
SENT: 'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:text-blue-400',
|
||||
VIEWED: 'bg-indigo-500/10 text-indigo-600 ring-indigo-500/20 dark:text-indigo-400',
|
||||
COMPLETED: 'bg-green-500/10 text-green-600 ring-green-500/20 dark:text-green-400',
|
||||
DUE: 'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:text-amber-400',
|
||||
OVERDUE: 'bg-red-500/10 text-red-600 ring-red-500/20 dark:text-red-400',
|
||||
UNPAID: 'bg-orange-500/10 text-orange-600 ring-orange-500/20 dark:text-orange-400',
|
||||
PARTIALLY_PAID: 'bg-cyan-500/10 text-cyan-600 ring-cyan-500/20 dark:text-cyan-400',
|
||||
PAID: 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:text-emerald-400',
|
||||
EXPIRED: 'bg-red-500/10 text-red-600 ring-red-500/20 dark:text-red-400',
|
||||
ACCEPTED: 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/20 dark:text-emerald-400',
|
||||
REJECTED: 'bg-rose-500/10 text-rose-600 ring-rose-500/20 dark:text-rose-400',
|
||||
ACTIVE: 'bg-blue-500/10 text-blue-600 ring-blue-500/20 dark:text-blue-400',
|
||||
ON_HOLD: 'bg-amber-500/10 text-amber-600 ring-amber-500/20 dark:text-amber-400',
|
||||
};
|
||||
|
||||
/** 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'];
|
||||
@@ -1,574 +0,0 @@
|
||||
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 = '/invoiceshelf/_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 = '/invoiceshelf/_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>('/invoiceshelf/_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 };
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -65,9 +65,6 @@ export type { GiteaRepoTab, GiteaSectionId } from './apps/Gitea/shared';
|
||||
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
|
||||
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
|
||||
|
||||
// Same for /invoices.
|
||||
export { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection } from './apps/Invoices/shared';
|
||||
export type { InvoicesSectionId } from './apps/Invoices/shared';
|
||||
|
||||
// Same for /wallet. WALLET_PARAM is exported too so anything linking into the screen from outside spells
|
||||
// the wallet query param the same way the panels read it.
|
||||
|
||||
Reference in New Issue
Block a user