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 => { 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 }; 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(`${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(`${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; /** * 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(resource: string, params?: ListParams, enabled = true) { const { get } = useClient(); const query = useQuery({ queryKey: [ROOT, resource, 'list', params ?? {}] as const, queryFn: () => get>(`${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(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(`${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(`${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(`${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 = { 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(`${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