This commit is contained in:
seb
2026-10-13 00:33:38 +02:00
parent 1ad9b14aa6
commit 4c5ccf7f64
12 changed files with 1152 additions and 645 deletions

View File

@@ -57,10 +57,16 @@ class App extends PureComponent {
} }
if (status !== 'authed') { if (status !== 'authed') {
if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') {
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
window.history.replaceState(null, '', `/login?returnTo=${returnTo}`);
}
return <LoginPage />; return <LoginPage />;
} }
if (user?.selectedCompanyId == null) { const isOAuthAuthorize = typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
if (user?.selectedCompanyId == null && !isOAuthAuthorize) {
return <CompanySetupDialog />; return <CompanySetupDialog />;
} }

View File

@@ -30,4 +30,5 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js'; export { shipping } from './shipping.js';
export { payment } from './payment.js'; export { payment } from './payment.js';
export { account } from './account.js'; export { account } from './account.js';
export { mcp } from './mcp.js';

40
src/api/mcp.js Normal file
View File

@@ -0,0 +1,40 @@
import { http } from './http.js';
export const mcp = {
// #region GET /api/mcp/authorize — getAuthorize
// MARK: calls GET /api/mcp/authorize
async getAuthorize(searchParams) {
const query = typeof searchParams === 'string'
? (searchParams.startsWith('?') ? searchParams.slice(1) : searchParams)
: new URLSearchParams(searchParams).toString();
const url = query ? `/api/mcp/authorize?${query}` : '/api/mcp/authorize';
return await http(url);
},
// #endregion
// #region POST /api/mcp/authorize — postAuthorize
// MARK: calls POST /api/mcp/authorize
async postAuthorize(payload) {
return await http('/api/mcp/authorize', {
method: 'POST',
body: payload,
});
},
// #endregion
// #region GET /api/mcp/authorizations — listAuthorizations
// MARK: calls GET /api/mcp/authorizations
async listAuthorizations() {
return await http('/api/mcp/authorizations');
},
// #endregion
// #region DELETE /api/mcp/authorizations/:clientId — revokeAuthorization
// MARK: calls DELETE /api/mcp/authorizations/:clientId
async revokeAuthorization(clientId) {
return await http(`/api/mcp/authorizations/${encodeURIComponent(clientId)}`, {
method: 'DELETE',
});
},
// #endregion
};

View File

