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/Dav": 7,
|
||||||
"src/workspaces/officerdev/src/apps/FileBrowser": 15,
|
"src/workspaces/officerdev/src/apps/FileBrowser": 15,
|
||||||
"src/workspaces/officerdev/src/apps/FileViewer": 38,
|
"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/Preview": 4,
|
||||||
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
|
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
|
||||||
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
|
"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" element={<Dashboard.GiteaScreen />} />
|
||||||
<Route path="/gitea/repo/:owner/:name/:tab" 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="/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" element={<Dashboard.WalletScreen />} />
|
||||||
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
|
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
|
||||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
<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 './Contacts';
|
||||||
export * from './CommandHQ';
|
export * from './CommandHQ';
|
||||||
export * from './Gitea';
|
export * from './Gitea';
|
||||||
export * from './Invoices';
|
|
||||||
export * from './Wallet';
|
export * from './Wallet';
|
||||||
export * from './SystemMonitor';
|
export * from './SystemMonitor';
|
||||||
export * from './Activity';
|
export * from './Activity';
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
|
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
|
||||||
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
||||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
{ 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('/wallet'), title: 'Wallet' },
|
||||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||||
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
|
{ 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 ──────────────────────────────
|
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||||
|
|
||||||
export * from './dav';
|
export * from './dav';
|
||||||
export * from './invoiceshelf';
|
|
||||||
export * from './notify';
|
export * from './notify';
|
||||||
export * from './wallet';
|
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 './notify/schema'; // push_devices officer-notify
|
||||||
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
// 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
|
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
||||||
|
|
||||||
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
// ── 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 },
|
{ 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/`.
|
// 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
|
// 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', () => {
|
describe('which installed services have work to do', () => {
|
||||||
it('skips single-tenant daemons entirely', () => {
|
it('skips single-tenant daemons entirely', () => {
|
||||||
const ids = servicesNeedingMemberProvision(['invoiceshelf', 'vnc', 'memos']).map((e) => e.id);
|
// caldav and memos provision accounts; vnc has no user concept. Both survivors are services with no
|
||||||
expect(ids.sort()).toEqual(['invoiceshelf', 'memos']);
|
// 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', () => {
|
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 { pluginsRouter } from './api/plugins/router';
|
||||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
|
||||||
// import { walletRouter } from './api/wallet/router';
|
// import { walletRouter } from './api/wallet/router';
|
||||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||||
// import { caldavRouter } from './api/dav/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
|
// ['/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
|
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
||||||
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
|
|
||||||
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
|
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
|
||||||
['/system-monitor', systemMonitorRouter],
|
['/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.
|
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
||||||
personal: ['/devices', '/subscriptions'],
|
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,
|
// `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
|
// 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
|
// 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.
|
// than one Jellyfin is a thing someone has.
|
||||||
has: { api: true, schema: true, sidecar: true, web: true },
|
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',
|
appName: 'vault',
|
||||||
publisher: 'officerdev',
|
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 widgetMetas } from '../apps/Widgets';
|
||||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||||
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
||||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
|
||||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||||
@@ -32,7 +31,6 @@ export const apps = [
|
|||||||
...widgetMetas,
|
...widgetMetas,
|
||||||
...desktopMetas,
|
...desktopMetas,
|
||||||
...giteaMetas,
|
...giteaMetas,
|
||||||
...invoicesMetas,
|
|
||||||
...walletMetas,
|
...walletMetas,
|
||||||
...monitorMetas,
|
...monitorMetas,
|
||||||
...qrTransferMetas,
|
...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 { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
|
||||||
export type { DavCollection, CalendarEvent, DavContact } 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
|
// 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.
|
// the wallet query param the same way the panels read it.
|
||||||
|
|||||||
Reference in New Issue
Block a user