Skip to content

React API Client Conditional Proposal

  • Status: CONDITIONAL PROPOSAL / REACT TBD
  • Owns: retained React-specific client design input if P12 later selects React
  • Does not own: current API v2 contract, frontend architecture, React adoption, Phase/Task status or implementation instruction
  • Current API owner: .agents/rules/API_CONTRACTS.md
  • Activation: P7-006, Product Stability Gate and explicit user approval through P12

Purpose

The React scaffold does not exist in this repository. The layout and patterns below are retained design input only and must be re-reviewed if React is activated.

The client must expose domain operations, not generic TYPE / ACTION dispatch.

Future File Layout

When the React scaffold is added, create the API client under:

frontend/react/src/api/
  client.ts                 composed domain client export
  http.ts                   base fetch helper and envelope parsing
  errors.ts                 ApiError and visible error helpers
  types.ts                  shared API envelope/request id types
  shared/
    files.ts
  addons/
    reports.ts
  platform/
    account.ts
    menu.ts
    users.ts
    admin.ts
  yms/
    containerTracking.ts
    yardMap.ts
    yardMoves.ts
  tms/
    transloads.ts
  wms/
    inventory.ts
  oms/
    outboundOrders.ts

If the React app uses a different build tool or root directory, keep this module shape under that app's src/api/ directory and update this file in the same task.

Base HTTP Helper

http.ts owns all direct fetch() calls. Domain clients must call this helper instead of calling fetch() directly.

Required behavior:

  • Use credentials: "same-origin" so existing PHP session auth applies.
  • Send Accept: application/json.
  • Send Content-Type: application/json only when a request body exists.
  • Serialize request bodies as JSON objects without TYPE, ACTION, or USERNAME.
  • Parse API v2 success envelopes as { data, requestId }.
  • Parse API v2 error envelopes as { error: { code, message, details? }, requestId }.
  • Throw ApiError for non-2xx responses and include status, code, message, requestId, and details.
  • Surface a visible error message from ApiError.message; do not swallow request failures.
  • Preserve the server requestId so UI error states and support logs can be correlated.

Reference shape:

export type ApiSuccess<T> = {
  data: T;
  requestId: string;
};

export type ApiErrorEnvelope = {
  error: {
    code: string;
    message: string;
    details?: unknown;
  };
  requestId: string;
};

export class ApiError extends Error {
  status: number;
  code: string;
  requestId: string;
  details?: unknown;
}

export async function apiRequest<T>(
  path: `/api/${string}`,
  options?: {
    method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
    query?: Record<string, string | number | boolean | null | undefined>;
    body?: Record<string, unknown>;
  },
): Promise<ApiSuccess<T>>;

Domain Client Pattern

client.ts composes domain modules into a named API object:

import { yardMap } from "./yms/yardMap";
import { yardMoves } from "./yms/yardMoves";
import { containerTracking } from "./yms/containerTracking";
import { transloads } from "./tms/transloads";
import { inventory } from "./wms/inventory";
import { outboundOrders } from "./oms/outboundOrders";
import { files } from "./shared/files";
import { reports } from "./addons/reports";
import { account, menu, users, admin } from "./platform";

export const api = {
  platform: {
    account,
    menu,
    users,
    admin,
  },
  yms: {
    containerTracking,
    yardMap,
    yardMoves,
  },
  tms: {
    transloads,
  },
  wms: {
    inventory,
  },
  oms: {
    outboundOrders,
  },
  shared: {
    files,
  },
  addons: {
    reports,
  },
};

Domain modules expose business operations:

export const yardMap = {
  getSections: () => apiRequest<YardMapSection[]>("/api/yms/yard-map/sections"),
  getState: () => apiRequest<YardMapState>("/api/yms/yard-map/state"),
};

export const account = {
  changePassword: (input: ChangePasswordInput) =>
    apiRequest<PasswordChangeResult>("/api/platform/account/password", { method: "PATCH", body: input }),
};

export const menu = {
  tree: () => apiRequest<MenuTree>("/api/platform/menu/tree"),
  updateTree: (input: MenuTreeUpdateInput) =>
    apiRequest<MenuWriteResult>("/api/platform/menu/tree", { method: "PATCH", body: input }),
  roles: () => apiRequest<MenuRole[]>("/api/platform/menu/roles"),
  pages: () => apiRequest<ModulePage[]>("/api/platform/module-pages"),
};

