diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index f9c815e..2f286a8 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 216 API endpoints across all modules. +Overview of all 229 API endpoints across all modules. ### Notation @@ -70,6 +70,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). ### Routes +### Section: /.well-known + +#### OAuth 2.0 Authorization Server Metadata (RFC 8414) — `GET /.well-known/oauth-authorization-server` +_none_ +- Returns: `200` +#### OAuth 2.0 Protected Resource Metadata (RFC 9728) — `GET /.well-known/oauth-protected-resource` +_none_ +- Returns: `200` + ### Section: /account #### List accounts — `GET /account` @@ -512,6 +521,30 @@ _none_ - Params: `sku: str, query: transaction_type?: str` - Returns: `200 T16` +### Section: /mcp + +#### Model Context Protocol (MCP) Streamable HTTP endpoint — `POST /mcp` +_none_ +- Returns: `200` +#### List active MCP client authorizations — `GET /mcp/authorizations` +_none_ +- Returns: `200` +#### Revoke MCP client authorizations — `DELETE /mcp/authorizations/{clientId}` +- Params: `clientId: str` +- Returns: `200` +#### Get MCP authorization prompt details — `GET /mcp/authorize` +- Params: `query: client_id: str, redirect_uri: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str` +- Returns: `200` +#### Grant or deny MCP client authorization — `POST /mcp/authorize` +- Params: `body: { client_id: str, redirect_uri: str, action: str, code_challenge?: str, code_challenge_method?: str, state?: str, scope?: str }` +- Returns: `200` +#### List registered MCP clients — `GET /mcp/clients` +_none_ +- Returns: `200` +#### Delete registered MCP client — `DELETE /mcp/clients/{clientId}` +- Params: `clientId: str` +- Returns: `200` + ### Section: /modelconversation #### List conversations — `GET /modelconversation` @@ -545,10 +578,25 @@ _none_ - Params: `id: int, body: { prefix?: str, padding?: int, next?: int }` - Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }` +### Section: /oauth + +#### OAuth 2.1 Authorization Endpoint (RFC 6749 & RFC 7636) — `GET /oauth/authorize` +- Params: `query: client_id?: str, redirect_uri?: str, response_type?: str, code_challenge?: str, code_challenge_method?: str, state?: str` +_no response body_ +#### OAuth 2.0 Token Introspection (RFC 7662) — `POST /oauth/introspect` +_none_ +- Returns: `200` +#### Dynamic Client Registration (RFC 7591) — `POST /oauth/register` +- Params: `body: { redirect_uris: str[], client_name?: str }` +- Returns: `201` +#### OAuth 2.1 Token Endpoint (RFC 6749 & RFC 7636) — `POST /oauth/token` +- Params: `application/x-www-form-urlencoded` +- Returns: `200` + ### Section: /order #### List orders — `GET /order` -- Params: `query: offset?: int, limit?: int` +- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int` - Returns: `200 { orders: T2[], total: int, offset: int, limit: int }` #### Create order — `POST /order` - Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }` diff --git a/src/api/order.js b/src/api/order.js index 2bf565c..e56d412 100644 --- a/src/api/order.js +++ b/src/api/order.js @@ -7,15 +7,21 @@ import { http } from './http.js'; */ export const order = { /** - * GET /api/order?offset=0&limit=20 - * @param {{ offset?: number, limit?: number }} [params] + * GET /api/order?offset=0&limit=50&search=...&onlyUnpaid=true&onlyUndelivered=true&seekMonths=-1 + * @param {{ offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number }} [params] * → { orders: Order[], total: number, offset: number, limit: number } */ // #region GET /api/order — list - list({ offset = 0, limit = 20 } = {}) { + list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths } = {}) { const params = new URLSearchParams(); - if (offset != null) params.set('offset', String(offset)); - if (limit != null) params.set('limit', String(limit)); + if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0)))); + if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50)))); + if (search && String(search).trim()) params.set('search', String(search).trim()); + if (onlyUnpaid === true || onlyUnpaid === 'true') params.set('onlyUnpaid', 'true'); + if (onlyUndelivered === true || onlyUndelivered === 'true') params.set('onlyUndelivered', 'true'); + if (seekMonths != null && Number(seekMonths) < 0) { + params.set('seekMonths', String(Math.floor(Number(seekMonths)))); + } const qs = params.toString(); return http(`/api/order${qs ? `?${qs}` : ''}`); }, diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 602a612..3291235 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -10,6 +10,7 @@ import { Automat } from 'react-automat'; import { order as orderApi } from '../api'; import authAutomat from './authAutomat'; +import entityAutomat from './entityAutomat'; /** * @typedef {Object} OrderLine @@ -50,11 +51,18 @@ const PREFETCH_CHUNKS = 2; const initialState = { items: null, total: 0, + filter: '', + onlyUnpaid: false, + onlyUndelivered: false, + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, status: 'idle', error: null, }; let automat; +let rawItems = null; const loadedChunks = new Set(); const inflightChunks = new Map(); @@ -147,7 +155,8 @@ export function filterOrders(items, filterOrOpts, nameOf) { } } if (query) { - const matches = [o.number, o.externalNumber, nameOf?.(o)] + const customer = nameOf ? nameOf(o) : getCustomerName(o); + const matches = [o.number, o.externalNumber, customer] .some((v) => String(v ?? '').toLowerCase().includes(query)); if (!matches) return false; } @@ -155,6 +164,50 @@ export function filterOrders(items, filterOrOpts, nameOf) { }); } +function getCustomerName(order) { + if (!order?.entityId) return ''; + const entities = entityAutomat.state?.items; + if (!Array.isArray(entities)) return ''; + const entity = entities.find((e) => e.id === order.entityId); + if (!entity) return ''; + return [entity.firstName, entity.name].filter(Boolean).join(' '); +} + +function hasActiveFilters(state = automat.state) { + return Boolean( + state.filter || + state.onlyUnpaid || + state.onlyUndelivered || + state.negativeSeekMonths < 0 + ); +} + +function updatePublishedItems() { + if (rawItems === null) { + automat.setState({ items: null }); + return; + } + const state = automat.state; + if (!hasActiveFilters(state)) { + automat.setState({ + items: rawItems ? [...rawItems] : null, + total: rawItems ? rawItems.length : 0, + }); + return; + } + const loadedOrders = rawItems.filter(Boolean); + const filtered = filterOrders(loadedOrders, { + filter: state.filter, + onlyUnpaid: state.onlyUnpaid, + onlyUndelivered: state.onlyUndelivered, + negativeSeekMonths: state.negativeSeekMonths, + }); + automat.setState({ + items: filtered, + total: filtered.length, + }); +} + const actions = { // MARK: returns current items and triggers chunk 0 if not loaded getItems() { @@ -170,7 +223,7 @@ const actions = { const start = Math.max(0, Number(chunkIndex) || 0); const p = actions.loadChunk(start); for (let c = start + 1; c <= start + PREFETCH_CHUNKS; c++) { - const total = automat.state.total; + const total = rawItems ? rawItems.length : (automat.state.total || 0); if (total > 0 && c * CHUNK_SIZE >= total) { break; } @@ -191,46 +244,41 @@ const actions = { const offset = index * CHUNK_SIZE; const limit = CHUNK_SIZE; - if (automat.state.items === null && automat.state.status === 'idle') { + if (rawItems === null && automat.state.status === 'idle') { automat.setState({ status: 'loading', error: null }); } const promise = fetchOrdersPage(offset, limit) .then(({ orders, total }) => { - let items = automat.state.items; const currentTotal = total > 0 ? total : (orders.length + offset); - if (items === null) { - // Pre-fill items array with that many empty (null) items - items = new Array(currentTotal).fill(null); - } else if (items.length !== currentTotal) { + if (rawItems === null) { + // Pre-fill rawItems array with that many empty (null) items + rawItems = new Array(currentTotal).fill(null); + } else if (rawItems.length !== currentTotal) { const next = new Array(currentTotal).fill(null); - const copyLen = Math.min(items.length, currentTotal); + const copyLen = Math.min(rawItems.length, currentTotal); for (let i = 0; i < copyLen; i++) { - next[i] = items[i]; + next[i] = rawItems[i]; } - items = next; - } else { - // Shallow copy to notify subscribers and PureComponents - items = [...items]; + rawItems = next; } // Fill in real received items for (let i = 0; i < orders.length; i++) { const idx = offset + i; - if (idx < items.length) { - items[idx] = orders[i]; + if (idx < rawItems.length) { + rawItems[idx] = orders[i]; } } loadedChunks.add(index); automat.setState({ - items, - total: currentTotal, status: 'ready', error: null, }); + updatePublishedItems(); // Trigger lookahead prefetching if first chunk just arrived if (index === 0) { @@ -241,11 +289,11 @@ const actions = { } } - return items; + return automat.state.items; }) .catch((err) => { console.error(`[orderAutomat] loadChunk(${index}) error:`, err); - if (automat.state.items === null) { + if (rawItems === null) { automat.setState({ status: 'failed', error: err }); } throw err; @@ -258,6 +306,98 @@ const actions = { return promise; }, + // MARK: loads all remaining chunks in the background when filtering + loadAllRemainingChunks() { + if (!rawItems) return; + const total = rawItems.length; + const totalChunks = Math.ceil(total / CHUNK_SIZE); + for (let c = 0; c < totalChunks; c++) { + if (!loadedChunks.has(c) && !inflightChunks.has(c)) { + void actions.loadChunk(c); + } + } + }, + + // MARK: filter actions + filter(query) { + automat.setState({ filter: String(query ?? '') }); + updatePublishedItems(); + if (hasActiveFilters()) { + actions.loadAllRemainingChunks(); + } + }, + + setOnlyUnpaid(onlyUnpaid) { + automat.setState({ onlyUnpaid: Boolean(onlyUnpaid) }); + updatePublishedItems(); + if (hasActiveFilters()) { + actions.loadAllRemainingChunks(); + } + }, + + setOnlyUndelivered(onlyUndelivered) { + automat.setState({ onlyUndelivered: Boolean(onlyUndelivered) }); + updatePublishedItems(); + if (hasActiveFilters()) { + actions.loadAllRemainingChunks(); + } + }, + + addSeekMonth() { + const seekMonths = (automat.state.seekMonths ?? 0) + 1; + const seekYears = automat.state.seekYears ?? 0; + const totalMonths = -(seekMonths + seekYears * 12); + automat.setState({ seekMonths, negativeSeekMonths: totalMonths }); + updatePublishedItems(); + if (hasActiveFilters()) { + actions.loadAllRemainingChunks(); + } + }, + + addSeekYear() { + const seekMonths = automat.state.seekMonths ?? 0; + const seekYears = (automat.state.seekYears ?? 0) + 1; + const totalMonths = -(seekMonths + seekYears * 12); + automat.setState({ seekYears, negativeSeekMonths: totalMonths }); + updatePublishedItems(); + if (hasActiveFilters()) { + actions.loadAllRemainingChunks(); + } + }, + + addNegativeSeek(months) { + if (months === -12) { + actions.addSeekYear(); + } else if (months === -1) { + actions.addSeekMonth(); + } else { + const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0)); + const next = current + months; + automat.setState({ negativeSeekMonths: Math.min(0, next) }); + updatePublishedItems(); + if (hasActiveFilters()) { + actions.loadAllRemainingChunks(); + } + } + }, + + resetNegativeSeek() { + automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 }); + updatePublishedItems(); + }, + + resetFilters() { + automat.setState({ + filter: '', + onlyUnpaid: false, + onlyUndelivered: false, + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, + }); + updatePublishedItems(); + }, + // MARK: initial load load() { return actions.loadChunk(0); @@ -276,12 +416,13 @@ const actions = { const chunksToReload = Array.from(loadedChunks); loadedChunks.clear(); inflightChunks.clear(); + rawItems = null; if (chunksToReload.length === 0) { return actions.loadChunk(0); } await actions.loadChunk(0); for (const c of chunksToReload) { - if (c !== 0 && c * CHUNK_SIZE < automat.state.total) { + if (c !== 0 && rawItems && c * CHUNK_SIZE < rawItems.length) { void actions.loadChunk(c); } } @@ -292,37 +433,38 @@ const actions = { replace(items) { loadedChunks.clear(); inflightChunks.clear(); - const count = Array.isArray(items) ? items.length : 0; + rawItems = Array.isArray(items) ? [...items] : []; + updatePublishedItems(); automat.setState({ - items, - total: count, status: 'ready', error: null, }); - return items; + return automat.state.items; }, // MARK: patches an order locally in the automat items cache patchOrder(id, patch) { - if (!Array.isArray(automat.state.items)) return automat.state.items; let changed = false; - const items = automat.state.items.map((o) => { - if (o && o.id === id) { - changed = true; - return { ...o, ...patch }; - } - return o; - }); - if (changed) { - automat.setState({ items }); + if (Array.isArray(rawItems)) { + rawItems = rawItems.map((o) => { + if (o && o.id === id) { + changed = true; + return { ...o, ...patch }; + } + return o; + }); } - return items; + if (changed) { + updatePublishedItems(); + } + return automat.state.items; }, // MARK: resets state reset() { loadedChunks.clear(); inflightChunks.clear(); + rawItems = null; automat.setState(initialState); }, }; @@ -372,17 +514,27 @@ export function selectOrderTotal(state = automat.state) { /** Selects unpaid filter */ export function selectOnlyUnpaid(state = automat.state) { - return false; + return state.onlyUnpaid; } /** Selects undelivered filter */ export function selectOnlyUndelivered(state = automat.state) { - return false; + return state.onlyUndelivered; } /** Selects negative seek offset in months */ export function selectNegativeSeekMonths(state = automat.state) { - return 0; + return state.negativeSeekMonths; +} + +/** Selects seek month count */ +export function selectSeekMonths(state = automat.state) { + return state.seekMonths ?? 0; +} + +/** Selects seek year count */ +export function selectSeekYears(state = automat.state) { + return state.seekYears ?? 0; } /** diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx index 8bb63a4..b8ad2af 100644 --- a/src/components/manifest/OrderList.jsx +++ b/src/components/manifest/OrderList.jsx @@ -3,13 +3,17 @@ import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; 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 Switch from '@mui/material/Switch'; import Skeleton from '@mui/material/Skeleton'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import { enqueueSnackbar } from 'notistack'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; @@ -211,6 +215,222 @@ class OrderRow extends PureComponent { } } +// MARK: +class OrderSearchField extends PureComponent { + constructor(props) { + super(props); + const s = orderAutomat.state; + this.state = { + input: s.filter ?? '', + onlyUnpaid: s.onlyUnpaid ?? false, + onlyUndelivered: s.onlyUndelivered ?? false, + seekMonths: s.seekMonths ?? 0, + seekYears: s.seekYears ?? 0, + negativeSeekMonths: s.negativeSeekMonths ?? 0, + }; + } + + componentDidMount() { + this.unsub = orderAutomat.subscribe(this, (state) => ({ + input: state.filter ?? '', + onlyUnpaid: state.onlyUnpaid ?? false, + onlyUndelivered: state.onlyUndelivered ?? false, + seekMonths: state.seekMonths ?? 0, + seekYears: state.seekYears ?? 0, + negativeSeekMonths: state.negativeSeekMonths ?? 0, + })); + } + + componentWillUnmount() { + this.unsub?.(); + } + + render() { + const { inputRef, onCreate } = this.props; + const { input, onlyUnpaid, onlyUndelivered, seekMonths, seekYears } = this.state; + const monthActive = seekMonths > 0; + const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`; + const yearActive = seekYears > 0; + const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`; + + return ( + + + {/* Top row: search field & create button */} + + orderAutomat.actions.filter(next)} + /> + + + + {/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */} + + {/* Left: Unpaid & Undelivered toggles */} + + orderAutomat.actions.setOnlyUnpaid(e.target.checked)} + /> + } + label={ + + Nur ungezahlte + + } + sx={{ m: 0 }} + /> + + + + orderAutomat.actions.setOnlyUndelivered(e.target.checked)} + /> + } + label={ + + Nur ungelieferte + + } + sx={{ m: 0 }} + /> + + + {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */} + + + + + + + + + + + + + ); + } +} + // MARK: export class OrderList extends PureComponent { constructor(props) { @@ -350,63 +570,53 @@ export class OrderList extends PureComponent { rowContext.onToggleDelivered = this.handleToggleDelivered; return ( - - - - - - - Nr. / Ref. - Datum - Kunde - Bezahlt - Geliefert - Pos. - Gesamt - - - } - onPageChange={this.handlePageChange} - /> - - + <> + + + + + Nr. / Ref. + Datum + Kunde + Bezahlt + Geliefert + Pos. + Gesamt + + + } + onPageChange={this.handlePageChange} + /> + + + ); } }