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/jsononly when a request body exists. - Serialize request bodies as JSON objects without
TYPE,ACTION, orUSERNAME. - Parse API v2 success envelopes as
{ data, requestId }. - Parse API v2 error envelopes as
{ error: { code, message, details? }, requestId }. - Throw
ApiErrorfor non-2xx responses and includestatus,code,message,requestId, anddetails. - Surface a visible error message from
ApiError.message; do not swallow request failures. - Preserve the server
requestIdso 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 legacyResult/Value. - Keep
public/assets/vendor/kj/kj.jsas 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.mdinstead 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.