// vibe_erp typed REST client. // // **Why a thin wrapper instead of a generated client.** The framework // already serves a real OpenAPI spec at /v3/api-docs and could feed // openapi-typescript or openapi-generator-cli — but every code // generator pulls a chain of npm/Java toolchain steps into the build // and generates ~thousands of lines of churn for every backend tweak. // A hand-written client over `fetch` keeps the SPA bundle small, // auditable, and dependency-free, and v1 of the SPA only needs the // few dozen calls listed below. Codegen can return when (a) the // API surface is too large to maintain by hand or (b) external // integrators want a typed client they can reuse — neither is true // in v1.0. // // **Auth.** Every call goes through `apiFetch`, which reads the // access token from localStorage and adds an `Authorization: Bearer // ` header. A 401 response triggers a hard logout (clears // the token + redirects to /login via the auth context) — not a // silent refresh, because v1 keeps the refresh story simple. // Refreshing on 401 is a v1.x improvement. // // **Errors.** A non-2xx response throws an `ApiError` carrying the // status, message, and the parsed JSON body if any. Pages catch // this and render the message inline. Network failures throw the // underlying TypeError, also caught by the page boundary. import type { Item, JournalEntry, Location, MetaInfo, Partner, PurchaseOrder, SalesOrder, ShopFloorEntry, StockBalance, StockMovement, TokenPair, Uom, WorkOrder, } from '@/types/api' const TOKEN_KEY = 'vibeerp.accessToken' export function getAccessToken(): string | null { return localStorage.getItem(TOKEN_KEY) } export function setAccessToken(token: string | null): void { if (token === null) { localStorage.removeItem(TOKEN_KEY) } else { localStorage.setItem(TOKEN_KEY, token) } } export class ApiError extends Error { constructor( message: string, public readonly status: number, public readonly body: unknown, ) { super(message) this.name = 'ApiError' } } // One-time-set callback that the auth context registers so the // client can trigger a logout on 401 without a circular import. let onUnauthorized: (() => void) | null = null export function registerUnauthorizedHandler(handler: () => void) { onUnauthorized = handler } async function apiFetch( path: string, init: RequestInit = {}, expectJson = true, ): Promise { const headers = new Headers(init.headers) headers.set('Accept', 'application/json') if (init.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } const token = getAccessToken() if (token) headers.set('Authorization', `Bearer ${token}`) const res = await fetch(path, { ...init, headers }) if (res.status === 401) { if (onUnauthorized) onUnauthorized() throw new ApiError('Not authenticated', 401, null) } if (!res.ok) { let body: unknown = null let message = `${res.status} ${res.statusText}` try { const text = await res.text() if (text) { try { body = JSON.parse(text) const m = (body as { message?: unknown }).message if (typeof m === 'string') message = m } catch { body = text message = text } } } catch { // ignore body parse errors } throw new ApiError(message, res.status, body) } if (!expectJson || res.status === 204) { return undefined as T } return (await res.json()) as T } // ─── Public unauthenticated calls ──────────────────────────────────── export const meta = { info: () => apiFetch('/api/v1/_meta/info'), } export const auth = { login: (username: string, password: string) => apiFetch('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }), } // ─── Catalog ───────────────────────────────────────────────────────── export const catalog = { listItems: () => apiFetch('/api/v1/catalog/items'), getItem: (id: string) => apiFetch(`/api/v1/catalog/items/${id}`), createItem: (body: { code: string; name: string; description?: string | null; itemType: string; baseUomCode: string; active?: boolean }) => apiFetch('/api/v1/catalog/items', { method: 'POST', body: JSON.stringify(body) }), listUoms: () => apiFetch('/api/v1/catalog/uoms'), } // ─── Partners ──────────────────────────────────────────────────────── export const partners = { list: () => apiFetch('/api/v1/partners/partners'), get: (id: string) => apiFetch(`/api/v1/partners/partners/${id}`), create: (body: { code: string; name: string; type: string; email?: string | null; phone?: string | null; taxId?: string | null; website?: string | null }) => apiFetch('/api/v1/partners/partners', { method: 'POST', body: JSON.stringify(body) }), } // ─── Inventory ─────────────────────────────────────────────────────── export const inventory = { listLocations: () => apiFetch('/api/v1/inventory/locations'), listBalances: () => apiFetch('/api/v1/inventory/balances'), listMovements: () => apiFetch('/api/v1/inventory/movements'), } // ─── Sales orders ──────────────────────────────────────────────────── export const salesOrders = { list: () => apiFetch('/api/v1/orders/sales-orders'), get: (id: string) => apiFetch(`/api/v1/orders/sales-orders/${id}`), create: (body: { code: string partnerCode: string orderDate: string currencyCode: string lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[] }) => apiFetch('/api/v1/orders/sales-orders', { method: 'POST', body: JSON.stringify(body), }), confirm: (id: string) => apiFetch(`/api/v1/orders/sales-orders/${id}/confirm`, { method: 'POST', }), cancel: (id: string) => apiFetch(`/api/v1/orders/sales-orders/${id}/cancel`, { method: 'POST', }), ship: (id: string, shippingLocationCode: string) => apiFetch(`/api/v1/orders/sales-orders/${id}/ship`, { method: 'POST', body: JSON.stringify({ shippingLocationCode }), }), } // ─── Purchase orders ───────────────────────────────────────────────── export const purchaseOrders = { list: () => apiFetch('/api/v1/orders/purchase-orders'), get: (id: string) => apiFetch(`/api/v1/orders/purchase-orders/${id}`), create: (body: { code: string; partnerCode: string; orderDate: string; expectedDate?: string | null; currencyCode: string; lines: { lineNo: number; itemCode: string; quantity: number; unitPrice: number; currencyCode: string }[] }) => apiFetch('/api/v1/orders/purchase-orders', { method: 'POST', body: JSON.stringify(body) }), confirm: (id: string) => apiFetch(`/api/v1/orders/purchase-orders/${id}/confirm`, { method: 'POST', }), cancel: (id: string) => apiFetch(`/api/v1/orders/purchase-orders/${id}/cancel`, { method: 'POST', }), receive: (id: string, receivingLocationCode: string) => apiFetch(`/api/v1/orders/purchase-orders/${id}/receive`, { method: 'POST', body: JSON.stringify({ receivingLocationCode }), }), } // ─── Production ────────────────────────────────────────────────────── export const production = { listWorkOrders: () => apiFetch('/api/v1/production/work-orders'), getWorkOrder: (id: string) => apiFetch(`/api/v1/production/work-orders/${id}`), createWorkOrder: (body: { code: string; outputItemCode: string; outputQuantity: number; dueDate?: string | null; sourceSalesOrderCode?: string | null; inputs?: { lineNo: number; itemCode: string; quantityPerUnit: number; sourceLocationCode: string }[]; operations?: { lineNo: number; operationCode: string; workCenter: string; standardMinutes: number }[]; }) => apiFetch('/api/v1/production/work-orders', { method: 'POST', body: JSON.stringify(body) }), startWorkOrder: (id: string) => apiFetch(`/api/v1/production/work-orders/${id}/start`, { method: 'POST', }), completeWorkOrder: (id: string, outputLocationCode: string) => apiFetch(`/api/v1/production/work-orders/${id}/complete`, { method: 'POST', body: JSON.stringify({ outputLocationCode }), }), cancelWorkOrder: (id: string) => apiFetch(`/api/v1/production/work-orders/${id}/cancel`, { method: 'POST', }), shopFloor: () => apiFetch('/api/v1/production/work-orders/shop-floor'), } // ─── Finance ───────────────────────────────────────────────────────── export const finance = { listJournalEntries: () => apiFetch('/api/v1/finance/journal-entries'), }