@@ -29,11 +29,26 @@ import KundeNeuPage from './routes/kunde/neu';
import KundeGruppenPage from './routes/kunde/gruppen'; import KundeGruppenPage from './routes/kunde/gruppen';
import KundeEditPage from './routes/kunde/_entityId'; import KundeEditPage from './routes/kunde/_entityId';
import StatisticsPage from './routes/infos/StatisticsPage'; import StatisticsPage from './routes/infos/StatisticsPage';
import OAuthAuthorizePage from './routes/oauth/OAuthAuthorizePage';
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage')); const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage')); const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
function LoginRedirect() {
const params = new URLSearchParams(window.location.search);
const returnTo = params.get('returnTo');
return <Navigate to={returnTo || '/artikel'} replace />;
}
const routes = [ const routes = [
{
path: '/oauth/authorize',
element: <OAuthAuthorizePage />,
},
{
path: '/login',
element: <LoginRedirect />,
},
{ {
path: '/', path: '/',
element: <AppShell />, element: <AppShell />,

View File

@@ -1,10 +1,10 @@
/** /**
* @file orderAutomat.js * @file orderAutomat.js
* @description Manages cached and paginated company sales orders (`GET /api/order`). * @description Manages cached and chunk-paginated company sales orders (`GET /api/order`).
* *
* Upstream Triggers: * Upstream Triggers:
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes. * - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
* - UI / Routes: On-demand calls to `ensureLoaded()`, `load()`, or `reload()`. * - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`.
*/ */
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
@@ -38,47 +38,39 @@ import authAutomat from './authAutomat';
/** /**
* @typedef {Object} OrderAutomatState * @typedef {Object} OrderAutomatState
* @property {Order[]} items Loaded orders for the current page. * @property {Order[]|null} items Pre-filled array of orders/nulls, initially null.
* @property {number} total Total unpaged count for pagination calculation. * @property {number} total Total unpaged count for pagination calculation.
* @property {number} offset Current 0-based offset.
* @property {number} limit Current page limit.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|string|null} error Last load error. * @property {Error|string|null} error Last load error.
*/ */
export const CHUNK_SIZE = 50;
const PREFETCH_CHUNKS = 2;
const initialState = { const initialState = {
items: [], items: null,
filter: '',
onlyUnpaid: false,
onlyUndelivered: false,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
total: 0, total: 0,
offset: 0,
limit: 20,
hasMore: false,
loadingMore: false,
status: 'idle', status: 'idle',
error: null, error: null,
}; };
let automat; let automat;
let inflightLoad = null; const loadedChunks = new Set();
let inflightMore = null; const inflightChunks = new Map();
const FETCH_PAGE_SIZE = 100;
// MARK: pure order helpers // MARK: pure order helpers
export function isOrderPaid(order) { export function isOrderPaid(order) {
if (!order) return false;
return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt); return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt);
} }
export function isOrderDelivered(order) { export function isOrderDelivered(order) {
if (!order) return false;
return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
} }
export function getOrderDate(order) { export function getOrderDate(order) {
if (!order) return null;
const raw = order.date || order.createdAt; const raw = order.date || order.createdAt;
if (!raw) return null; if (!raw) return null;
const d = new Date(raw); const d = new Date(raw);
@@ -87,7 +79,6 @@ export function getOrderDate(order) {
/** /**
* Calculates cutoff date for negative seek. * Calculates cutoff date for negative seek.
* Negative seek filters orders older than or equal to (today + months).
* @param {number} months Negative integer (e.g. -1 for 1 month ago, -12 for 1 year ago) * @param {number} months Negative integer (e.g. -1 for 1 month ago, -12 for 1 year ago)
* @returns {Date|null} * @returns {Date|null}
*/ */
@@ -101,7 +92,6 @@ export function getNegativeSeekCutoff(months) {
/** /**
* Formats negative seek duration in human readable German. * Formats negative seek duration in human readable German.
* e.g. -1 -> "-1 Monat", -2 -> "-2 Monate", -12 -> "-1 Jahr", -13 -> "-1 Jahr 1 Monat"
* @param {number} months * @param {number} months
* @returns {string} * @returns {string}
*/ */
@@ -120,8 +110,8 @@ export function formatNegativeSeek(months) {
return `-${parts.join(' ')}`; return `-${parts.join(' ')}`;
} }
// MARK: fetches a single page from GET /api/order // MARK: fetches a single chunk from GET /api/order
async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) { async function fetchOrdersPage(offset = 0, limit = CHUNK_SIZE) {
const res = await orderApi.list({ offset, limit }); const res = await orderApi.list({ offset, limit });
const orders = Array.isArray(res?.orders) ? res.orders : []; const orders = Array.isArray(res?.orders) ? res.orders : [];
const total = Number(res?.total ?? orders.length); const total = Number(res?.total ?? orders.length);
@@ -129,9 +119,9 @@ async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) {
} }
/** /**
* Client-side filter over number, external reference, customer name, paid/delivered flags, and negative seek. * Client-side filter helper over loaded orders.
* @param {Order[]} items * @param {Order[]} items
* @param {string|object} filterOrOpts String filter query or options object { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } * @param {string|object} filterOrOpts String filter query or options object
* @param {(order: Order) => string} [nameOf] Customer name resolver. * @param {(order: Order) => string} [nameOf] Customer name resolver.
*/ */
export function filterOrders(items, filterOrOpts, nameOf) { export function filterOrders(items, filterOrOpts, nameOf) {
@@ -147,11 +137,11 @@ export function filterOrders(items, filterOrOpts, nameOf) {
const cutoff = getNegativeSeekCutoff(seekMonths); const cutoff = getNegativeSeekCutoff(seekMonths);
return items.filter((o) => { return items.filter((o) => {
if (!o) return false;
if (onlyUnpaid && isOrderPaid(o)) return false; if (onlyUnpaid && isOrderPaid(o)) return false;
if (onlyUndelivered && isOrderDelivered(o)) return false; if (onlyUndelivered && isOrderDelivered(o)) return false;
if (cutoff) { if (cutoff) {
const orderDate = getOrderDate(o); const orderDate = getOrderDate(o);
// Older than cutoff point: order date <= cutoff
if (!orderDate || orderDate.getTime() > cutoff.getTime()) { if (!orderDate || orderDate.getTime() > cutoff.getTime()) {
return false; return false;
} }
@@ -166,230 +156,173 @@ export function filterOrders(items, filterOrOpts, nameOf) {
} }
const actions = { const actions = {
/** Sets the active search filter. */ // MARK: returns current items and triggers chunk 0 if not loaded
filter(query) { getItems() {
automat.setState({ filter: String(query ?? '') }); if (automat.state.items === null) {
}, void actions.requireChunk(0);
return null;
/** Sets whether to filter for only unpaid orders. */
setOnlyUnpaid(onlyUnpaid) {
automat.setState({ onlyUnpaid: Boolean(onlyUnpaid) });
},
/** Sets whether to filter for only undelivered orders. */
setOnlyUndelivered(onlyUndelivered) {
automat.setState({ onlyUndelivered: Boolean(onlyUndelivered) });
},
/** Increments seek by 1 month. */
addSeekMonth() {
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
const seekYears = automat.state.seekYears ?? 0;
const totalMonths = -(seekMonths + seekYears * 12);
automat.setState({ seekMonths, negativeSeekMonths: totalMonths });
},
/** Increments seek by 1 year. */
addSeekYear() {
const seekMonths = automat.state.seekMonths ?? 0;
const seekYears = (automat.state.seekYears ?? 0) + 1;
const totalMonths = -(seekMonths + seekYears * 12);
automat.setState({ seekYears, negativeSeekMonths: totalMonths });
},
/** Cumulative negative seek: adds months (e.g. -1 or -12) to current negativeSeekMonths. */
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) });
} }
return automat.state.items;
}, },
/** Resets negative seek back to 0 (all dates). */ // MARK: ensures chunkIndex and lookahead chunks are loaded/loading
resetNegativeSeek() { requireChunk(chunkIndex) {
automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 }); const start = Math.max(0, Number(chunkIndex) || 0);
}, const p = actions.loadChunk(start);
for (let c = start + 1; c <= start + PREFETCH_CHUNKS; c++) {
/** Resets all filters. */ const total = automat.state.total;
resetFilters() { if (total > 0 && c * CHUNK_SIZE >= total) {
automat.setState({ break;
filter: '', }
onlyUnpaid: false, void actions.loadChunk(c);
onlyUndelivered: false,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
});
},
/**
* 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, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = automat.state; return p;
return filterOrders(items, { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths }, nameOf);
}, },
/** // MARK: fetches a single chunk and populates prefilled array
* Loads the initial 100 orders, deduplicating concurrent callers. loadChunk(chunkIndex) {
* const index = Math.max(0, Number(chunkIndex) || 0);
* @returns {Promise<Order[]>} if (loadedChunks.has(index)) {
*/ return Promise.resolve(automat.state.items);
load() { }
if (inflightLoad) return inflightLoad; if (inflightChunks.has(index)) {
automat.setState({ status: 'loading', error: null }); return inflightChunks.get(index);
inflightLoad = fetchOrdersPage(0, FETCH_PAGE_SIZE) }
const offset = index * CHUNK_SIZE;
const limit = CHUNK_SIZE;
if (automat.state.items === null && automat.state.status === 'idle') {
automat.setState({ status: 'loading', error: null });
}
const promise = fetchOrdersPage(offset, limit)
.then(({ orders, total }) => { .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) {
const next = new Array(currentTotal).fill(null);
const copyLen = Math.min(items.length, currentTotal);
for (let i = 0; i < copyLen; i++) {
next[i] = items[i];
}
items = next;
} else {
// Shallow copy to notify subscribers and PureComponents
items = [...items];
}
// 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];
}
}
loadedChunks.add(index);
automat.setState({ automat.setState({
items: orders, items,
total, total: currentTotal,
offset: 0,
limit: FETCH_PAGE_SIZE,
hasMore: orders.length < total && orders.length >= FETCH_PAGE_SIZE,
loadingMore: false,
status: 'ready', status: 'ready',
error: null, error: null,
}); });
return orders;
})
.catch((error) => {
automat.setState({ status: 'failed', error });
throw error;
})
.finally(() => { inflightLoad = null; });
return inflightLoad;
},
/** // Trigger lookahead prefetching if first chunk just arrived
* Loads the next batch of 100 orders when pagination approaches the end. if (index === 0) {
* for (let c = 1; c <= PREFETCH_CHUNKS; c++) {
* @returns {Promise<Order[]>} if (c * CHUNK_SIZE < currentTotal) {
*/ void actions.loadChunk(c);
loadMore(minNeeded) { }
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 }); return items;
const offset = items.length;
const fetchLimit =
minNeeded && minNeeded > offset
? Math.max(FETCH_PAGE_SIZE, minNeeded - offset)
: FETCH_PAGE_SIZE;
inflightMore = fetchOrdersPage(offset, fetchLimit)
.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) => { .catch((err) => {
console.error('[orderAutomat] loadMore error:', err); console.error(`[orderAutomat] loadChunk(${index}) error:`, err);
automat.setState({ loadingMore: false }); if (automat.state.items === null) {
return automat.state.items; automat.setState({ status: 'failed', error: err });
}
throw err;
}) })
.finally(() => { .finally(() => {
inflightMore = null; inflightChunks.delete(index);
}); });
return inflightMore; inflightChunks.set(index, promise);
return promise;
}, },
/** // MARK: initial load
* Silently re-fetches loaded orders without flashing loading state. load() {
* return actions.loadChunk(0);
* @returns {Promise<Order[]>}
*/
async reload() {
try {
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 });
throw error;
}
}, },
/** // MARK: ensure loaded
* Ensures orders are loaded once the selected company is known.
*
* @returns {Promise<Order[]>}
*/
ensureLoaded() { ensureLoaded() {
if (automat.state.status === 'ready') { if (automat.state.items !== null) {
return Promise.resolve(automat.state.items); return Promise.resolve(automat.state.items);
} }
return actions.load(); return actions.loadChunk(0);
}, },
/** // MARK: reload all previously loaded chunks
* Replaces cached orders without network round-trip. async reload() {
* const chunksToReload = Array.from(loadedChunks);
* @param {Order[]} items loadedChunks.clear();
* @returns {Order[]} inflightChunks.clear();
*/ 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) {
void actions.loadChunk(c);
}
}
return automat.state.items;
},
// MARK: replace items directly
replace(items) { replace(items) {
loadedChunks.clear();
inflightChunks.clear();
const count = Array.isArray(items) ? items.length : 0;
automat.setState({ automat.setState({
items, items,
total: items.length, total: count,
hasMore: false,
loadingMore: false,
status: 'ready', status: 'ready',
error: null, error: null,
}); });
return items; return items;
}, },
/** // MARK: patches an order locally in the automat items cache
* Patches an order locally in the automat items cache.
*
* @param {number|string} id Order ID.
* @param {Partial<Order>} patch Partial properties to update.
* @returns {Order[]}
*/
patchOrder(id, patch) { patchOrder(id, patch) {
const items = automat.state.items.map((o) => if (!Array.isArray(automat.state.items)) return automat.state.items;
o.id === id ? { ...o, ...patch } : o, let changed = false;
); const items = automat.state.items.map((o) => {
automat.setState({ items }); if (o && o.id === id) {
changed = true;
return { ...o, ...patch };
}
return o;
});
if (changed) {
automat.setState({ items });
}
return items; return items;
}, },
/** // MARK: resets state
* Resets to idle state (called on logout).
*/
reset() { reset() {
inflightLoad = null; loadedChunks.clear();
inflightMore = null; inflightChunks.clear();
automat.setState(initialState); automat.setState(initialState);
}, },
}; };
@@ -397,7 +330,20 @@ const actions = {
export const orderAutomat = new Automat(initialState, actions, { name: 'order' }); export const orderAutomat = new Automat(initialState, actions, { name: 'order' });
automat = orderAutomat; automat = orderAutomat;
// Reset on logout or company change (lazy-loaded on demand by views via ensureLoaded). // MARK: exported methods for direct invocation
export function getItems() {
return actions.getItems();
}
export function requireChunk(chunkIndex) {
return actions.requireChunk(chunkIndex);
}
orderAutomat.getItems = getItems;
orderAutomat.requireChunk = requireChunk;
orderAutomat.CHUNK_SIZE = CHUNK_SIZE;
// Reset on logout or company change
let lastCompanyId = null; let lastCompanyId = null;
authAutomat.subscribe((auth) => { authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId ?? null; const companyId = auth.user?.selectedCompanyId ?? null;
@@ -424,59 +370,32 @@ export function selectOrderTotal(state = automat.state) {
return state.total; 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;
}
/** Selects unpaid filter */ /** Selects unpaid filter */
export function selectOnlyUnpaid(state = automat.state) { export function selectOnlyUnpaid(state = automat.state) {
return state.onlyUnpaid; return false;
} }
/** Selects undelivered filter */ /** Selects undelivered filter */
export function selectOnlyUndelivered(state = automat.state) { export function selectOnlyUndelivered(state = automat.state) {
return state.onlyUndelivered; return false;
} }
/** Selects negative seek offset in months */ /** Selects negative seek offset in months */
export function selectNegativeSeekMonths(state = automat.state) { export function selectNegativeSeekMonths(state = automat.state) {
return state.negativeSeekMonths; return 0;
}
/** 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;
} }
/** /**
* Calculates client pagination metadata. * Calculates client pagination metadata.
* Current Page: Math.floor(offset / limit) + 1
* Total Pages: Math.ceil(total / limit)
* Next Offset: (page - 1) * limit
*/ */
export function selectOrderPagination(state = automat.state) { export function selectOrderPagination(state = automat.state) {
const total = state.total ?? 0; const total = state.total ?? 0;
const offset = state.offset ?? 0;
const limit = state.limit || 20;
const currentPage = Math.floor(offset / limit) + 1;
const pageCount = Math.max(1, Math.ceil(total / limit));
return { return {
total, total,
offset, offset: 0,
limit, limit: CHUNK_SIZE,
currentPage, currentPage: 1,
pageCount, pageCount: Math.max(1, Math.ceil(total / CHUNK_SIZE)),
}; };
} }

View File

@@ -52,6 +52,11 @@ class LoginPage extends PureComponent {
this.setState({ busy: true }); this.setState({ busy: true });
await authAutomat.actions.loginWithGoogle(idToken); await authAutomat.actions.loginWithGoogle(idToken);
await bootstrapAuthMe(); await bootstrapAuthMe();
const params = new URLSearchParams(window.location.search);
const returnTo = params.get('returnTo');
if (returnTo) {
window.location.replace(returnTo);
}
} catch (err) { } catch (err) {
if (err instanceof GoogleSignInCancelledError) return; if (err instanceof GoogleSignInCancelledError) return;
logError('[login] Google sign-in failed', { logError('[login] Google sign-in failed', {

View File

@@ -3,25 +3,20 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress'; 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 IconButton from '@mui/material/IconButton';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Checkbox from '@mui/material/Checkbox'; 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 DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace'; import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import orderAutomat, { import orderAutomat, {
filterOrders, CHUNK_SIZE,
isOrderPaid, isOrderPaid,
isOrderDelivered, isOrderDelivered,
getNegativeSeekCutoff,
formatNegativeSeek,
} from '../../automats/orderAutomat'; } from '../../automats/orderAutomat';
import { order as orderApi } from '../../api'; import { order as orderApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
@@ -82,13 +77,42 @@ class OrderRow extends PureComponent {
} }
render() { render() {
const { item: order } = this.props; const { item: order, disabled } = this.props;
// Data-free probe (FillSpace measures the row height with `item` undefined). // Data-free probe (FillSpace measures the row height with `item` undefined).
if (!order) { if (!order) {
if (disabled) {
return (
<ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
return ( return (
<ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}> <ListItemButton disabled divider sx={rowSx}>
<Typography variant="body2">&nbsp;</Typography> <Box sx={{ width: 120, flexShrink: 0 }}>
<Skeleton variant="text" width={80} height={20} />
<Skeleton variant="text" width={50} height={16} />
</Box>
<Box sx={{ width: 100, flexShrink: 0 }}>
<Skeleton variant="text" width={70} height={20} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Skeleton variant="text" width="60%" height={20} />
</Box>
<Box sx={{ width: 72, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="circular" width={20} height={20} />
</Box>
<Box sx={{ width: 72, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="circular" width={20} height={20} />
</Box>
<Box sx={{ width: 40, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="rounded" width={28} height={20} />
</Box>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', justifyContent: 'flex-end' }}>
<Skeleton variant="text" width={60} height={20} />
</Box>
<Box sx={{ width: 40, flexShrink: 0 }} />
</ListItemButton> </ListItemButton>
); );
} }
@@ -187,239 +211,13 @@ class OrderRow extends PureComponent {
} }
} }
// MARK: <OrderSearchField/>
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 (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={input}
onChange={(next) => orderAutomat.actions.filter(next)}
/>
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
</Box>
{/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{/* Left: Unpaid & Undelivered toggles */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<FormControlLabel
control={
<Switch
size="small"
color="warning"
checked={onlyUnpaid}
onChange={(e) => orderAutomat.actions.setOnlyUnpaid(e.target.checked)}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: onlyUnpaid ? 600 : 400, userSelect: 'none' }}>
Nur ungezahlte
</Typography>
}
sx={{ m: 0 }}
/>
<Divider orientation="vertical" flexItem sx={{ my: 0.5 }} />
<FormControlLabel
control={
<Switch
size="small"
color="primary"
checked={onlyUndelivered}
onChange={(e) => orderAutomat.actions.setOnlyUndelivered(e.target.checked)}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: onlyUndelivered ? 600 : 400, userSelect: 'none' }}>
Nur ungelieferte
</Typography>
}
sx={{ m: 0 }}
/>
</Box>
{/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => orderAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => orderAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => orderAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
</Box>
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <OrderList/> // MARK: <OrderList/>
export class OrderList extends PureComponent { export class OrderList extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const s = orderAutomat.state; // getItems() returns current items or null and triggers loading of the first chunk
this.state = { this.state = {
status: s.status === 'ready' ? 'ready' : 'loading', items: orderAutomat.getItems(),
error: s.error ? backendErrorFrom(s.error) : null,
filteredItems: orderAutomat.actions.getOrLoad(),
items: s.items ?? [],
total: s.total ?? 0,
filter: s.filter ?? '',
onlyUnpaid: s.onlyUnpaid ?? false,
onlyUndelivered: s.onlyUndelivered ?? false,
negativeSeekMonths: s.negativeSeekMonths ?? 0,
hasMore: s.hasMore ?? false,
loadingMore: s.loadingMore ?? false,
partiesById: {}, partiesById: {},
busyId: null, busyId: null,
}; };
@@ -432,7 +230,74 @@ export class OrderList extends PureComponent {
this.handleTogglePaid = this.handleTogglePaid.bind(this); this.handleTogglePaid = this.handleTogglePaid.bind(this);
this.handleToggleDelivered = this.handleToggleDelivered.bind(this); this.handleToggleDelivered = this.handleToggleDelivered.bind(this);
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this);
this.nameOf = this.nameOf.bind(this); }
componentDidMount() {
this._cancelled = false;
// Subscribe to items only
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items,
}));
void this.loadParties();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
}
async loadParties() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
}
} catch {
// Non-fatal: names fall back to ids.
}
}
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
orderAutomat.requireChunk(startChunk);
if (endChunk !== startChunk) {
orderAutomat.requireChunk(endChunk);
}
}
handleRetry() {
void orderAutomat.actions.requireChunk(0).catch(() => {});
}
handleCreate() {
this.props.navigate(cfg.createPath);
}
handleOpen(order) {
this.props.navigate(cfg.editPath(order.id));
}
async handleDelete(order) {
if (this.state.busyId != null) return;
if (!window.confirm(cfg.confirmDelete(order.number))) return;
this.setState({ busyId: order.id });
try {
await orderApi.delete(order.id);
await orderAutomat.actions.reload();
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
if (!this._cancelled) this.setState({ busyId: null });
}
} }
async handleTogglePaid(order, checked) { async handleTogglePaid(order, checked) {
@@ -463,122 +328,19 @@ export class OrderList extends PureComponent {
} }
} }
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const loadedPages = Math.ceil(this.state.items.length / pageSize);
// Load next batch when the pagination comes close to the end of loaded items (within 2 pages) or beyond.
if (loadedPages - page <= 2 || page > loadedPages) {
void orderAutomat.actions.loadMore(page * pageSize);
}
}
componentDidMount() {
this._cancelled = false;
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items ?? [],
total: state.total ?? 0,
filter: state.filter ?? '',
onlyUnpaid: state.onlyUnpaid ?? false,
onlyUndelivered: state.onlyUndelivered ?? false,
negativeSeekMonths: state.negativeSeekMonths ?? 0,
hasMore: state.hasMore ?? false,
loadingMore: state.loadingMore ?? false,
filteredItems: filterOrders(state.items ?? [], {
filter: state.filter,
onlyUnpaid: state.onlyUnpaid,
onlyUndelivered: state.onlyUndelivered,
negativeSeekMonths: state.negativeSeekMonths,
}, this.nameOf),
status: state.status,
error: state.error ? backendErrorFrom(state.error) : null,
}));
void this.loadAll();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
}
async loadAll() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
const partiesById = cfg.partiesByIdFromList(parties);
this.setState((prev) => ({
partiesById,
filteredItems: filterOrders(prev.items, {
filter: prev.filter,
onlyUnpaid: prev.onlyUnpaid,
onlyUndelivered: prev.onlyUndelivered,
negativeSeekMonths: prev.negativeSeekMonths,
}, (o) => partyDisplayName('order', partiesById[o.entityId])),
}));
}
} catch {
// Non-fatal: names fall back to ids.
}
}
nameOf(order) {
const party = this.state.partiesById[order.entityId];
return partyDisplayName('order', party);
}
handleRetry() {
void orderAutomat.actions.load().catch(() => { });
}
handleCreate() {
this.props.navigate(cfg.createPath);
}
handleOpen(order) {
this.props.navigate(cfg.editPath(order.id));
}
async handleDelete(order) {
if (this.state.busyId != null) return;
if (!window.confirm(cfg.confirmDelete(order.number))) return;
this.setState({ busyId: order.id });
try {
await orderApi.delete(order.id);
await orderAutomat.actions.reload();
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
if (!this._cancelled) this.setState({ busyId: null });
}
}
render() { render() {
const { const { items, partiesById, busyId } = this.state;
status,
error,
items,
total,
filter,
onlyUnpaid,
onlyUndelivered,
negativeSeekMonths,
hasMore,
loadingMore,
filteredItems,
partiesById,
busyId,
} = this.state;
if (status === 'loading' || status === 'idle') { if (items === null) {
if (orderAutomat.state.status === 'failed') {
return <BackendError error={backendErrorFrom(orderAutomat.state.error)} onRetry={this.handleRetry} />;
}
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
); );
} }
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
rowContext.partiesById = partiesById; rowContext.partiesById = partiesById;
rowContext.busyId = busyId; rowContext.busyId = busyId;
@@ -587,58 +349,64 @@ export class OrderList extends PureComponent {
rowContext.onTogglePaid = this.handleTogglePaid; rowContext.onTogglePaid = this.handleTogglePaid;
rowContext.onToggleDelivered = this.handleToggleDelivered; rowContext.onToggleDelivered = this.handleToggleDelivered;
const rows = filteredItems ?? [];
const isFiltered = Boolean(filter || onlyUnpaid || onlyUndelivered || negativeSeekMonths < 0);
const displayTotal = isFiltered ? rows.length : (total || items.length);
return ( return (
<> <PanelTileGrid>
<OrderSearchField inputRef={this.searchRef} onCreate={this.handleCreate} /> <Box
<PanelTileGrid> ref={this.searchRef}
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0 }}> sx={{
<FillSpace gridColumn: '1 / -1',
aboveRef={[this.searchRef]} display: 'flex',
reserveBottom={32} justifyContent: 'flex-end',
minPageSize={8} pb: 1.5,
minHeight={0} }}
items={rows} >
totalCount={displayTotal} <Button variant="contained" onClick={this.handleCreate}>
renderItem={OrderRow} {cfg.createButton}
virtualScrollbar </Button>
header={ </Box>
<Box <PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0 }}>
sx={{ <FillSpace
display: 'flex', aboveRef={[this.searchRef]}
alignItems: 'center', reserveBottom={32}
height: 36, minPageSize={8}
minHeight: 36, minHeight={0}
px: 2, items={items}
gap: 1.5, totalCount={items.length}
borderBottom: '1px solid', renderItem={OrderRow}
borderColor: 'divider', virtualScrollbar
color: 'text.secondary', header={
typography: 'caption', <Box
fontWeight: 600, sx={{
textTransform: 'uppercase', display: 'flex',
letterSpacing: '0.04em', alignItems: 'center',
userSelect: 'none', height: 36,
}} minHeight: 36,
> px: 2,
<Box sx={{ width: 120, flexShrink: 0 }}>Nr. / Ref.</Box> gap: 1.5,
<Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box> borderBottom: '1px solid',
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box> borderColor: 'divider',
<Box sx={{ width: 72, flexShrink: 0, textAlign: 'center' }}>Bezahlt</Box> color: 'text.secondary',
<Box sx={{ width: 72, flexShrink: 0, textAlign: 'center' }}>Geliefert</Box> typography: 'caption',
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box> fontWeight: 600,
<Box sx={{ width: 96, flexShrink: 0, textAlign: 'right' }}>Gesamt</Box> textTransform: 'uppercase',
<Box sx={{ width: 40, flexShrink: 0 }} /> letterSpacing: '0.04em',
</Box> userSelect: 'none',
} }}
onPageChange={this.handlePageChange} >
/> <Box sx={{ width: 120, flexShrink: 0 }}>Nr. / Ref.</Box>
</PanelTile> <Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box>
</PanelTileGrid> <Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
</> <Box sx={{ width: 72, flexShrink: 0, textAlign: 'center' }}>Bezahlt</Box>
<Box sx={{ width: 72, flexShrink: 0, textAlign: 'center' }}>Geliefert</Box>
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box>
<Box sx={{ width: 96, flexShrink: 0, textAlign: 'right' }}>Gesamt</Box>
<Box sx={{ width: 40, flexShrink: 0 }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
</PanelTile>
</PanelTileGrid>
); );
} }
} }