export const users = {
  get: (id: number) => apiRequest<UserRow>(`/api/platform/users/${id}`),
  create: (input: UserCreateInput) =>
    apiRequest<UserWriteResult>("/api/platform/users", { method: "POST", body: input }),
  update: (id: number, input: UserUpdateInput) =>
    apiRequest<UserWriteResult>(`/api/platform/users/${id}`, { method: "PATCH", body: input }),
  roles: () => apiRequest<UserRole[]>("/api/platform/users/roles"),
  checkUsername: (username: string) =>
    apiRequest<UsernameAvailability>("/api/platform/users/check-username", { query: { username } }),
};

export const admin = {
  tenants: () => apiRequest<TenantList>("/api/platform/admin/tenants"),
  tenantModules: (clientId: number) =>
    apiRequest<TenantModule[]>("/api/platform/admin/tenants/" + clientId + "/modules"),
  users: (query: AdminUserQuery) =>
    apiRequest<AdminUser[]>("/api/platform/admin/users", { query }),
  impersonate: (input: ImpersonationInput) =>
    apiRequest<ImpersonationResult>("/api/platform/admin/impersonations", { method: "POST", body: input }),
};

export const yardMoves = {
  getDispatchQueue: () => apiRequest<YardDispatchQueue>("/api/yms/yard-moves/dispatch-queue"),
  preview: (input: YardMovePreviewInput) =>
    apiRequest<YardMovePreview>("/api/yms/yard-moves/preview", { method: "POST", body: input }),
  moveLocation: (locationId: number, input: YardLocationMoveInput) =>
    apiRequest<YardLocationMoveResult>(`/api/yms/yard-locations/${locationId}/move`, { method: "POST", body: input }),
};

export const containerTracking = {
  updateReturnBookings: (input: ReturnBookingUpdateInput) =>
    apiRequest<ReturnBookingUpdateResult>("/api/yms/containers/return-bookings", { method: "PATCH", body: input }),
};

export const transloads = {
  get: (id: number) => apiRequest<TransloadRow>(`/api/tms/transloads/${id}`),
  listByIds: (ids: number[]) =>
    apiRequest<TransloadRow[]>("/api/tms/transloads", { query: { ids: ids.join(",") } }),
  linkScaleTicket: (id: number, input: TransloadScaleTicketLinkInput) =>
    apiRequest<TransloadResult>(`/api/tms/transloads/${id}/scale-ticket`, { method: "PATCH", body: input }),
  checkIn: (id: number, input: TransloadCheckInInput) =>
    apiRequest<TransloadResult>(`/api/tms/transloads/${id}/check-in`, { method: "POST", body: input }),
  getLoadingPlanner: (id: number) =>
    apiRequest<LoadingPlannerRow>(`/api/tms/loading-planner/${id}`),
  createLoadingPlanner: (input: LoadingPlannerInput) =>
    apiRequest<LoadingPlannerResult>("/api/tms/loading-planner", { method: "POST", body: input }),
  updateLoadingPlanner: (id: number, input: Partial<LoadingPlannerInput>) =>
    apiRequest<LoadingPlannerResult>(`/api/tms/loading-planner/${id}`, { method: "PATCH", body: input }),
  previewPlan: (input: TransloadPlanInput) =>
    apiRequest<TransloadPlanPreview>("/api/tms/transload-plans/preview", { method: "POST", body: input }),
  applyPlan: (input: TransloadPlanApplyInput) =>
    apiRequest<TransloadPlanResult>("/api/tms/transload-plans/apply", { method: "POST", body: input }),
};

export const deliveries = {
  get: (id: number) => apiRequest<DeliveryRow>(`/api/tms/deliveries/${id}`),
  listByIds: (ids: number[]) =>
    apiRequest<DeliveryRow[]>("/api/tms/deliveries", { query: { ids: ids.join(",") } }),
};

export const inventory = {
  listLots: (query: InventoryLotQuery) =>
    apiRequest<InventoryLotList>("/api/wms/inventory/lots", { query }),
  getLot: (id: number) => apiRequest<InventoryLot>(`/api/wms/inventory/lots/${id}`),
};

