client.ts 15.8 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,
  CustomFieldDef,
  FormDefinition,
  Item,
  JournalEntry,
  ListViewDefinition,
  Location,
  MetadataEntity,
  MetadataPermission,
  MetaInfo,
  Partner,
  PurchaseOrder,
  Role,
  RuleDefinition,
  SalesOrder,
  ShopFloorEntry,
  StockBalance,
  StockMovement,
  TokenPair,
  Uom,
  User,
  UserTaskDetail,
  UserTaskSummary,
  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
}

export 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)
          // Spring ProblemDetail uses "detail"; fallback to "message"
          const parsed = body as { detail?: unknown; message?: unknown }
          const d = parsed.detail
          const m = parsed.message
          if (typeof d === 'string') message = d
          else 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;
    ext?: Record<string, unknown>
  }) => 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;
    ext?: Record<string, unknown>
  }) => 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'),
}

// ─── Workflow ────────────────────────────────────────────────────────

export const workflow = {
  listTasks: () => apiFetch<UserTaskSummary[]>('/api/v1/workflow/tasks'),
  getTask: (taskId: string) => apiFetch<UserTaskDetail>(`/api/v1/workflow/tasks/${taskId}`),
  completeTask: (taskId: string, variables: Record<string, unknown>) =>
    apiFetch<void>(`/api/v1/workflow/tasks/${taskId}/complete`, { method: 'POST', body: JSON.stringify(variables) }, false),
}

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

// ─── Metadata admin ─────────────────────────────────────────────────

export const metadata = {
  entities: () => apiFetch<MetadataEntity[]>('/api/v1/_meta/metadata/entities'),
  permissions: () => apiFetch<MetadataPermission[]>('/api/v1/_meta/metadata/permissions'),
  menus: () => apiFetch<any[]>('/api/v1/_meta/metadata/menus'),
  customFields: () => apiFetch<CustomFieldDef[]>('/api/v1/_meta/metadata/custom-fields'),
  customFieldsFor: (entity: string) => apiFetch<CustomFieldDef[]>(`/api/v1/_meta/metadata/custom-fields/${entity}`),
  listForms: () => apiFetch<FormDefinition[]>('/api/v1/_meta/metadata/forms'),
  getForm: (slug: string) => apiFetch<FormDefinition>(`/api/v1/_meta/metadata/forms/${slug}`),
  saveForm: (slug: string, body: Omit<FormDefinition, 'source'>) =>
    apiFetch<FormDefinition>(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
  deleteForm: (slug: string) =>
    apiFetch<void>(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'DELETE' }, false),
  listListViews: () => apiFetch<ListViewDefinition[]>('/api/v1/_meta/metadata/list-views'),
  getListView: (slug: string) => apiFetch<ListViewDefinition>(`/api/v1/_meta/metadata/list-views/${slug}`),
  saveListView: (slug: string, body: Omit<ListViewDefinition, 'source'>) =>
    apiFetch<ListViewDefinition>(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
  deleteListView: (slug: string) =>
    apiFetch<void>(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'DELETE' }, false),
  createCustomField: (body: Omit<CustomFieldDef, 'source'>) =>
    apiFetch<CustomFieldDef>('/api/v1/_meta/metadata/custom-fields', { method: 'POST', body: JSON.stringify(body) }),
  updateCustomField: (key: string, body: Omit<CustomFieldDef, 'source'>) =>
    apiFetch<CustomFieldDef>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }),
  deleteCustomField: (key: string) =>
    apiFetch<void>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'DELETE' }, false),
  listRules: () => apiFetch<RuleDefinition[]>('/api/v1/_meta/metadata/rules'),
  getRule: (slug: string) => apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`),
  saveRule: (slug: string, body: Omit<RuleDefinition, 'source'>) =>
    apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
  deleteRule: (slug: string) =>
    apiFetch<void>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'DELETE' }, false),
}

// ─── 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'),
}