This commit is contained in:
seb
2026-09-22 07:59:00 +02:00
parent 5e13b8f9cd
commit 51d01a502b
5 changed files with 220 additions and 80 deletions

View File

@@ -79,7 +79,7 @@ function createCache(label, load) {
export class StorageManager extends ApiClient { export class StorageManager extends ApiClient {
/** @type {EventSource | null} */ /** @type {EventSource | null} */
#events = null; #events = null;
/** @type {ReturnType<typeof createCache> | null} */ /** @type {ReturnType<StorageManager['#createDeferredCache']> | null} */
#articleIndexCache = null; #articleIndexCache = null;
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */ /** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
#articleWatchers = new Map(); #articleWatchers = new Map();
@@ -386,14 +386,24 @@ export class StorageManager extends ApiClient {
#initCaches() { #initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe()); const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries()); const deliveryCache = this.#createDeferredCache('GET /delivery', () =>
const customergroupCache = createCache('GET /customergroup', () => this.#loadDeliveries(),
);
const customergroupCache = this.#createDeferredCache('GET /customergroup', () =>
this.#loadCustomergroups(), this.#loadCustomergroups(),
); );
const entityCache = createCache('GET /entity', () => this.#loadEntities()); const entityCache = this.#createDeferredCache('GET /entity', () =>
const numberCache = createCache('GET /number', () => this.#loadNumbers()); this.#loadEntities(),
const quoteCache = createCache('GET /quote', () => this.#loadQuotes()); );
const orderCache = createCache('GET /order', () => this.#loadOrders()); const numberCache = this.#createDeferredCache('GET /number', () =>
this.#loadNumbers(),
);
const quoteCache = this.#createDeferredCache('GET /quote', () =>
this.#loadQuotes(),
);
const orderCache = this.#createDeferredCache('GET /order', () =>
this.#loadOrders(),
);
// articleIndex is auth-gated — create a deferred cache that only loads after auth // articleIndex is auth-gated — create a deferred cache that only loads after auth
this.#articleIndexCache = this.#createDeferredCache( this.#articleIndexCache = this.#createDeferredCache(
@@ -478,7 +488,7 @@ export class StorageManager extends ApiClient {
/** Settled GET /category/tree body, or undefined. */ /** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value }, categoryTree: { get: () => categoryTreeCache.value },
// ── Remaining non-auth-gated caches ───────────────────────────────────── // ── Deferred caches (auth-gated) ─────────────────────────────────────────
deliveryPromise: { get: () => deliveryCache.promise }, deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update }, updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */ /** Settled GET /delivery list, or undefined. */
@@ -513,58 +523,132 @@ export class StorageManager extends ApiClient {
reconnectEvents: { value: () => this.#reconnectEvents() }, reconnectEvents: { value: () => this.#reconnectEvents() },
}); });
// Trigger articleIndex load once auth is established let lastCompanyId = null;
authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) { const syncWithAuth = (auth) => {
if (!articleIndexCache.value) { const companyId =
void articleIndexCache.update?.().catch(() => {}); auth.status === 'authed' && auth.user?.selectedCompanyId != null
? String(auth.user.selectedCompanyId)
: null;
if (companyId === lastCompanyId) return;
const prevCompanyId = lastCompanyId;
lastCompanyId = companyId;
if (!companyId) {
// Unauthenticated or logged out
this.#disconnectEvents();
articleIndexCache.clear();
categoryTreeCache.clear();
deliveryCache.clear();
customergroupCache.clear();
entityCache.clear();
numberCache.clear();
quoteCache.clear();
orderCache.clear();
artikelAutomat.actions.clear();
} else {
// Company established or switched
if (prevCompanyId) {
articleIndexCache.clear();
categoryTreeCache.clear();
deliveryCache.clear();
customergroupCache.clear();
entityCache.clear();
numberCache.clear();
quoteCache.clear();
orderCache.clear();
artikelAutomat.actions.clear();
} }
this.#reconnectEvents();
void articleIndexCache.trigger().catch(() => {});
void deliveryCache.trigger().catch(() => {});
void customergroupCache.trigger().catch(() => {});
void entityCache.trigger().catch(() => {});
void numberCache.trigger().catch(() => {});
void quoteCache.trigger().catch(() => {});
void orderCache.trigger().catch(() => {});
} }
}); };
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
} }
/** /**
* Create a cache that does NOT load immediately but waits for an explicit trigger. * Create a cache that does NOT load immediately but waits for an explicit trigger
* Differs from createCache in that the initial load promise is not started. * or loads on-demand once authenticated.
*/ */
#createDeferredCache(label, load) { #createDeferredCache(label, load) {
let current; let current;
let resolveInitial; let loadPromise = null;
let rejectInitial;
let promise = new Promise((res, rej) => {
resolveInitial = res;
rejectInitial = rej;
});
async function update() { const trigger = () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const data = await load();
current = data;
return data;
} catch (err) {
console.error(`[storageMgr] ${label} failed`, err);
loadPromise = null;
throw err;
}
})();
return loadPromise;
};
const update = async () => {
try { try {
const data = await load(); const data = await load();
current = data; current = data;
promise = Promise.resolve(data); loadPromise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data; return data;
} catch (err) { } catch (err) {
console.error(`[storageMgr] update ${label} failed`, err); console.error(`[storageMgr] update ${label} failed`, err);
rejectInitial?.(err);
rejectInitial = null;
throw err; throw err;
} }
} };
function replace(data) { const replace = (data) => {
current = data; current = data;
promise = Promise.resolve(data); loadPromise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data; return data;
} };
const clear = () => {
current = undefined;
loadPromise = null;
};
return { return {
update, update,
replace, replace,
get promise() { return promise; }, clear,
get value() { return current; }, trigger,
get promise() {
if (!loadPromise) {
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
return trigger();
}
return new Promise((resolve, reject) => {
const unsub = authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
unsub();
trigger().then(resolve, reject);
} else if (auth.status === 'anon') {
unsub();
reject(new Error(`Cannot load ${label}: unauthenticated`));
}
});
});
}
return loadPromise;
},
get value() {
return current;
},
}; };
} }
@@ -652,24 +736,6 @@ export class StorageManager extends ApiClient {
})); }));
} }
async #loadBrands() {
const data = await this.brand.list();
requireObject(data, 'GET /api/brand');
return requireArray(data.brands, 'brands');
}
async #loadUnits() {
const data = await this.unit.list();
requireObject(data, 'GET /api/unit');
return requireArray(data.units, 'units');
}
async #loadCompanies() {
const data = await this.company.list();
requireObject(data, 'GET /api/company');
return requireArray(data.companies, 'companies');
}
async #loadCategoryTree() { async #loadCategoryTree() {
const data = await this.category.tree(); const data = await this.category.tree();
requireObject(data, 'GET /api/category/tree'); requireObject(data, 'GET /api/category/tree');
@@ -679,12 +745,6 @@ export class StorageManager extends ApiClient {
return data; return data;
} }
async #loadPosterminals() {
const data = await this.posterminal.list();
requireObject(data, 'GET /api/posterminal');
return requireArray(data.posterminals, 'posterminals');
}
async #loadDeliveries() { async #loadDeliveries() {
const data = await this.delivery.list(); const data = await this.delivery.list();
requireObject(data, 'GET /api/delivery'); requireObject(data, 'GET /api/delivery');