export const files = {
  list: (query: FileListQuery) =>
    apiRequest<FileMetadataList>("/api/files", { query }),
  register: (input: FileRegisterInput) =>
    apiRequest<FileRegisterResult>("/api/files", { method: "POST", body: input }),
  registerTransactionDocuments: (input: TransactionDocumentFileInput) =>
    apiRequest<FileRegisterResult>("/api/files/transaction-documents", { method: "POST", body: input }),
  update: (id: number, input: FileUpdateInput) =>
    apiRequest<FileWriteResult>(`/api/files/${id}`, { method: "PATCH", body: input }),
  delete: (id: number) =>
    apiRequest<FileWriteResult>(`/api/files/${id}`, { method: "DELETE" }),
};

export const reports = {
  monthlyInventory: (query: MonthlyInventoryReportQuery) =>
    apiRequest<MonthlyInventoryReport>("/api/reports/inventory/monthly", { query }),
  accumulativeInventory: (query: AccumulativeInventoryReportQuery) =>
    apiRequest<AccumulativeInventoryReport>("/api/reports/inventory/accumulative", { query }),
  mkDailyCommodity: (query: MkDailyCommodityReportQuery) =>
    apiRequest<MkDailyCommodityReport>("/api/reports/mk/daily-commodity", { query }),
  accountTrucking: (query: AccountTruckingReportQuery) =>
    apiRequest<AccountTruckingReport>("/api/bms/reports/account-trucking", { query }),
  fbol: (query: FbolReportQuery) =>
    apiRequest<FbolReport>("/api/reports/fbol", { query }),
  aiVerification: (deliveryId: number) =>
    apiRequest<AiVerificationReport>(`/api/addons/ai-verifications/${deliveryId}`),
};

export const outboundOrders = {
  options: () =>
    apiRequest<OutboundOrderOptions>("/api/oms/outbound-orders/options"),
  list: (query: OutboundOrderQuery) =>
    apiRequest<OutboundOrderList>("/api/oms/outbound-orders", { query }),
  schedule: (query: OutboundOrderScheduleQuery) =>
    apiRequest<OutboundOrderSchedule>("/api/oms/outbound-orders/schedule", { query }),
  detail: (id: number) =>
    apiRequest<OutboundOrder>(`/api/oms/outbound-orders/${id}`),
  create: (input: OutboundOrderInput) =>
    apiRequest<OutboundOrder>("/api/oms/outbound-orders", { method: "POST", body: input }),
  update: (id: number, input: OutboundOrderInput) =>
    apiRequest<OutboundOrder>(`/api/oms/outbound-orders/${id}`, { method: "PATCH", body: input }),
  addItem: (id: number, input: OutboundOrderItemInput) =>
    apiRequest<OutboundOrderItem>(`/api/oms/outbound-orders/${id}/items`, { method: "POST", body: input }),
  updateItem: (id: number, itemId: number, input: OutboundOrderItemInput) =>
    apiRequest<OutboundOrderItem>(`/api/oms/outbound-orders/${id}/items/${itemId}`, { method: "PATCH", body: input }),
  deleteItem: (id: number, itemId: number) =>
    apiRequest<OutboundOrderItem>(`/api/oms/outbound-orders/${id}/items/${itemId}`, { method: "DELETE" }),
  costs: (id: number) =>
    apiRequest<OutboundOrderCosts>(`/api/oms/outbound-orders/${id}/costs`),
  upsertCosts: (id: number, input: OutboundOrderCostInput) =>
    apiRequest<OutboundOrderCosts>(`/api/oms/outbound-orders/${id}/costs`, { method: "POST", body: input }),
};

export const inboundOrders = {
  options: () =>
    apiRequest<InboundOrderOptions>("/api/oms/inbound-orders/options"),
  list: (query: InboundOrderQuery) =>
    apiRequest<InboundOrderList>("/api/oms/inbound-orders", { query }),
  schedule: (query: InboundOrderScheduleQuery) =>
    apiRequest<InboundOrderSchedule>("/api/oms/inbound-orders/schedule", { query }),
  detail: (id: number) =>
    apiRequest<InboundOrder>(`/api/oms/inbound-orders/${id}`),
  create: (input: InboundOrderInput) =>
    apiRequest<InboundOrder>("/api/oms/inbound-orders", { method: "POST", body: input }),
  update: (id: number, input: InboundOrderInput) =>
    apiRequest<InboundOrder>(`/api/oms/inbound-orders/${id}`, { method: "PATCH", body: input }),
};

