diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 1f8cf2d..a858ae0 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -7,7 +7,7 @@ description: >- # Dash API Routes Overview (All) -Overview of all 207 API endpoints across all modules. +Overview of all 209 API endpoints across all modules. ### Notation @@ -64,6 +64,9 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). ### Routes + +### Section: /account + #### List accounts — `GET /account` _none_ - Returns: `200 { accounts: { id: int, companyId: int, type: str, name: str }[] }` @@ -73,6 +76,9 @@ _none_ #### Delete account — `DELETE /account/{id}` - Params: `id: int` - Returns: `204 void` + +### Section: /article + #### Create article in category — `POST /article` - Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }` - Returns: `201 T30` @@ -178,6 +184,9 @@ _none_ #### Set article weight by SKU — `PUT /article/{sku}/weight` - Params: `sku: str, body: { weight: num }` - Returns: `200 T1` + +### Section: /attributename + #### List attribute names — `GET /attributename` _none_ - Returns: `200 { attributenames: T24[] }` @@ -202,12 +211,24 @@ _none_ #### Update attribute name — `PATCH /attributename/{id}` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Returns: `200 { attributename: T24 }` + +### Section: /attributevalue + #### Delete attribute value — `DELETE /attributevalue/{id}` - Params: `id: int` - Returns: `200 { ok: bool }` #### Update attribute value — `PATCH /attributevalue/{id}` - Params: `id: int, body: { value?: str, sort?: int }` - Returns: `200 { attributevalue: T38 }` +#### Delete attribute value image — `DELETE /attributevalue/{id}/image` +- Params: `id: int` +- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }` +#### Add or replace attribute value image — `POST /attributevalue/{id}/image` +- Params: `id: int, image/avif` +- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }` + +### Section: /auth + #### Login with Google ID token — `POST /auth/login` - Params: `body: { idToken: str }` - Returns: `200 { message: str, user: T34 }` @@ -220,9 +241,12 @@ _none_ #### Set selected company — `PUT /auth/selected-company` - Params: `body: { companyId: int }` - Returns: `200 { user: T34 }` + +### Section: /brand + #### List brands — `GET /brand` - Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool` -- Returns: `200 { brands: { id: int, name: str, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` +- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` #### Create brand — `POST /brand` - Params: `body: { name: str, gsprpinfo?: str }` - Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }` @@ -238,6 +262,9 @@ _none_ #### Add or replace brand image — `POST /brand/{id}/image` - Params: `id: int, image/avif` - Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }` + +### Section: /category + #### Create category — `POST /category` - Params: `body: { parentId: int, name: str }` - Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }` @@ -280,6 +307,9 @@ _none_ #### Delete category property — `DELETE /category/{id}/property/{propertyId}` - Params: `id: int, propertyId: int` - Returns: `200 { ok: bool }` + +### Section: /company + #### List companies — `GET /company` _none_ - Returns: `200 { companies: T32[] }` @@ -310,6 +340,9 @@ _none_ #### Update company — `PUT /company/{id}` - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Returns: `200 { company: T32 }` + +### Section: /customergroup + #### List customer groups — `GET /customergroup` _none_ - Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }` @@ -325,6 +358,9 @@ _none_ #### Update customer group — `PATCH /customergroup/{id}` - Params: `id: int, body: { name: str }` - Returns: `200 { customergroup: T44 }` + +### Section: /delivery + #### List deliveries — `GET /delivery` _none_ - Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[] }` @@ -355,6 +391,9 @@ _none_ #### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}` - Params: `id: int, fileId: int, query: download?: bool` - Returns: `200 str` + +### Section: /entity + #### List entities — `GET /entity` - Params: `query: customergroupId?: int, role?: str` - Returns: `200 { entities: T27[] }` @@ -397,6 +436,9 @@ _none_ #### Update entity — `PATCH /entity/{id}` - Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }` - Returns: `200 T19` + +### Section: /events + #### Subscribe to update signals (SSE) — `GET /events` _none_ - Returns: `200 SSE` @@ -416,12 +458,21 @@ _none_ - `modelconversation`: `{}` - `modelconversation-`: `{}` - `brand`: `{ sender, brandId }` + +### Section: /health + #### Database health check — `GET /health` _none_ - Returns: `200 { ok: bool }` + +### Section: /image + #### Full image by hash — `GET /image/{fullHash}` - Params: `fullHash: str` - Returns: `200 avif` + +### Section: /labeltemplate + #### List label templates — `GET /labeltemplate` _none_ - Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }` @@ -440,6 +491,9 @@ _none_ #### Update label template — `PUT /labeltemplate/{id}` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T17 }` - Returns: `200 T5` + +### Section: /ledger + #### Query ledger by manifest article ID or SKU — `GET /ledger` - Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` - Returns: `200 T14` @@ -452,6 +506,9 @@ _none_ #### Query ledger by article SKU — `GET /ledger/sku/{sku}` - Params: `sku: str, query: transaction_type?: str` - Returns: `200 T14` + +### Section: /modelconversation + #### List conversations — `GET /modelconversation` _none_ - Returns: `200 { conversations: T29[] }` @@ -473,12 +530,18 @@ _none_ #### Stream conversation events (SSE) — `GET /modelconversation/{id}/stream` - Params: `id: int` - Returns: `200 SSE` + +### Section: /number + #### List number sequences — `GET /number` _none_ - Returns: `200 { numbers: { id: int, type: str, prefix: str, padding: int, next: int }[] }` #### Update number sequence — `PATCH /number/{id}` - Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` - Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }` + +### Section: /order + #### List orders — `GET /order` - Params: `query: offset?: int, limit?: int` - Returns: `200 { orders: T11[], total: int, offset: int, limit: int }` @@ -506,12 +569,21 @@ _none_ #### Create shipping for an order — `POST /order/{id}/shipping` - Params: `id: int, body: { type: str, state: str }` - Returns: `201 { shipping: { id: int, manifestId: int, number: str, type: str, state: str } }` + +### Section: /package + #### Delete package — `DELETE /package/{id}` - Params: `id: int` - Returns: `204 void` + +### Section: /payment + #### Delete payment — `DELETE /payment/{id}` - Params: `id: int` - Returns: `204 void` + +### Section: /posterminal + #### List POS terminals — `GET /posterminal` _none_ - Returns: `200 { posterminals: T10[] }` @@ -536,6 +608,9 @@ _none_ #### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}` - Params: `id: int, categoryId: int` - Returns: `200 T45` + +### Section: /printer + #### List printers — `GET /printer` _none_ - Returns: `200 { printers: T23[] }` @@ -557,6 +632,9 @@ _none_ #### Update printer — `PUT /printer/{id}` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }` - Returns: `200 T18` + +### Section: /propertyname + #### List property names — `GET /propertyname` _none_ - Returns: `200 { propertynames: T41[] }` @@ -572,6 +650,9 @@ _none_ #### Update property name — `PATCH /propertyname/{id}` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Returns: `200 { propertyname: T41 }` + +### Section: /public + #### Receipt crash dump — `POST /public/telemetry/crash` - Params: `body: { content: any }` - Returns: `200 { ok: bool, filename: str }` @@ -584,6 +665,9 @@ _none_ #### List public categories — `GET /public/{companyId}/categories` - Params: `companyId: int` - Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }` + +### Section: /quote + #### List quotes — `GET /quote` _none_ - Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }` @@ -599,6 +683,9 @@ _none_ #### Update quote — `PATCH /quote/{id}` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }` - Returns: `200 T8` + +### Section: /serper + #### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image` - Params: `body: { url: str }` - Returns: `200 avif` @@ -611,6 +698,9 @@ _none_ - `thumb`: `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }` - `done`: `{ "total": 100 }` - `error`: `{ "error": "…" }` + +### Section: /shipping + #### Delete shipping — `DELETE /shipping/{id}` - Params: `id: int` - Returns: `204 void` @@ -620,12 +710,21 @@ _none_ #### Create package for a shipping — `POST /shipping/{id}/package` - Params: `id: int, body: { entityaddressId: int, type: str, state: str, details?: str }` - Returns: `201 { package: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null } }` + +### Section: /smallthumb + #### Small thumb image by hash — `GET /smallthumb/{fullHash}` - Params: `fullHash: str` - Returns: `200 avif` + +### Section: /stock + #### Adjust stock manually — `POST /stock/adjust` - Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }` - Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T15[] }` + +### Section: /supplier + #### List suppliers — `GET /supplier` _none_ - Returns: `200 { suppliers: T39[] }` @@ -644,9 +743,15 @@ _none_ #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles` - Params: `id: int, body?: { skus?: str[] }` - Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T43|null }[] }` + +### Section: /thumb + #### Thumb image by hash — `GET /thumb/{fullHash}` - Params: `fullHash: str` - Returns: `200 avif` + +### Section: /unit + #### Delete unit — `DELETE /unit` - Params: `query: si: str` - Returns: `204 void` @@ -656,6 +761,9 @@ _none_ #### Upsert unit — `PUT /unit` - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - Returns: `200 { unit: T31 }` + +### Section: /v1 + #### JTL-POS category synchronization — `GET /v1/category` - Params: `query: lastChangedCategory?: str, limit?: int` - Returns: `200 {}[]` @@ -671,6 +779,9 @@ _none_ #### JTL-POS product catalog synchronization — `GET /v1/product` - Params: `query: lastChangedProduct?: str, limit?: int` - Returns: `200 {}[]` + +### Section: /vfs + #### List virtual filesystem files — `GET /vfs` _none_ - Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }` @@ -691,10 +802,13 @@ _none_ - Returns: `200 str` #### Execute a virtual filesystem applet — `POST /vfs/{*path}` - Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }` -- Returns: `200 { ok: bool, result: any|null, stdout: str, consoleLines: int, durationMs: int, truncated?: bool, truncationNote?: str }` +- Returns: `200 { ok: bool, result: str, columns: { name: str, type: str }[], rows: int, stdout: str, consoleLines: int, durationMs: int, warnings?: str[], truncated?: bool, truncationNote?: str }` #### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}` - Params: `path: str, body: { content: str, mime?: str }` - Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }` + +### Section: /warehouse + #### List warehouses — `GET /warehouse` _none_ - Returns: `200 { warehouses: T44[] }` diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 2b45c2f..31ec416 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -3,9 +3,8 @@ * @description Manages cached and paginated company sales orders (`GET /api/order`). * * Upstream Triggers: - * - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`, - * and `actions.reset()` on `'anon'`. - * - UI / Routes: Calls to `load({ offset, limit })`, `setPage(page)`, or `reload()`. + * - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes. + * - UI / Routes: On-demand calls to `ensureLoaded()`, `load()`, or `reload()`. */ import { Automat } from 'react-automat'; @@ -53,39 +52,25 @@ const initialState = { total: 0, offset: 0, limit: 20, + hasMore: false, + loadingMore: false, status: 'idle', error: null, }; let automat; let inflightLoad = null; +let inflightMore = null; const FETCH_PAGE_SIZE = 100; -// MARK: pages through GET /api/order until every order is collected -async function fetchAllOrders() { - let offset = 0; - let total = Infinity; - const collected = []; - while (offset === 0 || collected.length < total) { - const res = await orderApi.list({ offset, limit: FETCH_PAGE_SIZE }); - if (offset === 0) total = Number(res?.total ?? res?.orders?.length ?? 0); - const page = Array.isArray(res?.orders) ? res.orders : []; - collected.push(...page); - offset += page.length; - if (page.length === 0) break; - } - return collected; +// MARK: fetches a single page from GET /api/order +async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) { + const res = await orderApi.list({ offset, limit }); + const orders = Array.isArray(res?.orders) ? res.orders : []; + const total = Number(res?.total ?? orders.length); + return { orders, total }; } -const applyAll = (orders) => ({ - items: orders, - total: orders.length, - offset: 0, - limit: Math.max(orders.length, 1), - status: 'ready', - error: null, -}); - /** * Client-side filter over number, external reference and (resolved) customer name. * @param {Order[]} items @@ -107,16 +92,43 @@ const actions = { }, /** - * Loads all orders, deduplicating concurrent callers. + * Synchronously returns current items and triggers loading if idle. + * On remounts, directly returns cached items from the automat. + * + * @param {(order: Order) => string} [nameOf] Optional customer name resolver. + * @returns {Order[]} + */ + getOrLoad(nameOf) { + if (automat.state.status === 'idle') { + void actions.load().catch(() => {}); + } + const { items, filter } = automat.state; + if (filter) { + return filterOrders(items, filter, nameOf); + } + return items; + }, + + /** + * Loads the initial 100 orders, deduplicating concurrent callers. * * @returns {Promise} */ load() { if (inflightLoad) return inflightLoad; automat.setState({ status: 'loading', error: null }); - inflightLoad = fetchAllOrders() - .then((orders) => { - automat.setState(applyAll(orders)); + inflightLoad = fetchOrdersPage(0, FETCH_PAGE_SIZE) + .then(({ orders, total }) => { + automat.setState({ + items: orders, + total, + offset: 0, + limit: FETCH_PAGE_SIZE, + hasMore: orders.length < total && orders.length >= FETCH_PAGE_SIZE, + loadingMore: false, + status: 'ready', + error: null, + }); return orders; }) .catch((error) => { @@ -128,14 +140,61 @@ const actions = { }, /** - * Silently re-fetches all orders without flashing loading state. + * Loads the next batch of 100 orders when pagination approaches the end. + * + * @returns {Promise} + */ + loadMore() { + const { items, total, status, loadingMore } = automat.state; + if (status !== 'ready' || loadingMore) return Promise.resolve(items); + if (total > 0 && items.length >= total) return Promise.resolve(items); + if (inflightMore) return inflightMore; + + automat.setState({ loadingMore: true }); + const offset = items.length; + inflightMore = fetchOrdersPage(offset, FETCH_PAGE_SIZE) + .then(({ orders: nextBatch, total: newTotal }) => { + const existingIds = new Set(automat.state.items.map((o) => o.id)); + const uniqueNext = nextBatch.filter((o) => !existingIds.has(o.id)); + const merged = [...automat.state.items, ...uniqueNext]; + const finalTotal = Math.max(newTotal, merged.length); + automat.setState({ + items: merged, + total: finalTotal, + hasMore: nextBatch.length > 0 && merged.length < finalTotal, + loadingMore: false, + }); + return merged; + }) + .catch((err) => { + console.error('[orderAutomat] loadMore error:', err); + automat.setState({ loadingMore: false }); + return automat.state.items; + }) + .finally(() => { + inflightMore = null; + }); + + return inflightMore; + }, + + /** + * Silently re-fetches loaded orders without flashing loading state. * * @returns {Promise} */ async reload() { try { - const orders = await fetchAllOrders(); - automat.setState(applyAll(orders)); + const currentCount = Math.max(FETCH_PAGE_SIZE, automat.state.items.length); + const { orders, total } = await fetchOrdersPage(0, currentCount); + automat.setState({ + items: orders, + total, + hasMore: orders.length < total, + loadingMore: false, + status: 'ready', + error: null, + }); return orders; } catch (error) { automat.setState({ error }); @@ -143,7 +202,6 @@ const actions = { } }, - /** * Ensures orders are loaded once the selected company is known. * @@ -166,17 +224,35 @@ const actions = { automat.setState({ items, total: items.length, + hasMore: false, + loadingMore: false, status: 'ready', error: null, }); return items; }, + /** + * Patches an order locally in the automat items cache. + * + * @param {number|string} id Order ID. + * @param {Partial} patch Partial properties to update. + * @returns {Order[]} + */ + patchOrder(id, patch) { + const items = automat.state.items.map((o) => + o.id === id ? { ...o, ...patch } : o, + ); + automat.setState({ items }); + return items; + }, + /** * Resets to idle state (called on logout). */ reset() { inflightLoad = null; + inflightMore = null; automat.setState(initialState); }, }; @@ -184,13 +260,20 @@ const actions = { export const orderAutomat = new Automat(initialState, actions, { name: 'order' }); automat = orderAutomat; -// Load on session/company establishment, reset on logout. +// Reset on logout or company change (lazy-loaded on demand by views via ensureLoaded). +let lastCompanyId = null; authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void actions.load().catch(() => {}); - } else if (auth.status === 'anon') { + const companyId = auth.user?.selectedCompanyId ?? null; + if (auth.status === 'anon') { actions.reset(); + lastCompanyId = null; + return; + } + if (lastCompanyId !== null && companyId !== lastCompanyId) { + actions.reset(); + } + if (auth.status === 'authed' && companyId != null) { + lastCompanyId = companyId; } }); @@ -204,6 +287,16 @@ export function selectOrderTotal(state = automat.state) { return state.total; } +/** Selects whether more orders can be fetched from backend */ +export function selectHasMoreOrders(state = automat.state) { + return state.hasMore; +} + +/** Selects whether an incremental fetch is currently in flight */ +export function selectIsLoadingMoreOrders(state = automat.state) { + return state.loadingMore; +} + /** * Calculates client pagination metadata. * Current Page: Math.floor(offset / limit) + 1 diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx index b0e2cbf..1512157 100644 --- a/src/components/manifest/OrderList.jsx +++ b/src/components/manifest/OrderList.jsx @@ -6,6 +6,8 @@ import CircularProgress from '@mui/material/CircularProgress'; import IconButton from '@mui/material/IconButton'; import ListItemButton from '@mui/material/ListItemButton'; import Typography from '@mui/material/Typography'; +import Checkbox from '@mui/material/Checkbox'; +import Tooltip from '@mui/material/Tooltip'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import { enqueueSnackbar } from 'notistack'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; @@ -35,6 +37,14 @@ function orderGrossTotal(lines) { }, 0); } +function isOrderPaid(order) { + return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt); +} + +function isOrderDelivered(order) { + return Boolean(order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); +} + const cfg = manifestConfig('order'); const rowSx = { @@ -54,9 +64,16 @@ const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: ' * (``), so the list publishes callbacks and the party map * here on every render. */ -const rowContext = { partiesById: {}, busyId: null, onOpen: null, onDelete: null }; +const rowContext = { + partiesById: {}, + busyId: null, + onOpen: null, + onDelete: null, + onTogglePaid: null, + onToggleDelivered: null, +}; -// MARK: one row rendered by FillSpace +// MARK: class OrderRow extends PureComponent { partyName(partyId) { if (partyId == null) return '–'; @@ -76,9 +93,11 @@ class OrderRow extends PureComponent { ); } - const { busyId, onOpen, onDelete } = rowContext; + const { busyId, onOpen, onDelete, onTogglePaid, onToggleDelivered } = rowContext; const lines = order.lines ?? []; const gross = orderGrossTotal(lines); + const paid = isOrderPaid(order); + const delivered = isOrderDelivered(order); return ( onOpen?.(order)}> @@ -91,13 +110,48 @@ class OrderRow extends PureComponent { + + + {order.date ? formatDate(order.date) : '–'} + + + {this.partyName(order.entityId)} - - {cfg.dateLabel} {formatDate(order.date)} - + + + + + + e.stopPropagation()} + onChange={(e) => onTogglePaid?.(order, e.target.checked)} + inputProps={{ 'aria-label': `Auftrag ${order.number} bezahlt` }} + sx={{ p: 0.5 }} + /> + + + + + + + + e.stopPropagation()} + onChange={(e) => onToggleDelivered?.(order, e.target.checked)} + inputProps={{ 'aria-label': `Auftrag ${order.number} geliefert` }} + sx={{ p: 0.5 }} + /> + + ) class OrderSearchField extends PureComponent { constructor(props) { super(props); @@ -185,7 +239,7 @@ class OrderSearchField extends PureComponent { } } -// MARK: list page +// MARK: export class OrderList extends PureComponent { constructor(props) { super(props); @@ -193,6 +247,7 @@ export class OrderList extends PureComponent { this.state = { status: s.status === 'ready' ? 'ready' : 'loading', error: s.error ? backendErrorFrom(s.error) : null, + filteredItems: orderAutomat.actions.getOrLoad(), items: s.items ?? [], filter: s.filter ?? '', partiesById: {}, @@ -204,14 +259,56 @@ export class OrderList extends PureComponent { this.handleCreate = this.handleCreate.bind(this); this.handleOpen = this.handleOpen.bind(this); this.handleDelete = this.handleDelete.bind(this); + this.handleTogglePaid = this.handleTogglePaid.bind(this); + this.handleToggleDelivered = this.handleToggleDelivered.bind(this); + this.handlePageChange = this.handlePageChange.bind(this); this.nameOf = this.nameOf.bind(this); } + async handleTogglePaid(order, checked) { + if (this.state.busyId != null) return; + const patch = { paid: checked, payed: checked, isPaid: checked }; + orderAutomat.actions.patchOrder(order.id, patch); + try { + await orderApi.update(order.id, { paid: checked }); + enqueueSnackbar(checked ? 'Auftrag als bezahlt markiert' : 'Auftrag als unbezahlt markiert', { + variant: 'info', + }); + } catch (err) { + console.warn('[OrderList] update paid on backend failed:', err); + } + } + + async handleToggleDelivered(order, checked) { + if (this.state.busyId != null) return; + const patch = { delivered: checked, isDelivered: checked }; + orderAutomat.actions.patchOrder(order.id, patch); + try { + await orderApi.update(order.id, { delivered: checked }); + enqueueSnackbar(checked ? 'Auftrag als geliefert markiert' : 'Auftrag als ungeliefert markiert', { + variant: 'info', + }); + } catch (err) { + console.warn('[OrderList] update delivered on backend failed:', err); + } + } + + handlePageChange(page, meta) { + const pageCount = meta?.pageCount ?? 1; + // Load next batch when the pagination comes close to the end (within 2 pages). + if (pageCount - page <= 2) { + void orderAutomat.actions.loadMore(); + } + } + componentDidMount() { this._cancelled = false; this.unsub = orderAutomat.subscribe(this, (state) => ({ items: state.items ?? [], filter: state.filter ?? '', + filteredItems: state.filter + ? filterOrders(state.items ?? [], state.filter, this.nameOf) + : (state.items ?? []), status: state.status, error: state.error ? backendErrorFrom(state.error) : null, })); @@ -226,15 +323,18 @@ export class OrderList extends PureComponent { async loadAll() { try { const parties = await loadParties('order'); - if (!this._cancelled) this.setState({ partiesById: cfg.partiesByIdFromList(parties) }); + if (!this._cancelled) { + const partiesById = cfg.partiesByIdFromList(parties); + this.setState((prev) => ({ + partiesById, + filteredItems: prev.filter + ? filterOrders(prev.items, prev.filter, (o) => partyDisplayName('order', partiesById[o.entityId])) + : prev.filteredItems, + })); + } } catch { // Non-fatal: names fall back to ids. } - try { - await orderAutomat.actions.ensureLoaded(); - } catch { - // Surfaced through automat state. - } } nameOf(order) { @@ -243,7 +343,7 @@ export class OrderList extends PureComponent { } handleRetry() { - void orderAutomat.actions.load().catch(() => {}); + void orderAutomat.actions.load().catch(() => { }); } handleCreate() { @@ -270,7 +370,7 @@ export class OrderList extends PureComponent { } render() { - const { status, error, items, filter, partiesById, busyId } = this.state; + const { status, error, items, filteredItems, partiesById, busyId } = this.state; if (status === 'loading' || status === 'idle') { return ( @@ -287,8 +387,10 @@ export class OrderList extends PureComponent { rowContext.busyId = busyId; rowContext.onOpen = this.handleOpen; rowContext.onDelete = this.handleDelete; + rowContext.onTogglePaid = this.handleTogglePaid; + rowContext.onToggleDelivered = this.handleToggleDelivered; - const rows = filterOrders(items, filter, this.nameOf); + const rows = filteredItems ?? []; return ( <> @@ -308,6 +410,36 @@ export class OrderList extends PureComponent { items={rows} totalCount={items.length} renderItem={OrderRow} + header={ + + Nr. / Ref. + Datum + Kunde + Bezahlt + Geliefert + Pos. + Gesamt + + + } + onPageChange={this.handlePageChange} /> )}