v0.1.19 - Throughput overview & responsive header
- Throughput: new "Throughput Overview" header with Gauge icon and brand-green badge icons on each card (Today, This week, 4-week average, Horse Mix, Grain Mix) - Throughput: inline rolling-range selector (7d / 4w / 6w / 12w, default 4 weeks) driving the customer-mix cards; stats window widened to 12 weeks so switching range is a pure client-side re-filter - Throughput: cards collapse to a single even 5-across row on laptop and up, with container-query value text that scales to each card's width - Throughput: date logic pinned to Australian Eastern time (fixes the day-early date); This week subtitle shows the Mon-Sun date range - Throughput: subtler tinted add-form; removed the inline-entry kicker and the "Open full form" link - Topbar: fix cramped laptop header - action toggles no longer wrap above the user button; search drops to its own row earlier Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+109
-2
@@ -9,6 +9,11 @@ import type {
|
||||
ClientUserUpdateInput,
|
||||
LoginResponse,
|
||||
EditorMixUpdateInput,
|
||||
EditorMixRow,
|
||||
EditorMixFormula,
|
||||
EditorIngredientRow,
|
||||
EditorIngredientCreateInput,
|
||||
EditorIngredientUpdateInput,
|
||||
EditorProductFormula,
|
||||
EditorProductRow,
|
||||
EditorProductUpdateInput,
|
||||
@@ -38,10 +43,14 @@ import type {
|
||||
OrderingCustomerUser,
|
||||
OrderingNotificationSettings,
|
||||
XeroStatus,
|
||||
XeroContactList,
|
||||
XeroContactLinkRow,
|
||||
Scenario,
|
||||
ThroughputEntry,
|
||||
ThroughputEntryCreateInput,
|
||||
ThroughputEntryUpdateInput,
|
||||
ThroughputEntryListParams,
|
||||
ThroughputImportResult,
|
||||
ThroughputProduct,
|
||||
ThroughputProductCreateInput,
|
||||
ThroughputProductUpdateInput
|
||||
@@ -250,6 +259,45 @@ async function request<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestBlob(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -330,11 +378,36 @@ export const api = {
|
||||
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<EditorMixRow[]>(path, 'client', fetcher);
|
||||
},
|
||||
updateEditorMix: (mixId: number, payload: EditorMixUpdateInput) =>
|
||||
request<EditorProductRow[]>(`/api/editor/mixes/${mixId}`, {
|
||||
request<EditorMixRow>(`/api/editor/mixes/${mixId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
editorMixFormula: (mixId: number) =>
|
||||
request<EditorMixFormula>(`/api/editor/mixes/${mixId}/ingredients`, {}, 'client'),
|
||||
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'),
|
||||
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 }) =>
|
||||
@@ -351,6 +424,18 @@ export const api = {
|
||||
request<EditorProductFormula>(`/api/editor/products/${productId}/ingredients/${ingredientId}`, {
|
||||
method: 'DELETE'
|
||||
}, 'client'),
|
||||
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'),
|
||||
productCosts: (fetcher?: ApiFetch) =>
|
||||
cachedFetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', 'client', fetcher),
|
||||
productCostingItems: (fetcher?: ApiFetch) =>
|
||||
@@ -391,6 +476,18 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}, 'client'),
|
||||
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');
|
||||
},
|
||||
createThroughputProduct: (payload: ThroughputProductCreateInput) =>
|
||||
request<ThroughputProduct>('/api/throughput/products', {
|
||||
method: 'POST',
|
||||
@@ -579,6 +676,16 @@ export const api = {
|
||||
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'),
|
||||
xeroStatus: (fetcher?: ApiFetch) => cachedFetchJson<XeroStatus>('/api/ordering-admin/xero/status', 'client', fetcher)
|
||||
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')
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user