export const customerPos = {
  get: (id: number) => apiRequest<CustomerPoRow>(`/api/oms/customer-pos/${id}`),
  listItems: (id: number) => apiRequest<CustomerPoItemRow[]>(`/api/oms/customer-pos/${id}/items`),
  byLoadNumbers: (input: CustomerPoLoadNumberInput[]) =>
    apiRequest<CustomerPoLoadNumberResult[]>("/api/oms/customer-pos/by-load-numbers", { method: "POST", body: input }),
};

export const commercialOrders = {
  options: () =>
    apiRequest<CommercialOrderOptions>("/api/oms/commercial-orders/options"),
  list: (query: CommercialOrderQuery) =>
    apiRequest<CommercialOrderList>("/api/oms/commercial-orders", { query }),
  detail: (id: number) =>
    apiRequest<CommercialOrder>(`/api/oms/commercial-orders/${id}`),
  create: (input: CommercialOrderInput) =>
    apiRequest<CommercialOrder>("/api/oms/commercial-orders", { method: "POST", body: input }),
  update: (id: number, input: CommercialOrderInput) =>
    apiRequest<CommercialOrder>(`/api/oms/commercial-orders/${id}`, { method: "PATCH", body: input }),
  delete: (id: number) =>
    apiRequest<CommercialOrder>(`/api/oms/commercial-orders/${id}`, { method: "DELETE" }),
};

export const allocations = {
  workbench: (query: AllocationWorkbenchQuery) =>
    apiRequest<AllocationWorkbench>("/api/oms/allocations/workbench", { query }),
  targetShipments: (query: AllocationTargetShipmentQuery) =>
    apiRequest<AllocationTargetShipments>("/api/oms/allocations/target-shipments", { query }),
  apply: (input: AllocationApplyInput) =>
    apiRequest<AllocationApplyResult>("/api/oms/allocations/apply", { method: "POST", body: input }),
};

export const tradeSummary = {
  list: (query: TradeSummaryQuery) =>
    apiRequest<TradeSummaryList>("/api/oms/trade-summary", { query }),
  detail: (equipmentId: number) =>
    apiRequest<TradeSummaryDetail>(`/api/oms/trade-summary/${equipmentId}`),
};

export const inventory = {
  listLots: (query: InventoryLotQuery) =>
    apiRequest<InventoryLotList>("/api/wms/inventory/lots", { query }),
  lotDetail: (id: number) =>
    apiRequest<InventoryLotDetail>(`/api/wms/inventory/lots/${id}`),
  updateLot: (id: number, input: InventoryLotUpdateInput) =>
    apiRequest<InventoryLotDetail>(`/api/wms/inventory/lots/${id}`, { method: "PATCH", body: input }),
  adjustLot: (id: number, input: InventoryLotAdjustInput) =>
    apiRequest<InventoryLotAdjustResult>(`/api/wms/inventory/lots/${id}/adjust`, { method: "POST", body: input }),
  report: (query: InventoryReportQuery) =>
    apiRequest<InventoryReport>("/api/wms/inventory/report", { query }),
  storageByCompany: (companyId: number) =>
    apiRequest<StorageByCompanyReport>("/api/wms/storage/by-company", { query: { COMPANY_ID: companyId } }),
};

export const bmsRates = {
  commodityOptions: () =>
    apiRequest<BmsCommodityRateOptions>("/api/bms/rates/commodities/options"),
  commodities: (query: BmsCommodityRateQuery) =>
    apiRequest<BmsCommodityRateList>("/api/bms/rates/commodities", { query }),
  createCommodity: (input: BmsCommodityRateInput) =>
    apiRequest<BmsWriteResult>("/api/bms/rates/commodities", { method: "POST", body: input }),
  updateCommodity: (id: number, input: BmsCommodityRateInput) =>
    apiRequest<BmsWriteResult>(`/api/bms/rates/commodities/${id}`, { method: "PATCH", body: input }),
  deleteCommodity: (id: number) =>
    apiRequest<BmsWriteResult>(`/api/bms/rates/commodities/${id}`, { method: "DELETE" }),
  truckingOptions: () =>
    apiRequest<BmsTruckingRateOptions>("/api/bms/rates/trucking/options"),
  trucking: (query: BmsTruckingRateQuery) =>
    apiRequest<BmsTruckingRateList>("/api/bms/rates/trucking", { query }),
  truckingLookup: (query: BmsTruckingRateLookupQuery) =>
    apiRequest<BmsTruckingRateLookup>("/api/bms/rates/trucking/lookup", { query }),
  chassisByEquipmentLoad: (query: BmsChassisByEquipmentLoadQuery) =>
    apiRequest<BmsChassisRate>("/api/bms/rates/chassis/by-equipment-load", { query }),
  chassisByCompany: (companyId: number) =>
    apiRequest<BmsChassisRate>("/api/bms/rates/chassis/by-company", { query: { COMPANY_ID: companyId } }),
};

