import { env } from '$env/dynamic/public'; import { browser } from '$app/environment'; import type { ClientAccessAccount, ClientAccessPowerBiExport, DashboardSummary, ClientUserCreateInput, ClientUserModulePermission, ClientUserUpdateInput, LoginResponse, EditorMixCreateInput, EditorMixUpdateInput, EditorMixRow, EditorMixFormula, EditorResolvedMixFormula, EditorMixFormulaRowInput, EditorIngredientRow, EditorIngredientCreateInput, EditorIngredientUpdateInput, EditorProductFormula, EditorProductRow, EditorProductUpdateInput, MixCalculatorCreateInput, MixCalculatorOptions, MixCalculatorPreview, MixCalculatorSession, MixCalculatorUpdateInput, Mix, MixCreateInput, MixIngredientUpdateInput, MixUpdateInput, Product, ProductCostBreakdown, ProductCostingInputs, ProductCostingItem, ProductCostingItemUpdateInput, RawMaterial, RawMaterialCreateInput, RawMaterialPriceCreateInput, CatalogueProduct, CustomerPricing, CustomerVisibilityRow, DraftOrderInput, Order, OrderingCustomer, OrderingCustomerUser, OrderingNotificationSettings, XeroStatus, XeroContactList, XeroContactLinkRow, Scenario, ThroughputEntry, ThroughputEntryCreateInput, ThroughputEntryUpdateInput, ThroughputEntryListParams, ThroughputImportResult, ThroughputProduct, ThroughputProductCreateInput, ThroughputProductUpdateInput } from '$lib/types'; import { getStoredAdminSession, getStoredClientSession } from '$lib/session'; const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.'; type AuthMode = 'none' | 'client' | 'admin' | 'manager'; type ApiFetch = typeof fetch; function getApiBaseUrl() { if (!browser) { const internalBaseUrl = typeof process !== 'undefined' ? process.env.INTERNAL_API_BASE_URL?.trim() : ''; if (internalBaseUrl) { return internalBaseUrl.replace(/\/+$/, ''); } } if (browser) { const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim(); if (configuredBaseUrl) { try { const configuredUrl = new URL(configuredBaseUrl, window.location.origin); // Keep browser API traffic same-origin by default. This avoids CORS, // CSP `connect-src`, and cookie policy failures when the backend is // reverse-proxied under `/api` on the same host. if (configuredUrl.origin === window.location.origin || configuredUrl.hostname === window.location.hostname) { return ''; } return configuredUrl.toString().replace(/\/+$/, ''); } catch { return ''; } } return ''; } const defaultApiPort = env.PUBLIC_API_PORT || '8000'; return `http://127.0.0.1:${defaultApiPort}`; } function buildApiUrl(path: string) { return `${getApiBaseUrl()}${path}`; } function getSessionFingerprint(auth: AuthMode) { if (auth === 'client') { const session = getStoredClientSession(); return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : ''; } if (auth === 'admin') { const session = getStoredAdminSession(); return session ? `${session.role}:${session.email}` : ''; } if (auth === 'manager') { const admin = getStoredAdminSession(); if (admin) { return `${admin.role}:${admin.email}`; } const client = getStoredClientSession(); return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : ''; } return ''; } function resolveRequestUrl(path: string, fetcher: ApiFetch) { if (fetcher !== fetch) { return path; } return buildApiUrl(path); } function normalizeRequestError(error: unknown) { if (error instanceof Error) { const message = error.message.trim(); const isNetworkFetchFailure = /failed to fetch|fetch failed|networkerror when attempting to fetch resource|load failed|network request failed/i.test( message ) || error.name === 'NetworkError'; if (isNetworkFetchFailure) { return new Error(BACKEND_UNAVAILABLE_MESSAGE); } return error; } return new Error('An unexpected error occurred while contacting the server.'); } async function fetchJson(path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise { try { const response = await fetcher(resolveRequestUrl(path, fetcher), { credentials: 'include' }); if (!response.ok) { throw new Error(response.statusText || 'Request failed'); } return (await response.json()) as T; } catch (error) { throw normalizeRequestError(error); } } // In-memory GET cache with TTL + in-flight de-duplication. The cache key // includes the auth-mode and last 8 chars of the bearer token so different // sessions can't read each other's entries. Any mutation calls clearApiCache() // to invalidate. Memory footprint is bounded by entries naturally aging out. type CacheEntry = { value: unknown; expiresAt: number }; const responseCache = new Map(); const inflightRequests = new Map>(); const READ_CACHE_TTL_MS = 30_000; function makeCacheKey(path: string, auth: AuthMode) { const sessionFingerprint = browser ? getSessionFingerprint(auth) : ''; return `${auth}:${sessionFingerprint}:${path}`; } async function cachedFetchJson( path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch ): Promise { // Bypass the cache during SSR (no localStorage, no shared session). if (!browser) { return fetchJson(path, auth, fetcher); } const key = makeCacheKey(path, auth); const now = Date.now(); const cached = responseCache.get(key); if (cached && cached.expiresAt > now) { return cached.value as T; } // De-duplicate concurrent callers (e.g. two effects firing the same load). const existing = inflightRequests.get(key); if (existing) { return existing as Promise; } const promise = fetchJson(path, auth, fetcher) .then((value) => { responseCache.set(key, { value, expiresAt: Date.now() + READ_CACHE_TTL_MS }); return value; }) .finally(() => { inflightRequests.delete(key); }); inflightRequests.set(key, promise); return promise; } export function clearApiCache() { responseCache.clear(); inflightRequests.clear(); } async function request( path: string, options: RequestInit, auth: AuthMode = 'none', fetcher: ApiFetch = fetch ): Promise { try { const response = await fetcher(resolveRequestUrl(path, fetcher), { headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) }, credentials: 'include', ...options }); if (!response.ok) { let message = 'Request failed'; try { const body = (await response.json()) as { detail?: string }; message = body.detail ?? message; } catch { message = response.statusText || message; } throw new Error(message); } const isMutation = !!options.method && options.method.toUpperCase() !== 'GET'; if (isMutation && browser) { // Mutations invalidate cached reads — keeps Dashboard / lists fresh // after the user creates or updates anything. clearApiCache(); } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } catch (error) { throw normalizeRequestError(error); } } // Multipart upload. Unlike `request`, we must NOT set Content-Type ourselves — // the browser sets `multipart/form-data` with the correct boundary when given a // FormData body. Mirrors `request`'s auth/cache/error handling otherwise. async function uploadFile( path: string, formData: FormData, auth: AuthMode = 'none', fetcher: ApiFetch = fetch ): Promise { try { const response = await fetcher(resolveRequestUrl(path, fetcher), { method: 'POST', body: formData, credentials: 'include' }); if (!response.ok) { let message = 'Request failed'; try { const body = (await response.json()) as { detail?: string }; message = body.detail ?? message; } catch { message = response.statusText || message; } throw new Error(message); } if (browser) { clearApiCache(); } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } catch (error) { throw normalizeRequestError(error); } } async function requestBlob( path: string, options: RequestInit = {}, auth: AuthMode = 'none', fetcher: ApiFetch = fetch ): Promise { try { const response = await fetcher(resolveRequestUrl(path, fetcher), { headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) }, credentials: 'include', ...options }); if (!response.ok) { let message = 'Request failed'; try { const body = (await response.json()) as { detail?: string }; message = body.detail ?? message; } catch { message = response.statusText || message; } throw new Error(message); } return await response.blob(); } catch (error) { throw normalizeRequestError(error); } } export const api = { rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson('/api/raw-materials', 'client', fetcher), mixes: (fetcher?: ApiFetch) => cachedFetchJson('/api/mixes', 'client', fetcher), mix: (mixId: number, fetcher?: ApiFetch) => request(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher), mixCalculatorOptions: (fetcher?: ApiFetch) => cachedFetchJson('/api/mix-calculator/options', 'client', fetcher), mixCalculatorSessions: (fetcher?: ApiFetch) => cachedFetchJson('/api/mix-calculator', 'client', fetcher), mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) => request(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher), mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) => requestBlob(`/api/mix-calculator/${sessionId}/pdf`, {}, 'client', fetcher), previewMixCalculatorSession: (payload: MixCalculatorCreateInput) => request('/api/mix-calculator/preview', { method: 'POST', body: JSON.stringify(payload) }, 'client'), previewMixCalculatorPdf: (payload: MixCalculatorCreateInput) => requestBlob('/api/mix-calculator/preview/pdf', { method: 'POST', body: JSON.stringify(payload) }, 'client'), createMixCalculatorSession: (payload: MixCalculatorCreateInput) => request('/api/mix-calculator', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateMixCalculatorSession: (sessionId: number, payload: MixCalculatorUpdateInput) => request(`/api/mix-calculator/${sessionId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), products: (fetcher?: ApiFetch) => cachedFetchJson('/api/products', 'client', fetcher), editorProducts: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => { const search = new URLSearchParams(); if (params?.q) search.set('q', params.q); if (params?.client_name) search.set('client_name', params.client_name); if (params?.limit) search.set('limit', String(params.limit)); const qs = search.toString(); const path = qs ? `/api/editor/products?${qs}` : '/api/editor/products'; return cachedFetchJson(path, 'client', fetcher); }, updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) => request(`/api/editor/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), editorMixes: (params?: { q?: string; client_name?: string; limit?: number }, fetcher?: ApiFetch) => { const search = new URLSearchParams(); if (params?.q) search.set('q', params.q); if (params?.client_name) search.set('client_name', params.client_name); if (params?.limit) search.set('limit', String(params.limit)); const qs = search.toString(); const path = qs ? `/api/editor/mixes?${qs}` : '/api/editor/mixes'; return cachedFetchJson(path, 'client', fetcher); }, createEditorMix: (payload: EditorMixCreateInput) => request('/api/editor/mixes', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) => request(`/api/editor/mixes/${mixId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), editorMixFormula: (mixId: number) => request(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'), // The resolved formula matching the Mix Calculator (product-first), used by // the Mix Editor ingredient panel. editorMixResolvedFormula: (mixId: number) => request(`/api/editor/mixes/${mixId}/formula`, {}, 'client'), replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) => request(`/api/editor/mixes/${mixId}/formula`, { method: 'PUT', body: JSON.stringify({ rows }) }, 'client'), addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => request(`/api/editor/mixes/${mixId}/ingredients`, { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) => request(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), deleteEditorMixIngredient: (mixId: number, ingredientId: number) => request(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, { method: 'DELETE' }, 'client'), editorProductFormula: (productId: number) => request(`/api/editor/products/${productId}/ingredients`, {}, 'client'), addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => request(`/api/editor/products/${productId}/ingredients`, { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateEditorProductIngredient: (productId: number, ingredientId: number, payload: MixIngredientUpdateInput) => request(`/api/editor/products/${productId}/ingredients/${ingredientId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), deleteEditorProductIngredient: (productId: number, ingredientId: number) => request(`/api/editor/products/${productId}/ingredients/${ingredientId}`, { method: 'DELETE' }, 'client'), editorIngredients: (fetcher?: ApiFetch) => cachedFetchJson('/api/editor/ingredients', 'client', fetcher), createEditorIngredient: (payload: EditorIngredientCreateInput) => request('/api/editor/ingredients', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) => request(`/api/editor/ingredients/${ingredientId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), productCosts: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/product-costs', 'client', fetcher), productCostingItems: (fetcher?: ApiFetch) => cachedFetchJson('/api/product-costing/items', 'client', fetcher), productCostingItemsFresh: () => request(`/api/product-costing/items?_=${Date.now()}`, { method: 'GET' }, 'client'), productCostingInputs: (fetcher?: ApiFetch) => cachedFetchJson('/api/product-costing/inputs', 'client', fetcher), updateProductCostingInputs: (payload: Partial) => request('/api/product-costing/inputs', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), updateProductCostingItem: (itemId: number, payload: ProductCostingItemUpdateInput) => request(`/api/product-costing/items/${itemId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), recalculateProductCosting: () => request<{ recalculated: number }>('/api/product-costing/recalculate-all', { method: 'POST' }, 'client'), scenarios: (fetcher?: ApiFetch) => cachedFetchJson('/api/scenarios', 'client', fetcher), throughputProducts: (fetcher?: ApiFetch) => cachedFetchJson('/api/throughput/products', 'client', fetcher), throughputEntries: (params?: ThroughputEntryListParams, fetcher?: ApiFetch) => { const search = new URLSearchParams(); if (params?.date_from) search.set('date_from', params.date_from); if (params?.date_to) search.set('date_to', params.date_to); if (params?.product_id != null) search.set('product_id', String(params.product_id)); if (params?.staff_name) search.set('staff_name', params.staff_name); if (params?.quantity_type) search.set('quantity_type', params.quantity_type); if (params?.limit) search.set('limit', String(params.limit)); const qs = search.toString(); const path = qs ? `/api/throughput/entries?${qs}` : '/api/throughput/entries'; return cachedFetchJson(path, 'client', fetcher); }, createThroughputEntry: (payload: ThroughputEntryCreateInput) => request('/api/throughput/entries', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) => request(`/api/throughput/entries/${entryId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), deleteThroughputEntry: (entryId: number) => request(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'), importThroughputEntries: (file: File) => { const formData = new FormData(); formData.append('file', file); return uploadFile('/api/throughput/import', formData, 'client'); }, createThroughputProduct: (payload: ThroughputProductCreateInput) => request('/api/throughput/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateThroughputProduct: (productId: number, payload: ThroughputProductUpdateInput) => request(`/api/throughput/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), clientAccess: (fetcher?: ApiFetch) => cachedFetchJson('/api/client-access', 'manager', fetcher), clientAccessExport: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/client-access', 'manager', fetcher), dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', 'client', fetcher), dashboardSummary: (fetcher?: ApiFetch) => cachedFetchJson('/api/dashboard/summary', 'client', fetcher), clientLogin: (email: string, password: string) => request('/api/auth/client/login', { method: 'POST', body: JSON.stringify({ email, password }) }), // Internal Hunter Stock Feeds login. Returns the same LoginResponse shape // (with `permissions` populated) so the existing client-session store can // consume it directly. internalLogin: (email: string, password: string) => request('/api/access/login', { method: 'POST', body: JSON.stringify({ email, password }) }), internalSession: (fetcher?: ApiFetch) => request('/api/access/me', { method: 'GET' }, 'client', fetcher), updateMe: (payload: { name?: string; email?: string; current_password?: string; new_password?: string }) => request('/api/access/me', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), adminLogin: (email: string, password: string) => request('/api/auth/admin/login', { method: 'POST', body: JSON.stringify({ email, password }) }), clientSession: (fetcher?: ApiFetch) => request('/api/auth/client/session', { method: 'GET' }, 'client', fetcher), adminSession: (fetcher?: ApiFetch) => request('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher), clientLogout: () => request('/api/auth/client/logout', { method: 'POST' }, 'client'), adminLogout: () => request('/api/auth/admin/logout', { method: 'POST' }, 'admin'), internalLogout: () => request('/api/access/logout', { method: 'POST' }, 'client'), login: (email: string, password: string) => request('/api/auth/client/login', { method: 'POST', body: JSON.stringify({ email, password }) }), createMix: (payload: MixCreateInput) => request('/api/mixes', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateMix: (mixId: number, payload: MixUpdateInput) => request(`/api/mixes/${mixId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), addMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => request(`/api/mixes/${mixId}/ingredients`, { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) => request(`/api/mixes/${mixId}/ingredients/${ingredientId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), deleteMixIngredient: (mixId: number, ingredientId: number) => request(`/api/mixes/${mixId}/ingredients/${ingredientId}`, { method: 'DELETE' }, 'client'), createRawMaterial: (payload: RawMaterialCreateInput) => request('/api/raw-materials', { method: 'POST', body: JSON.stringify(payload) }, 'client'), addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) => request(`/api/raw-materials/${rawMaterialId}/prices`, { method: 'POST', body: JSON.stringify(payload) }, 'client'), createClientUser: (payload: ClientUserCreateInput) => request('/api/client-access/users', { method: 'POST', body: JSON.stringify(payload) }, 'manager'), updateClientUser: (userId: number, payload: ClientUserUpdateInput) => request(`/api/client-access/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'manager'), updateClientUserModulePermission: (userId: number, permission: Pick, payload: { access_level: string }) => request(`/api/client-access/users/${userId}/module-permissions/${permission.module_key}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'manager'), updateClientFeature: (featureId: number, payload: { enabled: boolean }) => request(`/api/client-access/features/${featureId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'manager'), // --- B2B ordering portal (customer) --------------------------------------- ordering: { catalogue: (params?: { category?: string; q?: string }, fetcher?: ApiFetch) => { const search = new URLSearchParams(); if (params?.category) search.set('category', params.category); if (params?.q) search.set('q', params.q); const qs = search.toString(); return cachedFetchJson(`/api/ordering/catalogue${qs ? `?${qs}` : ''}`, 'client', fetcher); }, product: (productId: number, quantity = 1, fetcher?: ApiFetch) => request(`/api/ordering/catalogue/${productId}?quantity=${quantity}`, { method: 'GET' }, 'client', fetcher), orders: (statusFilter?: string, fetcher?: ApiFetch) => cachedFetchJson(`/api/ordering/orders${statusFilter ? `?status=${statusFilter}` : ''}`, 'client', fetcher), order: (orderId: number, fetcher?: ApiFetch) => request(`/api/ordering/orders/${orderId}`, { method: 'GET' }, 'client', fetcher), createDraft: (payload: DraftOrderInput) => request('/api/ordering/orders', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateDraft: (orderId: number, payload: Partial) => request(`/api/ordering/orders/${orderId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), deleteDraft: (orderId: number) => request(`/api/ordering/orders/${orderId}`, { method: 'DELETE' }, 'client'), submit: (orderId: number, payload: Partial = {}) => request(`/api/ordering/orders/${orderId}/submit`, { method: 'POST', body: JSON.stringify(payload) }, 'client'), reorder: (orderId: number) => request(`/api/ordering/orders/${orderId}/reorder`, { method: 'POST' }, 'client'), confirmationPdf: (orderId: number) => requestBlob(`/api/ordering/orders/${orderId}/confirmation.pdf`, {}, 'client') }, // --- B2B ordering portal (admin) ------------------------------------------ orderingAdmin: { customers: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/customers', 'client', fetcher), createCustomer: (payload: { name: string; client_code: string; tenant_id?: string; notes?: string }) => request('/api/ordering-admin/customers', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateCustomer: (customerId: number, payload: { name?: string; status?: string; notes?: string }) => request(`/api/ordering-admin/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), customerUsers: (customerId: number, fetcher?: ApiFetch) => cachedFetchJson(`/api/ordering-admin/customers/${customerId}/users`, 'client', fetcher), createCustomerUser: (customerId: number, payload: { full_name: string; email: string; role: string }) => request(`/api/ordering-admin/customers/${customerId}/users`, { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateCustomerUser: (customerId: number, userId: number, payload: { full_name?: string; role?: string; status?: string }) => request(`/api/ordering-admin/customers/${customerId}/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), products: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/products', 'client', fetcher), createProduct: (payload: Partial) => request('/api/ordering-admin/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'), updateProduct: (productId: number, payload: Partial) => request(`/api/ordering-admin/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), visibility: (customerId: number, fetcher?: ApiFetch) => cachedFetchJson(`/api/ordering-admin/customers/${customerId}/visibility`, 'client', fetcher), setVisibility: (customerId: number, payload: { product_id: number; visible: boolean }) => request(`/api/ordering-admin/customers/${customerId}/visibility`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'), pricing: (customerId: number, fetcher?: ApiFetch) => cachedFetchJson(`/api/ordering-admin/customers/${customerId}/pricing`, 'client', fetcher), setAssignment: (customerId: number, payload: { price_list_id: number | null; discount_percent: number }) => request(`/api/ordering-admin/customers/${customerId}/assignment`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'), setProductPrice: ( customerId: number, payload: { product_id: number; unit_price: number | null; rule_type: string; contract_reference?: string | null; notes?: string | null; active?: boolean } ) => request(`/api/ordering-admin/customers/${customerId}/product-prices`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'), deleteProductPrice: (customerId: number, productId: number) => request(`/api/ordering-admin/customers/${customerId}/product-prices/${productId}`, { method: 'DELETE' }, 'client'), orders: (params?: { status?: string; customer_id?: number }, fetcher?: ApiFetch) => { const search = new URLSearchParams(); if (params?.status) search.set('status', params.status); if (params?.customer_id != null) search.set('customer_id', String(params.customer_id)); const qs = search.toString(); return cachedFetchJson(`/api/ordering-admin/orders${qs ? `?${qs}` : ''}`, 'client', fetcher); }, order: (orderId: number, fetcher?: ApiFetch) => request(`/api/ordering-admin/orders/${orderId}`, { method: 'GET' }, 'client', fetcher), updateStatus: (orderId: number, payload: { to_status: string; note?: string }) => request(`/api/ordering-admin/orders/${orderId}/status`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), overrideLine: (orderId: number, lineId: number, payload: { quantity?: number; unit_price?: number; reason?: string }) => request(`/api/ordering-admin/orders/${orderId}/lines/${lineId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), reopen: (orderId: number, note?: string) => request(`/api/ordering-admin/orders/${orderId}/reopen`, { method: 'POST', body: JSON.stringify({ note }) }, 'client'), sendToXero: (orderId: number) => request(`/api/ordering-admin/orders/${orderId}/send-to-xero`, { method: 'POST' }, 'client'), notificationSettings: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/notification-settings', 'client', fetcher), updateNotificationSettings: (payload: Partial) => request('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/xero/status', 'client', fetcher), xeroContacts: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/xero/contacts', 'client', fetcher), xeroContactLinks: (fetcher?: ApiFetch) => cachedFetchJson('/api/ordering-admin/xero/contact-links', 'client', fetcher), linkCustomerToXero: ( customerId: number, payload: { xero_contact_id: string; xero_contact_name?: string | null; xero_contact_email?: string | null } ) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'), unlinkCustomerFromXero: (customerId: number) => request(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client') } };