View File

@@ -398,7 +398,7 @@ export class OrderPaymentList extends PureComponent {
extractAllPayments(orders = this.state.items) { extractAllPayments(orders = this.state.items) {
const list = []; const list = [];
for (const order of orders) { for (const order of orders) {
if (!Array.isArray(order.payments)) continue; if (!order || !Array.isArray(order.payments)) continue;
for (const pay of order.payments) { for (const pay of order.payments) {
list.push({ list.push({
...pay, ...pay,

View File

@@ -486,7 +486,7 @@ export class ShipmentList extends PureComponent {
extractAllShipments(orders = this.state.items) { extractAllShipments(orders = this.state.items) {
const list = []; const list = [];
for (const order of orders) { for (const order of orders) {
if (!Array.isArray(order.shipments)) continue; if (!order || !Array.isArray(order.shipments)) continue;
for (const sh of order.shipments) { for (const sh of order.shipments) {
list.push({ list.push({
...sh, ...sh,

View File

@@ -0,0 +1,312 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import RefreshIcon from '@mui/icons-material/Refresh';
import SecurityIcon from '@mui/icons-material/Security';
import ComputerIcon from '@mui/icons-material/Computer';
import KeyIcon from '@mui/icons-material/Key';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined';
import { enqueueSnackbar } from 'notistack';
import { mcp } from '../../api';
import { notifyError } from '../../utils/notify';
import { SURFACE_BASE, SURFACE_RAISED, BORDER, DARK_2 } from '../../colors';
export default class McpAuthorizationsPanel extends Component {
constructor(props) {
super(props);
this.state = {
status: 'loading',
authorizations: [],
error: null,
confirmTarget: null,
revoking: false,
};
this.loadAuthorizations = this.loadAuthorizations.bind(this);
this.handleRevokeClick = this.handleRevokeClick.bind(this);
this.handleConfirmRevoke = this.handleConfirmRevoke.bind(this);
this.handleCloseConfirm = this.handleCloseConfirm.bind(this);
}
componentDidMount() {
void this.loadAuthorizations();
}
// #region loadAuthorizations
// MARK: calls GET /api/mcp/authorizations from ./api
async loadAuthorizations() {
this.setState({ status: 'loading', error: null });
try {
const res = await mcp.listAuthorizations();
this.setState({
authorizations: res?.authorizations || [],
status: 'ready',
});
} catch (err) {
this.setState({
status: 'error',
error: err?.message || 'Fehler beim Laden der MCP-Autorisierungen',
});
notifyError(err);
}
}
// #endregion
handleRevokeClick(client) {
this.setState({ confirmTarget: client });
}
handleCloseConfirm() {
if (this.state.revoking) return;
this.setState({ confirmTarget: null });
}
// #region handleConfirmRevoke
// MARK: calls DELETE /api/mcp/authorizations/:clientId from ./api
async handleConfirmRevoke() {
const { confirmTarget } = this.state;
if (!confirmTarget) return;
this.setState({ revoking: true });
try {
await mcp.revokeAuthorization(confirmTarget.clientId);
enqueueSnackbar(`Autorisierung für "${confirmTarget.clientName}" erfolgreich widerrufen.`, {
variant: 'success',
});
this.setState({ confirmTarget: null, revoking: false });
await this.loadAuthorizations();
} catch (err) {
this.setState({ revoking: false });
notifyError(err);
}
}
// #endregion
render() {
const { status, authorizations, error, confirmTarget, revoking } = this.state;
return (
<Box sx={{ maxWidth: 860 }}>
{/* Header */}
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" sx={{ mb: 2.5 }}>
<Box>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
<SecurityIcon sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
MCP-Autorisierungen & Sicherheit
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: DARK_2 }}>
Verwalten Sie externe MCP-Clients (z. B. Visual Studio Code, Cursor), die Zugriff auf Dashback Tools haben.
</Typography>
</Box>
<IconButton
size="small"
onClick={this.loadAuthorizations}
disabled={status === 'loading'}
aria-label="Aktualisieren"
>
<RefreshIcon fontSize="small" />
</IconButton>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }} action={
<Button color="inherit" size="small" onClick={this.loadAuthorizations}>
Wiederholen
</Button>
}>
{error}
</Alert>
)}
{status === 'loading' && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={36} />
</Box>
)}
{status === 'ready' && authorizations.length === 0 && (
<Card
variant="outlined"
sx={{
p: 4,
textAlign: 'center',
bgcolor: SURFACE_BASE,
borderColor: BORDER,
borderRadius: 2,
}}
>
<ComputerIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1, opacity: 0.6 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
Keine aktiven MCP-Clients
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 440, mx: 'auto' }}>
Derzeit sind keine externen Anwendungen autorisiert. Wenn Sie einen MCP-Client wie VS Code mit Dash verbinden, wird er hier aufgeführt.
</Typography>
</Card>
)}
{status === 'ready' && authorizations.length > 0 && (
<Stack spacing={2}>
{authorizations.map((auth) => {
const authorizedDate = auth.authorizedAt
? new Date(auth.authorizedAt).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '–';
return (
<Card
key={auth.clientId || auth.clientDbId}
variant="outlined"
sx={{
borderRadius: 2,
borderColor: BORDER,
bgcolor: 'background.paper',
transition: 'box-shadow 0.2s ease',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
},
}}
>
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={2}
>
<Stack direction="row" spacing={2} alignItems="center">
<Box
sx={{
width: 44,
height: 44,
borderRadius: '10px',
bgcolor: SURFACE_RAISED,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'primary.main',
flexShrink: 0,
}}
>
<ComputerIcon />
</Box>
<Box>
<Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap">
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{auth.clientName || 'Unbenannter Client'}
</Typography>
{auth.scope && (
<Chip
size="small"
label={auth.scope}
variant="outlined"
sx={{
height: 20,
fontSize: '0.72rem',
borderColor: 'primary.light',
color: 'primary.main',
}}
/>
)}
</Stack>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'monospace',
display: 'block',
mt: 0.25,
}}
>
Client ID: {auth.clientId}
</Typography>
<Stack direction="row" spacing={2} sx={{ mt: 0.75 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Autorisiert: <strong>{authorizedDate}</strong>
</Typography>
{auth.tokenCount != null && (
<Stack direction="row" spacing={0.5} alignItems="center">
<KeyIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Aktive Tokens: <strong>{auth.tokenCount}</strong>
</Typography>
</Stack>
)}
</Stack>
</Box>
</Stack>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteOutlineIcon />}
onClick={() => this.handleRevokeClick(auth)}
sx={{ flexShrink: 0, textTransform: 'none' }}
>
Widerrufen
</Button>
</Stack>
</CardContent>
</Card>
);
})}
</Stack>
)}
{/* Confirmation Dialog */}
<Dialog
open={Boolean(confirmTarget)}
onClose={this.handleCloseConfirm}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ fontWeight: 600 }}>
Autorisierung widerrufen?
</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie den Zugriff für <strong>{confirmTarget?.clientName}</strong> wirklich widerrufen?
Alle bestehenden Sitzungen und Tokens für diesen MCP-Client werden sofort ungültig.
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={this.handleCloseConfirm} disabled={revoking}>
Abbrechen
</Button>
<Button
onClick={this.handleConfirmRevoke}
color="error"
variant="contained"
disabled={revoking}
startIcon={revoking ? <CircularProgress size={16} color="inherit" /> : <DeleteOutlineIcon />}
>
{revoking ? 'Widerrufe...' : 'Ja, widerrufen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
}

View File

@@ -19,6 +19,8 @@ import PrinterSettingsPanel from './PrinterSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel'; import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel'; import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel'; import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
import SecurityIcon from '@mui/icons-material/Security';
import { SURFACE_BASE } from '../../colors'; import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = { const TAB_KEYS = {
@@ -28,6 +30,7 @@ const TAB_KEYS = {
printers: 'printers', printers: 'printers',
importExport: 'importExport', importExport: 'importExport',
posterminal: 'posterminal', posterminal: 'posterminal',
security: 'security',
}; };
const TABS = [ const TABS = [
@@ -37,6 +40,7 @@ const TABS = [
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel }, { id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel }, { id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel }, { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
]; ];
export default class SettingsDialog extends Component { export default class SettingsDialog extends Component {

View File

@@ -0,0 +1,437 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Chip from '@mui/material/Chip';
import Alert from '@mui/material/Alert';
import Divider from '@mui/material/Divider';
import Avatar from '@mui/material/Avatar';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import ComputerIcon from '@mui/icons-material/Computer';
import SecurityIcon from '@mui/icons-material/Security';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import ConstructionIcon from '@mui/icons-material/Construction';
import { mcp } from '../../api';
import authAutomat from '../../automats/authAutomat';
import { notifyError } from '../../utils/notify';
import quixposLogo from '../../assets/quixpos-logo.png';
import {
MAIN_BACKGROUND,
SURFACE_BASE,
SURFACE_RAISED,
BORDER,
BORDER_STRONG,
DARK_2,
DARK_4,
} from '../../colors';
export default class OAuthAuthorizePage extends Component {
constructor(props) {
super(props);
this.state = {
authStatus: authAutomat.state.status,
loading: true,
submitting: null, // 'allow' | 'deny' | null
consent: null,
error: null,
};
this.fetchConsent = this.fetchConsent.bind(this);
this.handleAllow = this.handleAllow.bind(this);
this.handleDeny = this.handleDeny.bind(this);
this.redirectToLogin = this.redirectToLogin.bind(this);
}
componentDidMount() {
document.title = 'Zugriffsanfrage autorisieren * Dash MCP';
this.unsubAuth = authAutomat.subscribe(this, (state) => {
const prevStatus = this.state.authStatus;
const nextStatus = state.status;
if (prevStatus !== nextStatus) {
this.setState({ authStatus: nextStatus });
if (nextStatus === 'authed') {
void this.fetchConsent();
} else if (nextStatus === 'anon') {
this.redirectToLogin();
}
}
});
if (this.state.authStatus === 'authed') {
void this.fetchConsent();
} else if (this.state.authStatus === 'anon') {
this.redirectToLogin();
}
}
componentWillUnmount() {
this.unsubAuth?.();
}
redirectToLogin() {
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
window.location.replace(`/login?returnTo=${returnTo}`);
}
// #region fetchConsent
// MARK: calls GET /api/mcp/authorize from ./api
async fetchConsent() {
this.setState({ loading: true, error: null });
try {
const search = window.location.search;
const data = await mcp.getAuthorize(search);
this.setState({ consent: data, loading: false });
} catch (err) {
this.setState({
loading: false,
error: err?.message || 'Fehler beim Laden der Autorisierungsdetails',
});
notifyError(err);
}
}
// #endregion
// #region handleAllow
// MARK: calls POST /api/mcp/authorize (action: allow)
async handleAllow() {
const { consent, submitting } = this.state;
if (!consent || submitting) return;
const params = consent.params || {};
this.setState({ submitting: 'allow', error: null });
try {
const payload = {
client_id: params.clientId || consent.client?.clientId,
redirect_uri: params.redirectUri,
code_challenge: params.codeChallenge,
code_challenge_method: params.codeChallengeMethod,
state: params.state,
scope: params.scope || consent.client?.scope || 'mcp:tools',
action: 'allow',
};
const res = await mcp.postAuthorize(payload);
const redirectUrl = res?.redirectUrl || res?.data?.redirectUrl;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
throw new Error('Keine Weiterleitungs-URL vom Server erhalten.');
}
} catch (err) {
this.setState({ submitting: null });
notifyError(err);
}
}
// #endregion
// #region handleDeny
// MARK: calls POST /api/mcp/authorize (action: deny)
async handleDeny() {
const { consent, submitting } = this.state;
if (!consent || submitting) return;
const params = consent.params || {};
this.setState({ submitting: 'deny', error: null });
try {
const payload = {
client_id: params.clientId || consent.client?.clientId,
redirect_uri: params.redirectUri,
state: params.state,
action: 'deny',
};
const res = await mcp.postAuthorize(payload);
const redirectUrl = res?.redirectUrl || res?.data?.redirectUrl;
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
throw new Error('Keine Weiterleitungs-URL vom Server erhalten.');
}
} catch (err) {
this.setState({ submitting: null });
notifyError(err);
}
}
// #endregion
render() {
const { authStatus, loading, submitting, consent, error } = this.state;
if (authStatus === 'loading' || (authStatus === 'authed' && loading)) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: MAIN_BACKGROUND,
gap: 2,
}}
>
<CircularProgress size={44} />
<Typography variant="body2" sx={{ color: DARK_2 }}>
Autorisierungsanfrage wird geladen...
</Typography>
</Box>
);
}
const client = consent?.client || {};
const user = consent?.user || authAutomat.state.user || {};
const params = consent?.params || {};
const clientName = client.clientName || 'MCP-Anwendung';
const scope = params.scope || client.scope || 'mcp:tools';
return (
<Box
sx={{
minHeight: '100vh',
bgcolor: MAIN_BACKGROUND,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
}}
>
<Card
sx={{
maxWidth: 520,
width: '100%',
borderRadius: 3,
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.12)',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
{/* Top Brand Header */}
<Box
sx={{
py: 2.5,
px: 3,
bgcolor: SURFACE_BASE,
borderBottom: '1px solid',
borderColor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
component="img"
src={quixposLogo}
alt="QuixPOS"
sx={{ height: 28, objectFit: 'contain' }}
/>
<Typography variant="subtitle2" sx={{ fontWeight: 700, letterSpacing: '0.04em' }}>
DASH MCP
</Typography>
</Stack>
<Chip
icon={<SecurityIcon sx={{ '&&': { fontSize: 16 } }} />}
label="OAuth 2.1"
size="small"
variant="outlined"
sx={{
fontSize: '0.75rem',
borderColor: BORDER_STRONG,
color: 'text.secondary',
}}
/>
</Box>
<CardContent sx={{ p: 3.5 }}>
{error ? (
<Stack spacing={2.5}>
<Alert severity="error">
{error}
</Alert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Die Autorisierungsanfrage ist ungültig oder abgelaufen. Bitte starten Sie die Anmeldung in Ihrem MCP-Client erneut.
</Typography>
<Button
variant="outlined"
onClick={() => window.location.reload()}
sx={{ alignSelf: 'flex-start' }}
>
Erneut versuchen
</Button>
</Stack>
) : (
<Stack spacing={3}>
{/* Visual handshake illustration */}
<Stack direction="row" alignItems="center" justifyContent="center" spacing={2.5} sx={{ py: 1 }}>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '14px',
bgcolor: SURFACE_RAISED,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'primary.main',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<ComputerIcon sx={{ fontSize: 30 }} />
</Box>
<SyncAltIcon sx={{ color: DARK_2, fontSize: 24 }} />
<Box
sx={{
width: 56,
height: 56,
borderRadius: '14px',
bgcolor: 'primary.main',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
boxShadow: '0 4px 12px rgba(15, 118, 110, 0.3)',
}}
>
<ConstructionIcon sx={{ fontSize: 28 }} />
</Box>
</Stack>
{/* Main Heading */}
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5, color: DARK_4 }}>
{clientName} autorisieren?
</Typography>
<Typography variant="body2" sx={{ color: DARK_2 }}>
Diese Anwendung möchte Zugriff auf Ihre QuixPOS / Dashback Tools erhalten.
</Typography>
</Box>
<Divider />
{/* Account row */}
<Box
sx={{
p: 1.75,
borderRadius: 2,
bgcolor: SURFACE_BASE,
border: '1px solid',
borderColor: BORDER,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar
sx={{
width: 34,
height: 34,
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 600,
}}
>
{(user.email || 'U')[0].toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', lineHeight: 1.2 }}>
Autorisieren als Benutzer
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, noWrap: true }}>
{user.email || 'Unbekanntes Konto'}
</Typography>
</Box>
</Stack>
</Box>
{/* Scope & Permissions row */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: '0.05em', display: 'block', mb: 1 }}>
Angefordertes Zugriffsrecht
</Typography>
<Box
sx={{
p: 2,
borderRadius: 2,
bgcolor: SURFACE_BASE,
border: '1px solid',
borderColor: BORDER,
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Chip
label={scope}
size="small"
sx={{
fontWeight: 600,
fontFamily: 'monospace',
bgcolor: 'primary.light',
color: 'primary.dark',
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Dashback Tools API
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: DARK_2, fontSize: '0.85rem' }}>
Ermöglicht dem MCP-Client das Ausführen von Werkzeugen und Abfragen (Artikel, Lager, Kunden, Aufträge) in Ihrem Namen.
</Typography>
</Box>
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', display: 'block' }}>
Sie können diese Autorisierung jederzeit in den Einstellungen unter <strong>Sicherheit & MCP</strong> widerrufen.
</Typography>
{/* Action buttons */}
<Stack direction="row" spacing={2} sx={{ pt: 1 }}>
<Button
variant="outlined"
color="inherit"
fullWidth
size="large"
disabled={submitting != null}
startIcon={<CloseIcon />}
onClick={this.handleDeny}
sx={{
borderRadius: 2,
textTransform: 'none',
borderColor: BORDER_STRONG,
}}
>
{submitting === 'deny' ? 'Wird abgelehnt...' : 'Ablehnen'}
</Button>
<Button
variant="contained"
color="primary"
fullWidth
size="large"
disabled={submitting != null}
startIcon={submitting === 'allow' ? <CircularProgress size={18} color="inherit" /> : <CheckIcon />}
onClick={this.handleAllow}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
}}
>
{submitting === 'allow' ? 'Autorisiere...' : 'Zulassen'}
</Button>
</Stack>
</Stack>
)}
</CardContent>
</Card>
</Box>
);
}
}