View File

@@ -1,23 +1,87 @@
import { createContext, useContext, useState, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { GRID_COLUMNS } from 'dashgui/panels';
export const PageTitleRightContext = createContext(null);
export function PageTitleRight({ children }) {
const contextNode = useContext(PageTitleRightContext);
const [domNode, setDomNode] = useState(
() => (typeof document !== 'undefined' ? document.getElementById('page-title-right') : null)
);
useLayoutEffect(() => {
if (!contextNode && typeof document !== 'undefined') {
const el = document.getElementById('page-title-right');
if (el && el !== domNode) {
setDomNode(el);
}
}
}, [contextNode, domNode]);
const target = contextNode || domNode;
if (!target) return null;
return createPortal(children, target);
}
export default function PageLayout({ export default function PageLayout({
title, title,
showTitle = true, showTitle = true,
titleAction, titleAction,
titleRightAction,
titleWide = 3,
children, children,
}) { }) {
const [rightNode, setRightNode] = useState(null);
return ( return (
<Box sx={{ minWidth: 0 }}> <PageTitleRightContext.Provider value={rightNode}>
{showTitle ? ( <Box sx={{ minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}> {showTitle ? (
<Typography variant="h5" sx={{ fontWeight: 700 }}> <Box
{title} sx={{
</Typography> display: 'grid',
{titleAction} gridTemplateColumns: {
</Box> xs: '1fr',
) : null} sm: 'repeat(2, 1fr)',
{children} md: `repeat(${GRID_COLUMNS}, 1fr)`,
</Box> },
gap: 2,
mb: 2,
}}
>
<Box
sx={{
gridColumn: {
xs: '1 / -1',
sm: titleWide >= 2 ? 'span 2' : 'span 1',
md: `span ${Math.min(Math.max(titleWide, 1), GRID_COLUMNS)}`,
},
display: 'flex',
alignItems: 'center',
gap: 1.5,
minWidth: 0,
}}
>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{titleAction}
<Box
id="page-title-right"
ref={setRightNode}
sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}
>
{titleRightAction}
</Box>
</Box>
</Box>
) : null}
{children}
</Box>
</PageTitleRightContext.Provider>
); );
} }

View File

@@ -1,9 +1,11 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import { PageTitleRight } from '../PageLayout';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext'; import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
@@ -473,6 +475,18 @@ class ManifestForm extends PureComponent {
return ( return (
<> <>
{status === 'ready' && (
<PageTitleRight>
<Button
variant="contained"
size="small"
disabled={saving}
onClick={this.handleSave}
>
{saving ? 'Speichern…' : 'Speichern'}
</Button>
</PageTitleRight>
)}
<PanelTileGrid> <PanelTileGrid>
<ManifestHeader <ManifestHeader
kind={kind} kind={kind}

View File

@@ -101,15 +101,6 @@ class ManifestLines extends PureComponent {
> >
Zeile hinzufügen Zeile hinzufügen
</Button> </Button>
<Box sx={{ flex: 1 }} />
<Button
variant="contained"
size="small"
disabled={saving}
onClick={onSave}
>
{saving ? 'Speichern…' : 'Speichern'}
</Button>
</Box> </Box>
</Stack> </Stack>
</PanelTile> </PanelTile>

View File

@@ -10,6 +10,7 @@ import { enqueueSnackbar } from 'notistack';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import withNavigate from '../../components/withNavigate'; import withNavigate from '../../components/withNavigate';
import { PageTitleRight } from '../../components/PageLayout';
import { import {
ManifestHeader, ManifestHeader,
ManifestLines, ManifestLines,
@@ -1158,6 +1159,16 @@ class LieferungNeuPage extends PureComponent {
return ( return (
<> <>
<PageTitleRight>
<Button
variant="contained"
size="small"
disabled={saving}
onClick={this.handleSave}
>
{saving ? 'Speichern…' : 'Speichern'}
</Button>
</PageTitleRight>
<PanelTileGrid> <PanelTileGrid>
<ManifestHeader <ManifestHeader
kind="delivery" kind="delivery"