export const bmsScaleTickets = {
  create: (input: BmsScaleTicketInput) =>
    apiRequest<BmsWriteResult>("/api/bms/scale-tickets", { method: "POST", body: input }),
  get: (id: number) => apiRequest<BmsScaleTicket>(`/api/bms/scale-tickets/${id}`),
  update: (id: number, input: BmsScaleTicketInput) =>
    apiRequest<BmsWriteResult>(`/api/bms/scale-tickets/${id}`, { method: "PATCH", body: input }),
  delete: (id: number) =>
    apiRequest<BmsWriteResult>(`/api/bms/scale-tickets/${id}`, { method: "DELETE" }),
  createClosing: (input: BmsClosingScaleTicketInput) =>
    apiRequest<BmsWriteResult>("/api/bms/scale-tickets/closing", { method: "POST", body: input }),
  applyOutboundWeight: (input: BmsOutboundWeightApplyInput) =>
    apiRequest<BmsWriteResult>("/api/bms/scale-tickets/outbound-weight-apply", { method: "POST", body: input }),
};

export const bmsTransactions = {
  detail: (id: number) =>
    apiRequest<BmsTransactionDetail>(`/api/bms/transactions/${id}`),
  billingAddress: (companyId: number) =>
    apiRequest<BmsBillingAddress>(`/api/bms/companies/${companyId}/billing-address`),
  createFromSource: (source: "trucking" | "transload" | "chassis" | "scale-ticket", input: BmsTransactionSourceInput) =>
    apiRequest<BmsWriteResult>(`/api/bms/transactions/${source}`, { method: "POST", body: input }),
  deleteFromSource: (source: "trucking" | "transload" | "chassis" | "scale-ticket", input: BmsTransactionSourceDeleteInput) =>
    apiRequest<BmsWriteResult>(`/api/bms/transactions/${source}`, { method: "DELETE", body: input }),
  deleteTransactions: (input: BmsTransactionDeleteInput) =>
    apiRequest<BmsWriteResult>("/api/bms/transactions", { method: "DELETE", body: input }),
  close: (input: BmsTransactionCloseInput) =>
    apiRequest<BmsWriteResult>("/api/bms/transactions/close", { method: "PATCH", body: input }),
  chassisBulkInput: (input: BmsChassisBulkInput) =>
    apiRequest<BmsWriteResult>("/api/bms/transactions/chassis/bulk-input", { method: "POST", body: input }),
  applyFinalTransload: (input: BmsFinalTransloadInput) =>
    apiRequest<BmsWriteResult>("/api/bms/transactions/apply-final-transload", { method: "POST", body: input }),
};

Prohibited Pattern

Do not recreate legacy dispatch under new names:

// Forbidden
api.post("TRANSLOAD", "CHECKIN", data, username);
api.call({ type: "YARD_MAP", action: "STATE", data });
api.query(type, action, data);

The caller chooses a domain operation. The domain client chooses the route.

UI Error Contract

React screens must handle ApiError explicitly:

try {
  const response = await api.tms.transloads.checkIn(id, input);
  return response.data;
} catch (error) {
  if (error instanceof ApiError) {
    showError(`${error.message} (${error.requestId})`);
    return;
  }
  showError("Unexpected request failure.");
}

Visible feedback is required for failed writes and blocking reads. A screen may log requestId, but it must not hide the user-facing error message.

Migration Rules

  • Each API v2 domain migration task adds the server route and the matching domain client operation together.
  • Domain clients may wrap response data, but they must not convert back to legacy Result / Value.
  • Keep public/assets/vendor/kj/kj.js as legacy-only. Do not add API v2 calls there.
  • Do not add KJ.api.v2(type, action, data) or equivalent generic helpers.
  • If a route is deferred, mark the missing operation in .agents/rules/API_V2_CONTRACT_INVENTORY.md instead of creating a generic escape hatch.

---

Source: .agents/rules/REACT_API_CLIENT.md — ctc repository · This page is an English translation of the source document. Identifiers, file paths and code symbols are unchanged.