Ninth plugin out, third resurrection: switched off since 2026-08-13 with the permission reduced to api: [], so the server booted clean and every panel request 404'd. invoiceshelf/schema was also commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the frontend route, the table prefix and the install key all at once, and before extraction those had drifted: the API was /api/invoiceshelf while the screen was /invoices. The URL is what a person sees and should name what the plugin shows rather than the daemon behind it — the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts to satisfy the table-prefix rule. That rename was free ONLY because the table existed on no server, which was checked before deciding. With rows in it, keeping the vendor name would have been the better trade: db:push sees a rename as a drop plus a create and this project has no migration mechanism. The sidecar keeps its Invoiceshelf-named functions deliberately — they name the upstream API it actually speaks. 91 palette classes converted, 52 of them in STATUS_TONE, which was already the single source of status colour but held raw palette strings. SENT and VIEWED now share `info`: the closed six-tone set has no second shade of "in flight, not yet actioned". Recorded in PLUGIN.md as a real loss.
575 lines
22 KiB
TypeScript
575 lines
22 KiB
TypeScript
import type { Currency, CustomerStats, Dashboard, Lookups, Paginated, ReportKind, Summary } from './shared';
|
|
import { useEffect, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { useClient } from 'hooks/useClient';
|
|
|
|
// Data layer for the /invoices panels. Everything goes through the /api/invoiceshelf auth proxy to the
|
|
// officer-invoiceshelf sidecar, which holds the InvoiceShelf URL, the Sanctum token and the company header.
|
|
//
|
|
// Unlike the Transmission panels this does NOT poll. InvoiceShelf is a bookkeeping app whose records only
|
|
// change when someone in this UI changes them — a background refetch would spend requests to re-render
|
|
// identical rows. Freshness comes from invalidating after writes instead, plus a refetch when the window
|
|
// regains focus in case the change was made in InvoiceShelf's own UI.
|
|
//
|
|
// Mutations invalidate the whole ['invoiceshelf'] prefix rather than patching caches. The totals are deeply
|
|
// entangled — recording a payment moves the invoice's due_amount AND its paid_status AND the customer's
|
|
// due_amount AND four dashboard figures — so a targeted patch would be subtly wrong far more often than it
|
|
// would be cheap.
|
|
|
|
const ROOT = 'invoiceshelf';
|
|
const BASE = '/invoices/_officer';
|
|
|
|
/** Records are stable between writes; this only bounds how long a remount reuses the cache. */
|
|
const STALE_MS = 30_000;
|
|
|
|
const EMPTY: never[] = [];
|
|
|
|
const qs = (params?: Record<string, string | number | undefined | null>): string => {
|
|
if (!params) return '';
|
|
const search = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === null || value === '') continue;
|
|
search.set(key, String(value));
|
|
}
|
|
const out = search.toString();
|
|
return out ? `?${out}` : '';
|
|
};
|
|
|
|
/**
|
|
* Pull a human message out of a thrown client error.
|
|
*
|
|
* useClient throws `{status, message}` where message is the raw response body, so the useful text is
|
|
* usually one JSON level down. Laravel reports validation failures as `{message, errors: {field: [msg]}}`
|
|
* and the field-level message is the specific one — "The due date must be a date after invoice date" beats
|
|
* "The given data was invalid."
|
|
*/
|
|
function errorMessage(err: unknown, fallback: string): string {
|
|
const raw = typeof err === 'object' && err !== null && 'message' in err ? String((err as Error).message) : '';
|
|
if (!raw) return fallback;
|
|
try {
|
|
const parsed = JSON.parse(raw) as { error?: string; message?: string; errors?: Record<string, string[]> };
|
|
const firstField = parsed.errors && Object.values(parsed.errors)[0]?.[0];
|
|
return firstField || parsed.error || parsed.message || fallback;
|
|
} catch {
|
|
return raw || fallback;
|
|
}
|
|
}
|
|
|
|
// ── company-wide context ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The one call every panel needs: who we are, the company, its currency and the dashboard totals.
|
|
*
|
|
* The currency matters beyond the dashboard — it carries precision, separators and symbol placement, and
|
|
* every money value in the UI is unrenderable without it. Panels take it from here rather than each
|
|
* fetching it, so there is a single cache entry keeping them consistent.
|
|
*/
|
|
export function useSummary() {
|
|
const { get } = useClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: [ROOT, 'summary'] as const,
|
|
queryFn: () => get<Summary>(`${BASE}/summary`),
|
|
staleTime: STALE_MS,
|
|
});
|
|
|
|
return {
|
|
summary: query.data ?? null,
|
|
currency: query.data?.currency ?? null,
|
|
company: query.data?.company ?? null,
|
|
dashboard: query.data?.dashboard ?? null,
|
|
isLoading: query.isLoading,
|
|
error: query.error,
|
|
};
|
|
}
|
|
|
|
/** Convenience for the many components that need only the currency to render money. */
|
|
export function useCurrency(): Currency | null {
|
|
return useSummary().currency;
|
|
}
|
|
|
|
/**
|
|
* Every select-box option in one call: customers, items, units, tax types, categories, payment methods,
|
|
* currencies. Held long because these are small, near-static reference lists that half the forms need.
|
|
*/
|
|
export function useLookups() {
|
|
const { get } = useClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: [ROOT, 'lookups'] as const,
|
|
queryFn: () => get<Lookups>(`${BASE}/lookups`),
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
const data = query.data;
|
|
return {
|
|
customers: data?.customers ?? EMPTY,
|
|
items: data?.items ?? EMPTY,
|
|
units: data?.units ?? EMPTY,
|
|
taxTypes: data?.taxTypes ?? EMPTY,
|
|
categories: data?.categories ?? EMPTY,
|
|
paymentMethods: data?.paymentMethods ?? EMPTY,
|
|
currencies: data?.currencies ?? EMPTY,
|
|
isLoading: query.isLoading,
|
|
};
|
|
}
|
|
|
|
export function useDashboard(): { dashboard: Dashboard | null; isLoading: boolean } {
|
|
const { dashboard, isLoading } = useSummary();
|
|
return { dashboard, isLoading };
|
|
}
|
|
|
|
// ── generic resource access ──────────────────────────────────────────────────────────────────────
|
|
|
|
export type ListParams = Record<string, string | number | undefined | null>;
|
|
|
|
/**
|
|
* A page of any allow-listed resource. `params` is passed to upstream verbatim, which is what makes one
|
|
* hook enough for seven list screens — InvoiceShelf's index endpoints share a filter vocabulary
|
|
* (`page`, `limit`, `search`, `customer_id`, `status`, `from_date`, `to_date`, `orderByField`, `orderBy`).
|
|
*
|
|
* `params` is spread into the query key, so changing a filter is a new cache entry rather than a refetch
|
|
* of the old one — paging back and forth is then free.
|
|
*/
|
|
export function useResourceList<T>(resource: string, params?: ListParams, enabled = true) {
|
|
const { get } = useClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: [ROOT, resource, 'list', params ?? {}] as const,
|
|
queryFn: () => get<Paginated<T>>(`${BASE}/${resource}${qs(params)}`),
|
|
enabled,
|
|
staleTime: STALE_MS,
|
|
// Keeps the previous page on screen while the next one loads, instead of flashing an empty table.
|
|
placeholderData: (prev) => prev,
|
|
});
|
|
|
|
return {
|
|
rows: query.data?.data ?? (EMPTY as T[]),
|
|
meta: query.data?.meta ?? null,
|
|
isLoading: query.isLoading,
|
|
isFetching: query.isFetching,
|
|
error: query.error,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One record by id. Upstream wraps singular reads in a `data` key for most resources but returns some bare,
|
|
* so the response is unwrapped defensively rather than assuming either.
|
|
*/
|
|
export function useResourceDetail<T>(resource: string, id: number | null) {
|
|
const { get } = useClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: [ROOT, resource, 'detail', id] as const,
|
|
queryFn: async () => {
|
|
const res = await get<{ data?: T } | T>(`${BASE}/${resource}/${id}`);
|
|
return (res && typeof res === 'object' && 'data' in res ? (res as { data: T }).data : (res as T)) ?? null;
|
|
},
|
|
enabled: id != null,
|
|
staleTime: STALE_MS,
|
|
});
|
|
|
|
return { record: query.data ?? null, isLoading: query.isLoading, error: query.error };
|
|
}
|
|
|
|
/**
|
|
* Create / update / delete / bulk-delete for one resource.
|
|
*
|
|
* All four invalidate the entire ['invoiceshelf'] prefix on success — see the header note on why targeted
|
|
* cache patching is the wrong trade here.
|
|
*/
|
|
export function useResourceMutations(resource: string, label = resource) {
|
|
const { post, put, delete: del } = useClient();
|
|
const qc = useQueryClient();
|
|
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
|
|
|
|
const create = useMutation({
|
|
mutationFn: (payload: unknown) => post<{ data?: unknown }>(`${BASE}/${resource}`, payload),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
toast.success(`${singular(label)} created`);
|
|
},
|
|
onError: (err) => toast.error(errorMessage(err, `Could not create ${singular(label).toLowerCase()}`)),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ id, payload }: { id: number; payload: unknown }) =>
|
|
put<{ data?: unknown }>(`${BASE}/${resource}/${id}`, payload),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
toast.success(`${singular(label)} saved`);
|
|
},
|
|
onError: (err) => toast.error(errorMessage(err, `Could not save ${singular(label).toLowerCase()}`)),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => del<unknown>(`${BASE}/${resource}/${id}`),
|
|
onSuccess: () => {
|
|
invalidate();
|
|
toast.success(`${singular(label)} deleted`);
|
|
},
|
|
onError: (err) => toast.error(errorMessage(err, `Could not delete ${singular(label).toLowerCase()}`)),
|
|
});
|
|
|
|
const removeMany = useMutation({
|
|
mutationFn: (ids: number[]) => post<unknown>(`${BASE}/${resource}/delete`, { ids }),
|
|
onSuccess: (_data, ids) => {
|
|
invalidate();
|
|
toast.success(`${ids.length} ${ids.length === 1 ? singular(label).toLowerCase() : label} deleted`);
|
|
},
|
|
onError: (err) => toast.error(errorMessage(err, `Could not delete ${label}`)),
|
|
});
|
|
|
|
return { create, update, remove, removeMany };
|
|
}
|
|
|
|
const singular = (label: string): string => {
|
|
const base = label.endsWith('ies') ? `${label.slice(0, -3)}y` : label.endsWith('s') ? label.slice(0, -1) : label;
|
|
return base.charAt(0).toUpperCase() + base.slice(1);
|
|
};
|
|
|
|
/**
|
|
* The per-document verbs: status, clone, send, convert-to-invoice, duplicate.
|
|
*
|
|
* `send` genuinely emails the customer. It is reachable from here because the UI needs it, but nothing in
|
|
* this module calls it on its own — it must always be behind an explicit confirm step, never a side effect
|
|
* of saving or of any other action.
|
|
*/
|
|
export function useDocumentAction(resource: string) {
|
|
const { post } = useClient();
|
|
const qc = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: ({ id, action, payload }: { id: number; action: string; payload?: unknown }) =>
|
|
post<unknown>(`${BASE}/${resource}/${id}/${action}`, payload ?? {}),
|
|
onSuccess: (_data, vars) => {
|
|
qc.invalidateQueries({ queryKey: [ROOT] });
|
|
toast.success(ACTION_TOAST[vars.action] ?? 'Done');
|
|
},
|
|
onError: (err) => toast.error(errorMessage(err, 'Action failed')),
|
|
});
|
|
}
|
|
|
|
const ACTION_TOAST: Record<string, string> = {
|
|
status: 'Status updated',
|
|
clone: 'Copy created',
|
|
send: 'Sent',
|
|
'convert-to-invoice': 'Converted to invoice',
|
|
duplicate: 'Duplicated',
|
|
};
|
|
|
|
/** The number the next document of this kind would take, for the create form's placeholder. */
|
|
export function useNextNumber(key: 'invoice' | 'estimate' | 'payment', enabled = true) {
|
|
const { get } = useClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: [ROOT, 'next-number', key] as const,
|
|
queryFn: () => get<{ nextNumber?: string; nextSequenceNumber?: number }>(`${BASE}/next-number?key=${key}`),
|
|
enabled,
|
|
// Never cached: two create forms opened in sequence must not both claim the same number.
|
|
staleTime: 0,
|
|
gcTime: 0,
|
|
});
|
|
|
|
return query.data?.nextNumber ?? null;
|
|
}
|
|
|
|
/**
|
|
* The PDF templates a document can be rendered with. `template_name` is a *required* field on every
|
|
* invoice/estimate write, so the create form needs this before it can save anything — it is not decoration.
|
|
*/
|
|
export function useTemplates(resource: 'invoices' | 'estimates') {
|
|
const { get } = useClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: [ROOT, resource, 'templates'] as const,
|
|
queryFn: () => get<{ [key: string]: unknown }>(`${BASE}/${resource}/templates`),
|
|
staleTime: 60 * 60_000,
|
|
});
|
|
|
|
// Upstream keys the payload by resource — `{invoiceTemplates: […]}` / `{estimateTemplates: […]}` — so it
|
|
// is read positionally rather than by a name that differs per endpoint.
|
|
const list = Object.values(query.data ?? {}).find(Array.isArray) as { name: string; path?: string }[] | undefined;
|
|
return list ?? (EMPTY as { name: string; path?: string }[]);
|
|
}
|
|
|
|
export function useCustomerStats(id: number | null) {
|
|
const { get } = useClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: [ROOT, 'customers', 'stats', id] as const,
|
|
queryFn: () => get<CustomerStats>(`${BASE}/customers/${id}/stats`),
|
|
enabled: id != null,
|
|
staleTime: STALE_MS,
|
|
});
|
|
|
|
return { stats: query.data ?? null, isLoading: query.isLoading };
|
|
}
|
|
|
|
// ── PDFs ─────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
type PdfState = { url: string | null; isLoading: boolean; error: string | null };
|
|
|
|
/**
|
|
* Fetch a PDF and expose it as an object URL an <iframe> can render.
|
|
*
|
|
* It has to be done this way round: the sidecar sits behind Officer's bearer auth, and a naked
|
|
* `<iframe src="/api/...">` cannot carry an Authorization header — it would fetch unauthenticated and
|
|
* render a 401 body. So the bytes are pulled with the authenticated client and handed to the iframe as a
|
|
* blob: URL instead.
|
|
*
|
|
* The previous URL is revoked whenever the target changes and on unmount; without that, every document a
|
|
* user clicks through leaks its PDF into memory until the tab closes.
|
|
*/
|
|
export function usePdfUrl(resource: string, id: number | null, enabled = true) {
|
|
const { getBlob } = useClient();
|
|
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
|
|
|
|
useEffect(() => {
|
|
if (id == null || !enabled) {
|
|
setState({ url: null, isLoading: false, error: null });
|
|
return;
|
|
}
|
|
|
|
let objectUrl: string | null = null;
|
|
// Guards against an out-of-order response overwriting a newer one when the user clicks quickly.
|
|
let cancelled = false;
|
|
|
|
setState({ url: null, isLoading: true, error: null });
|
|
|
|
getBlob(`${BASE}/${resource}/${id}/pdf`)
|
|
.then((blob) => {
|
|
if (cancelled) return;
|
|
objectUrl = URL.createObjectURL(blob);
|
|
setState({ url: objectUrl, isLoading: false, error: null });
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (cancelled) return;
|
|
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not load PDF') });
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
};
|
|
// getBlob is recreated on every useClient() call, so depending on it would refetch forever.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [resource, id, enabled]);
|
|
|
|
return state;
|
|
}
|
|
|
|
/** Same trick for the report PDFs, which are parameterised by date range rather than by id. */
|
|
export function useReportUrl(kind: ReportKind | null, params: ListParams, enabled = true) {
|
|
const { getBlob } = useClient();
|
|
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
|
|
const key = `${kind ?? ''}${qs(params)}`;
|
|
|
|
useEffect(() => {
|
|
if (!kind || !enabled) {
|
|
setState({ url: null, isLoading: false, error: null });
|
|
return;
|
|
}
|
|
|
|
let objectUrl: string | null = null;
|
|
let cancelled = false;
|
|
|
|
setState({ url: null, isLoading: true, error: null });
|
|
|
|
getBlob(`${BASE}/reports/${kind}${qs(params)}`)
|
|
.then((blob) => {
|
|
if (cancelled) return;
|
|
objectUrl = URL.createObjectURL(blob);
|
|
setState({ url: objectUrl, isLoading: false, error: null });
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (cancelled) return;
|
|
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not build report') });
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
};
|
|
// `key` collapses kind+params into one comparable string; see the note in usePdfUrl about getBlob.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, enabled]);
|
|
|
|
return state;
|
|
}
|
|
|
|
/** Save a fetched PDF to disk under a readable name. */
|
|
export function downloadPdf(url: string, filename: string) {
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
|
|
export { errorMessage };
|
|
|
|
// ── accounts ─────────────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// The InvoiceShelf URL, its Sanctum token and the company it acts for are the owner's to set from
|
|
// /invoices/settings; nothing reads them from the environment any more. It is a REGISTRY — any number of
|
|
// labelled accounts, one of them selected — and two accounts differing only by company is the normal case,
|
|
// because the `company` header silently returns another company's books rather than erroring.
|
|
//
|
|
// The token is WRITE-ONLY across this boundary: an account carries its label, URL and pinned company, and
|
|
// has no field that could carry the token back to the browser.
|
|
|
|
const CONFIG = '/invoices/_config';
|
|
|
|
export type InvoiceShelfHealth = {
|
|
ok: boolean;
|
|
/** False only when no account is stored. It separates "set this up" from "this used to work". */
|
|
configured?: boolean;
|
|
/** Label of the selected account, so a failure names which books did not answer. */
|
|
account?: string;
|
|
version?: string | null;
|
|
company?: number | null;
|
|
error?: string;
|
|
};
|
|
|
|
export type InvoiceShelfAccount = {
|
|
id: number;
|
|
label: string;
|
|
url: string;
|
|
companyId: number | null;
|
|
companyName: string | null;
|
|
version: string | null;
|
|
isActive: boolean;
|
|
lastSeenAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type InvoiceShelfAccounts = { configured: boolean; activeId: number | null; accounts: InvoiceShelfAccount[] };
|
|
|
|
export type CompanyOption = { id: number; name: string | null };
|
|
|
|
/**
|
|
* Unwrap a thrown client error into the shape the connection form needs.
|
|
*
|
|
* `needsChoice` is the interesting one: adding an account whose token can see several companies is a 409
|
|
* carrying the list, because nothing is wrong with what was sent — it just is not enough to decide. The form
|
|
* turns that into a picker rather than an error.
|
|
*/
|
|
export function parseAccountError(err: unknown): { message: string; needsChoice: boolean; companies: CompanyOption[] } {
|
|
const raw = (err as { message?: unknown } | null)?.message;
|
|
const fallback = { message: 'Something went wrong', needsChoice: false, companies: [] as CompanyOption[] };
|
|
if (typeof raw !== 'string' || !raw) return fallback;
|
|
try {
|
|
const body = JSON.parse(raw) as { error?: string; needsChoice?: boolean; companies?: CompanyOption[] };
|
|
return {
|
|
message: body.error || raw.slice(0, 300),
|
|
needsChoice: !!body.needsChoice,
|
|
companies: body.companies ?? [],
|
|
};
|
|
} catch {
|
|
return { ...fallback, message: raw.slice(0, 300) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Health, including its failure bodies.
|
|
*
|
|
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
|
|
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
|
|
* so the throw is turned back into the answer rather than an error state.
|
|
*/
|
|
export function useInvoiceShelfHealth() {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [ROOT, 'health'] as const,
|
|
queryFn: async (): Promise<InvoiceShelfHealth> => {
|
|
try {
|
|
return await get<InvoiceShelfHealth>('/invoices/_health');
|
|
} catch (err) {
|
|
const raw = (err as { message?: unknown } | null)?.message;
|
|
if (typeof raw === 'string') {
|
|
try {
|
|
const body = JSON.parse(raw) as InvoiceShelfHealth;
|
|
if (body && body.ok === false) return body;
|
|
} catch {
|
|
/* not the sidecar's body */
|
|
}
|
|
}
|
|
// Anything else — the platform proxy, auth, the sidecar being down — is a configured instance that is
|
|
// failing, not an unconfigured one. Never offer the setup form on a guess.
|
|
return { ok: false, configured: true, error: parseAccountError(err).message };
|
|
}
|
|
},
|
|
staleTime: STALE_MS,
|
|
retry: false,
|
|
});
|
|
}
|
|
|
|
export function useInvoiceShelfAccounts() {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [ROOT, 'accounts'] as const,
|
|
queryFn: () => get<InvoiceShelfAccounts>(CONFIG),
|
|
staleTime: STALE_MS,
|
|
retry: false,
|
|
});
|
|
}
|
|
|
|
/** The companies a stored account's token can act for — what the company picker is populated from. */
|
|
export function useAccountCompanies(id: number | null) {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [ROOT, 'accounts', id, 'companies'] as const,
|
|
queryFn: () => get<{ companies: CompanyOption[]; companyId: number | null }>(`${CONFIG}/${id}/companies`),
|
|
enabled: id != null,
|
|
staleTime: STALE_MS,
|
|
retry: false,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Either a pasted Sanctum token or a sign-in the sidecar trades for one.
|
|
*
|
|
* The password goes to the sidecar, which uses it for the single `/auth/login` call and stores only the token
|
|
* it gets back. It exists because InvoiceShelf 2.4.2 has no screen that issues tokens at all.
|
|
*/
|
|
export type Credential = { token: string } | { email: string; password: string };
|
|
|
|
export type AddAccountInput = { label: string; url: string; companyId?: number | null } & Credential;
|
|
export type EditAccountInput = { id: number; label?: string; url?: string; companyId?: number | null } & Partial<
|
|
Record<'token' | 'email' | 'password', string>
|
|
>;
|
|
|
|
export function useInvoiceShelfAccountActions() {
|
|
const { post, patch, delete: del } = useClient();
|
|
const qc = useQueryClient();
|
|
// Adding, editing, switching and removing all change what every other query here can even answer — a
|
|
// switch in particular changes the answer to all of them without changing any of their inputs.
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
|
|
|
|
const add = useMutation({
|
|
mutationFn: (input: AddAccountInput) => post<{ account: InvoiceShelfAccount }>(CONFIG, input),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const edit = useMutation({
|
|
mutationFn: ({ id, ...rest }: EditAccountInput) => patch<{ account: InvoiceShelfAccount }>(`${CONFIG}/${id}`, rest),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const activate = useMutation({
|
|
mutationFn: (id: number) => post<InvoiceShelfAccounts>(`${CONFIG}/${id}/activate`, {}),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => del<InvoiceShelfAccounts>(`${CONFIG}/${id}`),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
return { add, edit, activate, remove };
|
|
}
|