2026-04-25 20:43:37 +12:00
|
|
|
import { env } from '$env/dynamic/public';
|
2026-04-25 22:51:36 +12:00
|
|
|
import { browser } from '$app/environment';
|
2026-04-25 20:43:37 +12:00
|
|
|
import type {
|
2026-04-25 22:51:36 +12:00
|
|
|
ClientAccessAccount,
|
|
|
|
|
ClientAccessPowerBiExport,
|
2026-05-08 00:00:56 +12:00
|
|
|
DashboardSummary,
|
2026-04-25 22:51:36 +12:00
|
|
|
ClientUserCreateInput,
|
2026-04-29 01:21:16 +12:00
|
|
|
ClientUserModulePermission,
|
2026-04-25 22:51:36 +12:00
|
|
|
ClientUserUpdateInput,
|
2026-04-25 20:43:37 +12:00
|
|
|
LoginResponse,
|
2026-06-16 14:43:17 +12:00
|
|
|
EditorMixCreateInput,
|
2026-06-03 00:17:12 +12:00
|
|
|
EditorMixUpdateInput,
|
2026-06-13 10:00:15 +12:00
|
|
|
EditorMixRow,
|
|
|
|
|
EditorMixFormula,
|
2026-06-16 14:43:17 +12:00
|
|
|
EditorResolvedMixFormula,
|
|
|
|
|
EditorMixFormulaRowInput,
|
2026-06-13 10:00:15 +12:00
|
|
|
EditorIngredientRow,
|
|
|
|
|
EditorIngredientCreateInput,
|
|
|
|
|
EditorIngredientUpdateInput,
|
2026-06-03 15:09:21 +12:00
|
|
|
EditorProductFormula,
|
2026-06-03 00:17:12 +12:00
|
|
|
EditorProductRow,
|
|
|
|
|
EditorProductUpdateInput,
|
2026-04-29 23:05:27 +12:00
|
|
|
MixCalculatorCreateInput,
|
|
|
|
|
MixCalculatorOptions,
|
|
|
|
|
MixCalculatorPreview,
|
|
|
|
|
MixCalculatorSession,
|
|
|
|
|
MixCalculatorUpdateInput,
|
2026-04-25 22:51:36 +12:00
|
|
|
Mix,
|
|
|
|
|
MixCreateInput,
|
|
|
|
|
MixIngredientUpdateInput,
|
|
|
|
|
MixUpdateInput,
|
2026-04-25 20:43:37 +12:00
|
|
|
Product,
|
|
|
|
|
ProductCostBreakdown,
|
2026-06-09 21:28:53 +12:00
|
|
|
ProductCostingInputs,
|
|
|
|
|
ProductCostingItem,
|
|
|
|
|
ProductCostingItemUpdateInput,
|
2026-04-25 20:43:37 +12:00
|
|
|
RawMaterial,
|
|
|
|
|
RawMaterialCreateInput,
|
|
|
|
|
RawMaterialPriceCreateInput,
|
2026-06-11 23:56:02 +12:00
|
|
|
CatalogueProduct,
|
|
|
|
|
CustomerPricing,
|
|
|
|
|
CustomerVisibilityRow,
|
|
|
|
|
DraftOrderInput,
|
|
|
|
|
Order,
|
|
|
|
|
OrderingCustomer,
|
|
|
|
|
OrderingCustomerUser,
|
|
|
|
|
OrderingNotificationSettings,
|
|
|
|
|
XeroStatus,
|
2026-06-13 10:00:15 +12:00
|
|
|
XeroContactList,
|
|
|
|
|
XeroContactLinkRow,
|
2026-05-31 20:19:44 +12:00
|
|
|
Scenario,
|
|
|
|
|
ThroughputEntry,
|
|
|
|
|
ThroughputEntryCreateInput,
|
2026-06-13 10:00:15 +12:00
|
|
|
ThroughputEntryUpdateInput,
|
2026-05-31 20:19:44 +12:00
|
|
|
ThroughputEntryListParams,
|
2026-06-13 10:00:15 +12:00
|
|
|
ThroughputImportResult,
|
2026-05-31 20:19:44 +12:00
|
|
|
ThroughputProduct,
|
|
|
|
|
ThroughputProductCreateInput,
|
|
|
|
|
ThroughputProductUpdateInput
|
2026-04-25 20:43:37 +12:00
|
|
|
} from '$lib/types';
|
2026-04-25 22:51:36 +12:00
|
|
|
import { getStoredAdminSession, getStoredClientSession } from '$lib/session';
|
2026-04-25 20:43:37 +12:00
|
|
|
|
2026-05-01 17:40:47 +12:00
|
|
|
const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.';
|
2026-04-25 20:43:37 +12:00
|
|
|
|
2026-04-29 01:21:16 +12:00
|
|
|
type AuthMode = 'none' | 'client' | 'admin' | 'manager';
|
2026-04-27 21:53:36 +12:00
|
|
|
type ApiFetch = typeof fetch;
|
|
|
|
|
|
|
|
|
|
function getApiBaseUrl() {
|
2026-04-29 23:53:51 +12:00
|
|
|
if (!browser) {
|
|
|
|
|
const internalBaseUrl = typeof process !== 'undefined' ? process.env.INTERNAL_API_BASE_URL?.trim() : '';
|
|
|
|
|
if (internalBaseUrl) {
|
|
|
|
|
return internalBaseUrl.replace(/\/+$/, '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:53:36 +12:00
|
|
|
if (browser) {
|
2026-05-10 09:46:07 +12:00
|
|
|
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 '';
|
2026-04-27 21:53:36 +12:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 09:46:07 +12:00
|
|
|
const defaultApiPort = env.PUBLIC_API_PORT || '8000';
|
|
|
|
|
return `http://127.0.0.1:${defaultApiPort}`;
|
2026-04-27 21:53:36 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildApiUrl(path: string) {
|
|
|
|
|
return `${getApiBaseUrl()}${path}`;
|
|
|
|
|
}
|
2026-04-25 22:51:36 +12:00
|
|
|
|
2026-05-10 09:46:07 +12:00
|
|
|
function getSessionFingerprint(auth: AuthMode) {
|
2026-04-25 22:51:36 +12:00
|
|
|
if (auth === 'client') {
|
2026-05-10 09:46:07 +12:00
|
|
|
const session = getStoredClientSession();
|
|
|
|
|
return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : '';
|
2026-04-25 22:51:36 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (auth === 'admin') {
|
2026-05-10 09:46:07 +12:00
|
|
|
const session = getStoredAdminSession();
|
|
|
|
|
return session ? `${session.role}:${session.email}` : '';
|
2026-04-25 22:51:36 +12:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 01:21:16 +12:00
|
|
|
if (auth === 'manager') {
|
2026-05-10 09:46:07 +12:00
|
|
|
const admin = getStoredAdminSession();
|
|
|
|
|
if (admin) {
|
|
|
|
|
return `${admin.role}:${admin.email}`;
|
|
|
|
|
}
|
|
|
|
|
const client = getStoredClientSession();
|
|
|
|
|
return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : '';
|
2026-04-29 01:21:16 +12:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 09:46:07 +12:00
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveRequestUrl(path: string, fetcher: ApiFetch) {
|
|
|
|
|
if (fetcher !== fetch) {
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
return buildApiUrl(path);
|
2026-04-25 22:51:36 +12:00
|
|
|
}
|
|
|
|
|
|
2026-05-01 17:40:47 +12:00
|
|
|
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.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 21:28:53 +12:00
|
|
|
async function fetchJson<T>(path: string, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise<T> {
|
2026-04-25 20:43:37 +12:00
|
|
|
try {
|
2026-05-10 09:46:07 +12:00
|
|
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
|
|
|
|
credentials: 'include'
|
2026-04-25 22:51:36 +12:00
|
|
|
});
|
2026-04-25 20:43:37 +12:00
|
|
|
if (!response.ok) {
|
2026-06-09 21:28:53 +12:00
|
|
|
throw new Error(response.statusText || 'Request failed');
|
2026-04-25 20:43:37 +12:00
|
|
|
}
|
|
|
|
|
return (await response.json()) as T;
|
2026-04-25 22:51:36 +12:00
|
|
|
} catch (error) {
|
2026-06-09 21:28:53 +12:00
|
|
|
throw normalizeRequestError(error);
|
2026-04-25 20:43:37 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:00:56 +12:00
|
|
|
// 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<string, CacheEntry>();
|
|
|
|
|
const inflightRequests = new Map<string, Promise<unknown>>();
|
|
|
|
|
const READ_CACHE_TTL_MS = 30_000;
|
|
|
|
|
|
|
|
|
|
function makeCacheKey(path: string, auth: AuthMode) {
|
2026-05-10 09:46:07 +12:00
|
|
|
const sessionFingerprint = browser ? getSessionFingerprint(auth) : '';
|
|
|
|
|
return `${auth}:${sessionFingerprint}:${path}`;
|
2026-05-08 00:00:56 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cachedFetchJson<T>(
|
|
|
|
|
path: string,
|
|
|
|
|
auth: AuthMode = 'none',
|
|
|
|
|
fetcher: ApiFetch = fetch
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
// Bypass the cache during SSR (no localStorage, no shared session).
|
|
|
|
|
if (!browser) {
|
2026-06-09 21:28:53 +12:00
|
|
|
return fetchJson<T>(path, auth, fetcher);
|
2026-05-08 00:00:56 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<T>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 21:28:53 +12:00
|
|
|
const promise = fetchJson<T>(path, auth, fetcher)
|
2026-05-08 00:00:56 +12:00
|
|
|
.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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:53:36 +12:00
|
|
|
async function request<T>(
|
|
|
|
|
path: string,
|
|
|
|
|
options: RequestInit,
|
|
|
|
|
auth: AuthMode = 'none',
|
|
|
|
|
fetcher: ApiFetch = fetch
|
|
|
|
|
): Promise<T> {
|
2026-05-01 17:40:47 +12:00
|
|
|
try {
|
2026-05-10 09:46:07 +12:00
|
|
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
2026-05-01 17:40:47 +12:00
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
...(options.headers ?? {})
|
|
|
|
|
},
|
2026-05-10 09:46:07 +12:00
|
|
|
credentials: 'include',
|
2026-05-01 17:40:47 +12:00
|
|
|
...options
|
|
|
|
|
});
|
2026-04-25 20:43:37 +12:00
|
|
|
|
2026-05-01 17:40:47 +12:00
|
|
|
if (!response.ok) {
|
|
|
|
|
let message = 'Request failed';
|
2026-04-25 20:43:37 +12:00
|
|
|
|
2026-05-01 17:40:47 +12:00
|
|
|
try {
|
|
|
|
|
const body = (await response.json()) as { detail?: string };
|
|
|
|
|
message = body.detail ?? message;
|
|
|
|
|
} catch {
|
|
|
|
|
message = response.statusText || message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(message);
|
2026-04-25 20:43:37 +12:00
|
|
|
}
|
|
|
|
|
|
2026-05-08 00:00:56 +12:00
|
|
|
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();
|
|
|
|
|
}
|
2026-05-10 09:46:07 +12:00
|
|
|
if (response.status === 204) {
|
|
|
|
|
return undefined as T;
|
|
|
|
|
}
|
2026-05-01 17:40:47 +12:00
|
|
|
return (await response.json()) as T;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw normalizeRequestError(error);
|
2026-04-25 20:43:37 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
// 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<T>(
|
|
|
|
|
path: string,
|
|
|
|
|
formData: FormData,
|
|
|
|
|
auth: AuthMode = 'none',
|
|
|
|
|
fetcher: ApiFetch = fetch
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:07:01 +12:00
|
|
|
async function requestBlob(
|
|
|
|
|
path: string,
|
2026-05-31 20:19:44 +12:00
|
|
|
options: RequestInit = {},
|
2026-05-08 23:07:01 +12:00
|
|
|
auth: AuthMode = 'none',
|
|
|
|
|
fetcher: ApiFetch = fetch
|
|
|
|
|
): Promise<Blob> {
|
|
|
|
|
try {
|
2026-05-10 09:46:07 +12:00
|
|
|
const response = await fetcher(resolveRequestUrl(path, fetcher), {
|
2026-05-31 20:19:44 +12:00
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
...(options.headers ?? {})
|
|
|
|
|
},
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
...options
|
2026-05-08 23:07:01 +12:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 20:43:37 +12:00
|
|
|
export const api = {
|
2026-06-09 21:28:53 +12:00
|
|
|
rawMaterials: (fetcher?: ApiFetch) => cachedFetchJson<RawMaterial[]>('/api/raw-materials', 'client', fetcher),
|
|
|
|
|
mixes: (fetcher?: ApiFetch) => cachedFetchJson<Mix[]>('/api/mixes', 'client', fetcher),
|
2026-04-27 21:53:36 +12:00
|
|
|
mix: (mixId: number, fetcher?: ApiFetch) => request<Mix>(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher),
|
2026-04-29 23:05:27 +12:00
|
|
|
mixCalculatorOptions: (fetcher?: ApiFetch) =>
|
2026-06-09 21:28:53 +12:00
|
|
|
cachedFetchJson<MixCalculatorOptions>('/api/mix-calculator/options', 'client', fetcher),
|
2026-04-29 23:05:27 +12:00
|
|
|
mixCalculatorSessions: (fetcher?: ApiFetch) =>
|
2026-06-09 21:28:53 +12:00
|
|
|
cachedFetchJson<MixCalculatorSession[]>('/api/mix-calculator', 'client', fetcher),
|
2026-04-29 23:05:27 +12:00
|
|
|
mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) =>
|
|
|
|
|
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher),
|
2026-05-08 23:07:01 +12:00
|
|
|
mixCalculatorSessionPdf: (sessionId: number, fetcher?: ApiFetch) =>
|
2026-05-31 20:19:44 +12:00
|
|
|
requestBlob(`/api/mix-calculator/${sessionId}/pdf`, {}, 'client', fetcher),
|
2026-04-29 23:05:27 +12:00
|
|
|
previewMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
|
|
|
|
request<MixCalculatorPreview>('/api/mix-calculator/preview', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-05-31 20:19:44 +12:00
|
|
|
previewMixCalculatorPdf: (payload: MixCalculatorCreateInput) =>
|
|
|
|
|
requestBlob('/api/mix-calculator/preview/pdf', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-04-29 23:05:27 +12:00
|
|
|
createMixCalculatorSession: (payload: MixCalculatorCreateInput) =>
|
|
|
|
|
request<MixCalculatorSession>('/api/mix-calculator', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateMixCalculatorSession: (sessionId: number, payload: MixCalculatorUpdateInput) =>
|
|
|
|
|
request<MixCalculatorSession>(`/api/mix-calculator/${sessionId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-06-09 21:28:53 +12:00
|
|
|
products: (fetcher?: ApiFetch) => cachedFetchJson<Product[]>('/api/products', 'client', fetcher),
|
2026-06-03 00:17:12 +12:00
|
|
|
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';
|
2026-06-09 21:28:53 +12:00
|
|
|
return cachedFetchJson<EditorProductRow[]>(path, 'client', fetcher);
|
2026-06-03 00:17:12 +12:00
|
|
|
},
|
|
|
|
|
updateEditorProduct: (productId: number, payload: EditorProductUpdateInput) =>
|
|
|
|
|
request<EditorProductRow>(`/api/editor/products/${productId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-06-13 10:00:15 +12:00
|
|
|
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<EditorMixRow[]>(path, 'client', fetcher);
|
|
|
|
|
},
|
2026-06-16 14:43:17 +12:00
|
|
|
createEditorMix: (payload: EditorMixCreateInput) =>
|
|
|
|
|
request<EditorMixRow>('/api/editor/mixes', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-06-03 00:17:12 +12:00
|
|
|
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
|
2026-06-13 10:00:15 +12:00
|
|
|
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
|
2026-06-03 00:17:12 +12:00
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-06-13 10:00:15 +12:00
|
|
|
editorMixFormula: (mixId: number) =>
|
|
|
|
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
|
2026-06-16 14:43:17 +12:00
|
|
|
// The resolved formula matching the Mix Calculator (product-first), used by
|
|
|
|
|
// the Mix Editor ingredient panel.
|
|
|
|
|
editorMixResolvedFormula: (mixId: number) =>
|
|
|
|
|
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {}, 'client'),
|
|
|
|
|
replaceEditorMixFormula: (mixId: number, rows: EditorMixFormulaRowInput[]) =>
|
|
|
|
|
request<EditorResolvedMixFormula>(`/api/editor/mixes/${mixId}/formula`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
body: JSON.stringify({ rows })
|
|
|
|
|
}, 'client'),
|
2026-06-13 10:00:15 +12:00
|
|
|
addEditorMixIngredient: (mixId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
|
|
|
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateEditorMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
|
|
|
|
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
deleteEditorMixIngredient: (mixId: number, ingredientId: number) =>
|
|
|
|
|
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients/${ingredientId}`, {
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
}, 'client'),
|
2026-06-03 15:09:21 +12:00
|
|
|
editorProductFormula: (productId: number) =>
|
|
|
|
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {}, 'client'),
|
|
|
|
|
addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) =>
|
|
|
|
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateEditorProductIngredient: (productId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
|
|
|
|
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
deleteEditorProductIngredient: (productId: number, ingredientId: number) =>
|
|
|
|
|
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
}, 'client'),
|
2026-06-13 10:00:15 +12:00
|
|
|
editorIngredients: (fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<EditorIngredientRow[]>('/api/editor/ingredients', 'client', fetcher),
|
|
|
|
|
createEditorIngredient: (payload: EditorIngredientCreateInput) =>
|
|
|
|
|
request<EditorIngredientRow>('/api/editor/ingredients', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateEditorIngredient: (ingredientId: number, payload: EditorIngredientUpdateInput) =>
|
|
|
|
|
request<EditorIngredientRow>(`/api/editor/ingredients/${ingredientId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-04-27 21:53:36 +12:00
|
|
|
productCosts: (fetcher?: ApiFetch) =>
|
2026-06-09 21:28:53 +12:00
|
|
|
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
|
|
|
|
|
productCostingItems: (fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<ProductCostingItem[]>('/api/product-costing/items', 'client', fetcher),
|
|
|
|
|
productCostingItemsFresh: () =>
|
|
|
|
|
request<ProductCostingItem[]>(`/api/product-costing/items?_=${Date.now()}`, { method: 'GET' }, 'client'),
|
|
|
|
|
productCostingInputs: (fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<ProductCostingInputs>('/api/product-costing/inputs', 'client', fetcher),
|
|
|
|
|
updateProductCostingInputs: (payload: Partial<ProductCostingInputs>) =>
|
|
|
|
|
request<ProductCostingInputs>('/api/product-costing/inputs', {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateProductCostingItem: (itemId: number, payload: ProductCostingItemUpdateInput) =>
|
|
|
|
|
request<ProductCostingItem>(`/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<Scenario[]>('/api/scenarios', 'client', fetcher),
|
2026-05-31 20:19:44 +12:00
|
|
|
throughputProducts: (fetcher?: ApiFetch) =>
|
2026-06-09 21:28:53 +12:00
|
|
|
cachedFetchJson<ThroughputProduct[]>('/api/throughput/products', 'client', fetcher),
|
2026-05-31 20:19:44 +12:00
|
|
|
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';
|
2026-06-09 21:28:53 +12:00
|
|
|
return cachedFetchJson<ThroughputEntry[]>(path, 'client', fetcher);
|
2026-05-31 20:19:44 +12:00
|
|
|
},
|
|
|
|
|
createThroughputEntry: (payload: ThroughputEntryCreateInput) =>
|
|
|
|
|
request<ThroughputEntry>('/api/throughput/entries', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-06-13 10:00:15 +12:00
|
|
|
updateThroughputEntry: (entryId: number, payload: ThroughputEntryUpdateInput) =>
|
|
|
|
|
request<ThroughputEntry>(`/api/throughput/entries/${entryId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
deleteThroughputEntry: (entryId: number) =>
|
|
|
|
|
request<void>(`/api/throughput/entries/${entryId}`, { method: 'DELETE' }, 'client'),
|
|
|
|
|
importThroughputEntries: (file: File) => {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
return uploadFile<ThroughputImportResult>('/api/throughput/import', formData, 'client');
|
|
|
|
|
},
|
2026-05-31 20:19:44 +12:00
|
|
|
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
|
|
|
|
|
request<ThroughputProduct>('/api/throughput/products', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateThroughputProduct: (productId: number, payload: ThroughputProductUpdateInput) =>
|
|
|
|
|
request<ThroughputProduct>(`/api/throughput/products/${productId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-06-09 21:28:53 +12:00
|
|
|
clientAccess: (fetcher?: ApiFetch) => cachedFetchJson<ClientAccessAccount[]>('/api/client-access', 'manager', fetcher),
|
2026-04-27 21:53:36 +12:00
|
|
|
clientAccessExport: (fetcher?: ApiFetch) =>
|
2026-06-09 21:28:53 +12:00
|
|
|
cachedFetchJson<ClientAccessPowerBiExport>('/api/powerbi/client-access', 'manager', fetcher),
|
|
|
|
|
dataQuality: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/data-quality-issues', 'client', fetcher),
|
2026-05-08 00:00:56 +12:00
|
|
|
dashboardSummary: (fetcher?: ApiFetch) =>
|
2026-06-09 21:28:53 +12:00
|
|
|
cachedFetchJson<DashboardSummary>('/api/dashboard/summary', 'client', fetcher),
|
2026-04-25 22:51:36 +12:00
|
|
|
clientLogin: (email: string, password: string) =>
|
|
|
|
|
request<LoginResponse>('/api/auth/client/login', {
|
2026-04-25 20:43:37 +12:00
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ email, password })
|
|
|
|
|
}),
|
2026-05-08 00:00:56 +12:00
|
|
|
// 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<LoginResponse>('/api/access/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ email, password })
|
|
|
|
|
}),
|
|
|
|
|
internalSession: (fetcher?: ApiFetch) =>
|
|
|
|
|
request<LoginResponse>('/api/access/me', { method: 'GET' }, 'client', fetcher),
|
2026-05-08 09:06:14 +12:00
|
|
|
updateMe: (payload: { name?: string; email?: string; current_password?: string; new_password?: string }) =>
|
|
|
|
|
request<LoginResponse>('/api/access/me', {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
2026-04-25 22:51:36 +12:00
|
|
|
adminLogin: (email: string, password: string) =>
|
|
|
|
|
request<LoginResponse>('/api/auth/admin/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ email, password })
|
|
|
|
|
}),
|
2026-04-29 01:21:16 +12:00
|
|
|
clientSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/client/session', { method: 'GET' }, 'client', fetcher),
|
|
|
|
|
adminSession: (fetcher?: ApiFetch) => request<LoginResponse>('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher),
|
2026-05-10 09:46:07 +12:00
|
|
|
clientLogout: () => request<void>('/api/auth/client/logout', { method: 'POST' }, 'client'),
|
|
|
|
|
adminLogout: () => request<void>('/api/auth/admin/logout', { method: 'POST' }, 'admin'),
|
|
|
|
|
internalLogout: () => request<void>('/api/access/logout', { method: 'POST' }, 'client'),
|
2026-04-25 22:51:36 +12:00
|
|
|
login: (email: string, password: string) =>
|
|
|
|
|
request<LoginResponse>('/api/auth/client/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ email, password })
|
|
|
|
|
}),
|
|
|
|
|
createMix: (payload: MixCreateInput) =>
|
|
|
|
|
request<Mix>('/api/mixes', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateMix: (mixId: number, payload: MixUpdateInput) =>
|
|
|
|
|
request<Mix>(`/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<Mix>(`/api/mixes/${mixId}/ingredients`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
updateMixIngredient: (mixId: number, ingredientId: number, payload: MixIngredientUpdateInput) =>
|
|
|
|
|
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'client'),
|
|
|
|
|
deleteMixIngredient: (mixId: number, ingredientId: number) =>
|
|
|
|
|
request<Mix>(`/api/mixes/${mixId}/ingredients/${ingredientId}`, {
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
}, 'client'),
|
2026-04-25 20:43:37 +12:00
|
|
|
createRawMaterial: (payload: RawMaterialCreateInput) =>
|
|
|
|
|
request<RawMaterial>('/api/raw-materials', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
2026-04-25 22:51:36 +12:00
|
|
|
}, 'client'),
|
2026-04-25 20:43:37 +12:00
|
|
|
addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) =>
|
|
|
|
|
request(`/api/raw-materials/${rawMaterialId}/prices`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
2026-04-25 22:51:36 +12:00
|
|
|
}, 'client'),
|
|
|
|
|
createClientUser: (payload: ClientUserCreateInput) =>
|
|
|
|
|
request<ClientAccessAccount>('/api/client-access/users', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(payload)
|
2026-04-29 01:21:16 +12:00
|
|
|
}, 'manager'),
|
2026-04-25 22:51:36 +12:00
|
|
|
updateClientUser: (userId: number, payload: ClientUserUpdateInput) =>
|
|
|
|
|
request<ClientAccessAccount>(`/api/client-access/users/${userId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
2026-04-29 01:21:16 +12:00
|
|
|
}, 'manager'),
|
|
|
|
|
updateClientUserModulePermission: (userId: number, permission: Pick<ClientUserModulePermission, 'module_key'>, payload: { access_level: string }) =>
|
|
|
|
|
request<ClientAccessAccount>(`/api/client-access/users/${userId}/module-permissions/${permission.module_key}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
}, 'manager'),
|
2026-04-25 22:51:36 +12:00
|
|
|
updateClientFeature: (featureId: number, payload: { enabled: boolean }) =>
|
|
|
|
|
request<ClientAccessAccount>(`/api/client-access/features/${featureId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: JSON.stringify(payload)
|
2026-06-11 23:56:02 +12:00
|
|
|
}, '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<CatalogueProduct[]>(`/api/ordering/catalogue${qs ? `?${qs}` : ''}`, 'client', fetcher);
|
|
|
|
|
},
|
|
|
|
|
product: (productId: number, quantity = 1, fetcher?: ApiFetch) =>
|
|
|
|
|
request<CatalogueProduct>(`/api/ordering/catalogue/${productId}?quantity=${quantity}`, { method: 'GET' }, 'client', fetcher),
|
|
|
|
|
orders: (statusFilter?: string, fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<Order[]>(`/api/ordering/orders${statusFilter ? `?status=${statusFilter}` : ''}`, 'client', fetcher),
|
|
|
|
|
order: (orderId: number, fetcher?: ApiFetch) =>
|
|
|
|
|
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
|
|
|
|
|
createDraft: (payload: DraftOrderInput) =>
|
|
|
|
|
request<Order>('/api/ordering/orders', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
updateDraft: (orderId: number, payload: Partial<DraftOrderInput>) =>
|
|
|
|
|
request<Order>(`/api/ordering/orders/${orderId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
deleteDraft: (orderId: number) =>
|
|
|
|
|
request<void>(`/api/ordering/orders/${orderId}`, { method: 'DELETE' }, 'client'),
|
|
|
|
|
submit: (orderId: number, payload: Partial<DraftOrderInput> = {}) =>
|
|
|
|
|
request<Order>(`/api/ordering/orders/${orderId}/submit`, { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
reorder: (orderId: number) =>
|
|
|
|
|
request<Order>(`/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<OrderingCustomer[]>('/api/ordering-admin/customers', 'client', fetcher),
|
|
|
|
|
createCustomer: (payload: { name: string; client_code: string; tenant_id?: string; notes?: string }) =>
|
|
|
|
|
request<OrderingCustomer>('/api/ordering-admin/customers', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
updateCustomer: (customerId: number, payload: { name?: string; status?: string; notes?: string }) =>
|
|
|
|
|
request<OrderingCustomer>(`/api/ordering-admin/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
customerUsers: (customerId: number, fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<OrderingCustomerUser[]>(`/api/ordering-admin/customers/${customerId}/users`, 'client', fetcher),
|
|
|
|
|
createCustomerUser: (customerId: number, payload: { full_name: string; email: string; role: string }) =>
|
|
|
|
|
request<OrderingCustomerUser>(`/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<OrderingCustomerUser>(`/api/ordering-admin/customers/${customerId}/users/${userId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
products: (fetcher?: ApiFetch) => cachedFetchJson<CatalogueProduct[]>('/api/ordering-admin/products', 'client', fetcher),
|
|
|
|
|
createProduct: (payload: Partial<CatalogueProduct>) =>
|
|
|
|
|
request<CatalogueProduct>('/api/ordering-admin/products', { method: 'POST', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
updateProduct: (productId: number, payload: Partial<CatalogueProduct>) =>
|
|
|
|
|
request<CatalogueProduct>(`/api/ordering-admin/products/${productId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
visibility: (customerId: number, fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<CustomerVisibilityRow[]>(`/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<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/pricing`, 'client', fetcher),
|
|
|
|
|
setAssignment: (customerId: number, payload: { price_list_id: number | null; discount_percent: number }) =>
|
|
|
|
|
request<CustomerPricing>(`/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<CustomerPricing>(`/api/ordering-admin/customers/${customerId}/product-prices`, { method: 'PUT', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
deleteProductPrice: (customerId: number, productId: number) =>
|
|
|
|
|
request<void>(`/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<Order[]>(`/api/ordering-admin/orders${qs ? `?${qs}` : ''}`, 'client', fetcher);
|
|
|
|
|
},
|
|
|
|
|
order: (orderId: number, fetcher?: ApiFetch) =>
|
|
|
|
|
request<Order>(`/api/ordering-admin/orders/${orderId}`, { method: 'GET' }, 'client', fetcher),
|
|
|
|
|
updateStatus: (orderId: number, payload: { to_status: string; note?: string }) =>
|
|
|
|
|
request<Order>(`/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<Order>(`/api/ordering-admin/orders/${orderId}/lines/${lineId}`, { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
|
|
|
|
reopen: (orderId: number, note?: string) =>
|
|
|
|
|
request<Order>(`/api/ordering-admin/orders/${orderId}/reopen`, { method: 'POST', body: JSON.stringify({ note }) }, 'client'),
|
|
|
|
|
sendToXero: (orderId: number) =>
|
|
|
|
|
request<Order>(`/api/ordering-admin/orders/${orderId}/send-to-xero`, { method: 'POST' }, 'client'),
|
|
|
|
|
notificationSettings: (fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', 'client', fetcher),
|
|
|
|
|
updateNotificationSettings: (payload: Partial<OrderingNotificationSettings>) =>
|
|
|
|
|
request<OrderingNotificationSettings>('/api/ordering-admin/notification-settings', { method: 'PATCH', body: JSON.stringify(payload) }, 'client'),
|
2026-06-13 10:00:15 +12:00
|
|
|
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher),
|
|
|
|
|
xeroContacts: (fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<XeroContactList>('/api/ordering-admin/xero/contacts', 'client', fetcher),
|
|
|
|
|
xeroContactLinks: (fetcher?: ApiFetch) =>
|
|
|
|
|
cachedFetchJson<XeroContactLinkRow[]>('/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<void>(`/api/ordering-admin/customers/${customerId}/xero-link`, { method: 'DELETE' }, 'client')
|
2026-06-11 23:56:02 +12:00
|
|
|
}
|
2026-04-25 20:43:37 +12:00
|
|
|
};
|