client.ts 12 KB
// 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
// <token>` 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 {
  Account,
  Item,
  JournalEntry,
  Location,
  MetaInfo,
  Partner,
  PurchaseOrder,
  Role,
  SalesOrder,
  ShopFloorEntry,
  StockBalance,
  StockMovement,
  TokenPair,
  Uom,
  User,
  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<T>(
  path: string,
  init: RequestInit = {},
  expectJson = true,
): Promise<T> {
  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<MetaInfo>('/api/v1/_meta/info'),
}

export const auth = {
  login: (username: string, password: string) =>
    apiFetch<TokenPair>('/api/v1/auth/login', {
      method: 'POST',
      body: JSON.stringify({ username, password }),
    }),
}

// ─── Identity ────────────────────────────────────────────────────────

export const identity = {
  listUsers: () => apiFetch<User[]>('/api/v1/identity/users'),
  getUser: (id: string) => apiFetch<User>(`/api/v1/identity/users/${id}`),
  createUser: (body: {
    username: string; displayName: string; email?: string | null
  }) => apiFetch<User>('/api/v1/identity/users', { method: 'POST', body: JSON.stringify(body) }),
  listRoles: () => apiFetch<Role[]>('/api/v1/identity/roles'),
  createRole: (body: {
    code: string; name: string; description?: string | null
  }) => apiFetch<Role>('/api/v1/identity/roles', { method: 'POST', body: JSON.stringify(body) }),
  getUserRoles: (userId: string) => apiFetch<string[]>(`/api/v1/identity/users/${userId}/roles`),
  assignRole: (userId: string, roleCode: string) =>
    apiFetch<void>(`/api/v1/identity/users/${userId}/roles/${roleCode}`, { method: 'POST' }, false),
  revokeRole: (userId: string, roleCode: string) =>
    apiFetch<void>(`/api/v1/identity/users/${userId}/roles/${roleCode}`, { method: 'DELETE' }, false),
}

// ─── Catalog ─────────────────────────────────────────────────────────

export const catalog = {
  listItems: () => apiFetch<Item[]>('/api/v1/catalog/items'),
  getItem: (id: string) => apiFetch<Item>(`/api/v1/catalog/items/${id}`),
  createItem: (body: {
    code: string; name: string; description?: string | null;
    itemType: string; baseUomCode: string; active?: boolean
  }) => apiFetch<Item>('/api/v1/catalog/items', { method: 'POST', body: JSON.stringify(body) }),
  updateItem: (id: string, body: {
    name?: string; description?: string | null; itemType?: string; active?: boolean
  }) => apiFetch<Item>(`/api/v1/catalog/items/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
  listUoms: () => apiFetch<Uom[]>('/api/v1/catalog/uoms'),
}

// ─── Partners ────────────────────────────────────────────────────────

export const partners = {
  list: () => apiFetch<Partner[]>('/api/v1/partners/partners'),
  get: (id: string) => apiFetch<Partner>(`/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<Partner>('/api/v1/partners/partners', { method: 'POST', body: JSON.stringify(body) }),
  update: (id: string, body: {
    name?: string; type?: string;
    email?: string | null; phone?: string | null
  }) => apiFetch<Partner>(`/api/v1/partners/partners/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
}

// ─── Inventory ───────────────────────────────────────────────────────

export const inventory = {
  listLocations: () => apiFetch<Location[]>('/api/v1/inventory/locations'),
  createLocation: (body: { code: string; name: string; type: string }) =>
    apiFetch<Location>('/api/v1/inventory/locations', { method: 'POST', body: JSON.stringify(body) }),
  listBalances: () => apiFetch<StockBalance[]>('/api/v1/inventory/balances'),
  adjustBalance: (body: { itemCode: string; locationId: string; quantity: number }) =>
    apiFetch<StockBalance>('/api/v1/inventory/balances/adjust', { method: 'POST', body: JSON.stringify(body) }),
  listMovements: () => apiFetch<StockMovement[]>('/api/v1/inventory/movements'),
}

// ─── Sales orders ────────────────────────────────────────────────────

export const salesOrders = {
  list: () => apiFetch<SalesOrder[]>('/api/v1/orders/sales-orders'),
  get: (id: string) => apiFetch<SalesOrder>(`/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<SalesOrder>('/api/v1/orders/sales-orders', {
      method: 'POST',
      body: JSON.stringify(body),
    }),
  confirm: (id: string) =>
    apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/confirm`, {
      method: 'POST',
    }),
  cancel: (id: string) =>
    apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/cancel`, {
      method: 'POST',
    }),
  ship: (id: string, shippingLocationCode: string) =>
    apiFetch<SalesOrder>(`/api/v1/orders/sales-orders/${id}/ship`, {
      method: 'POST',
      body: JSON.stringify({ shippingLocationCode }),
    }),
}

// ─── Purchase orders ─────────────────────────────────────────────────

export const purchaseOrders = {
  list: () => apiFetch<PurchaseOrder[]>('/api/v1/orders/purchase-orders'),
  get: (id: string) => apiFetch<PurchaseOrder>(`/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<PurchaseOrder>('/api/v1/orders/purchase-orders', { method: 'POST', body: JSON.stringify(body) }),
  confirm: (id: string) =>
    apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/confirm`, {
      method: 'POST',
    }),
  cancel: (id: string) =>
    apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/cancel`, {
      method: 'POST',
    }),
  receive: (id: string, receivingLocationCode: string) =>
    apiFetch<PurchaseOrder>(`/api/v1/orders/purchase-orders/${id}/receive`, {
      method: 'POST',
      body: JSON.stringify({ receivingLocationCode }),
    }),
}

// ─── Production ──────────────────────────────────────────────────────

export const production = {
  listWorkOrders: () => apiFetch<WorkOrder[]>('/api/v1/production/work-orders'),
  getWorkOrder: (id: string) =>
    apiFetch<WorkOrder>(`/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<WorkOrder>('/api/v1/production/work-orders', { method: 'POST', body: JSON.stringify(body) }),
  startWorkOrder: (id: string) =>
    apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/start`, {
      method: 'POST',
    }),
  completeWorkOrder: (id: string, outputLocationCode: string) =>
    apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/complete`, {
      method: 'POST',
      body: JSON.stringify({ outputLocationCode }),
    }),
  cancelWorkOrder: (id: string) =>
    apiFetch<WorkOrder>(`/api/v1/production/work-orders/${id}/cancel`, {
      method: 'POST',
    }),
  shopFloor: () =>
    apiFetch<ShopFloorEntry[]>('/api/v1/production/work-orders/shop-floor'),
}

// ─── Finance ─────────────────────────────────────────────────────────

export const finance = {
  listAccounts: () => apiFetch<Account[]>('/api/v1/finance/accounts'),
  createAccount: (body: {
    code: string; name: string; accountType: string; description?: string | null
  }) => apiFetch<Account>('/api/v1/finance/accounts', { method: 'POST', body: JSON.stringify(body) }),
  listJournalEntries: () =>
    apiFetch<JournalEntry[]>('/api/v1/finance/journal-entries'),
}