u
This commit is contained in:
@@ -88,6 +88,7 @@ export { default as customergroupAutomat } from './customergroupAutomat';
|
||||
export { default as numberAutomat } from './numberAutomat';
|
||||
export { default as accountAutomat } from './accountAutomat';
|
||||
export { default as backupAutomat } from './backupAutomat';
|
||||
export { default as shipmentAutomat } from './shipmentAutomat';
|
||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||
|
||||
|
||||
@@ -181,6 +182,18 @@ export {
|
||||
|
||||
export { default as vfsAutomat } from './vfsAutomat';
|
||||
|
||||
export { default as pinnedAppletAutomat } from './pinnedAppletAutomat';
|
||||
export {
|
||||
isPinned,
|
||||
getPinnedForRoute,
|
||||
pinApplet,
|
||||
unpinApplet,
|
||||
togglePin,
|
||||
executeApplet,
|
||||
closeExecution,
|
||||
normalizeRoute,
|
||||
} from './pinnedAppletAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
|
||||
304
src/automats/pinnedAppletAutomat.js
Normal file
304
src/automats/pinnedAppletAutomat.js
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @file pinnedAppletAutomat.js
|
||||
* @description State container for applets pinned to browser routes.
|
||||
* Persists pin assignments in company settings using the 'applet.pinned' namespace,
|
||||
* with local storage fallback per company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth changes — loads pinned applet settings when authed
|
||||
* with a selectedCompanyId, and resets when anon/logged out.
|
||||
* - UI / VfsAppletRunner: Calls `pinApplet`, `unpinApplet`, `togglePin`.
|
||||
* - UI / PinnedAppletFabs: Calls `executeApplet`, `closeExecution`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { setting as settingApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* Normalizes a browser route pathname (strips trailing slashes, hashes, query params).
|
||||
* @param {string} rawRoute
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeRoute(rawRoute) {
|
||||
if (!rawRoute) return '/';
|
||||
const clean = String(rawRoute).split('?')[0].split('#')[0];
|
||||
const trimmed = clean.replace(/\/+$/, '');
|
||||
return trimmed || '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PinnedAppletState
|
||||
* @property {Record<string, string[]>} pinnedByRoute Mapping of normalized route -> array of applet file paths
|
||||
* @property {'idle'|'loading'|'ready'} status
|
||||
* @property {string|null} activeExecution Currently active applet fileName to execute in dialog
|
||||
*/
|
||||
|
||||
/** @type {PinnedAppletState} */
|
||||
const initialState = {
|
||||
pinnedByRoute: {},
|
||||
status: 'idle',
|
||||
activeExecution: null,
|
||||
};
|
||||
|
||||
let pinnedAppletAutomat;
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Check if an applet is pinned to the specified route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPinned(fileName, rawRoute) {
|
||||
if (!fileName) return false;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
|
||||
return Array.isArray(list) && list.includes(fileName);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all applet file paths pinned to the specified route.
|
||||
* @param {string} rawRoute
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getPinnedForRoute(rawRoute) {
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
|
||||
return Array.isArray(list) ? list : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Pin an applet to a route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
*/
|
||||
pinApplet(fileName, rawRoute) {
|
||||
if (!fileName) return;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const current = pinnedAppletAutomat.state.pinnedByRoute;
|
||||
const list = current[route] ? [...current[route]] : [];
|
||||
if (!list.includes(fileName)) {
|
||||
list.push(fileName);
|
||||
const next = { ...current, [route]: list };
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: next });
|
||||
void actions.save(route);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unpin an applet from a route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
*/
|
||||
unpinApplet(fileName, rawRoute) {
|
||||
if (!fileName) return;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const current = pinnedAppletAutomat.state.pinnedByRoute;
|
||||
const list = current[route] ? current[route].filter((f) => f !== fileName) : [];
|
||||
const next = { ...current };
|
||||
if (list.length > 0) {
|
||||
next[route] = list;
|
||||
} else {
|
||||
delete next[route];
|
||||
}
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: next });
|
||||
void actions.save(route);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle pin status for an applet on a route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
* @returns {boolean} New pinned status
|
||||
*/
|
||||
togglePin(fileName, rawRoute) {
|
||||
if (!fileName) return false;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const isCurrentlyPinned = actions.isPinned(fileName, route);
|
||||
if (isCurrentlyPinned) {
|
||||
actions.unpinApplet(fileName, route);
|
||||
return false;
|
||||
}
|
||||
actions.pinApplet(fileName, route);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open dialog to execute the specified applet.
|
||||
* @param {string} fileName
|
||||
*/
|
||||
executeApplet(fileName) {
|
||||
pinnedAppletAutomat.setState({ activeExecution: fileName });
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the execution dialog.
|
||||
*/
|
||||
closeExecution() {
|
||||
pinnedAppletAutomat.setState({ activeExecution: null });
|
||||
},
|
||||
|
||||
/**
|
||||
* Load pinned applets from company settings namespace and localStorage.
|
||||
*/
|
||||
async load() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (!companyId) return;
|
||||
|
||||
pinnedAppletAutomat.setState({ status: 'loading' });
|
||||
const storageKey = `dash_company_${companyId}_applet_pinned`;
|
||||
let loadedMap = null;
|
||||
|
||||
// Fast-path: Synchronous read from localStorage to eliminate blank flash
|
||||
try {
|
||||
const rawLocal = localStorage.getItem(storageKey);
|
||||
if (rawLocal) {
|
||||
loadedMap = JSON.parse(rawLocal);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[pinnedAppletAutomat] localStorage read failed:', err);
|
||||
}
|
||||
|
||||
if (loadedMap && typeof loadedMap === 'object') {
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
|
||||
}
|
||||
|
||||
// Network read: Try company settings API with 'applet.pinned' namespace
|
||||
try {
|
||||
const res = await settingApi.get('applet.pinned');
|
||||
const val = res?.setting?.value;
|
||||
if (val) {
|
||||
const parsed = JSON.parse(val);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
loadedMap = { ...(loadedMap || {}), ...parsed };
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(loadedMap));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore if key is not yet stored on backend
|
||||
}
|
||||
|
||||
// Also scan company settings list for any keys in namespace 'applet.pinned:'
|
||||
try {
|
||||
const listRes = await settingApi.list({ frontendOnly: true });
|
||||
const settings = Array.isArray(listRes?.settings) ? listRes.settings : [];
|
||||
let merged = false;
|
||||
const current = { ...(loadedMap || pinnedAppletAutomat.state.pinnedByRoute) };
|
||||
|
||||
for (const s of settings) {
|
||||
if (s.key === 'applet.pinned' && s.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(s.value);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
Object.assign(current, parsed);
|
||||
merged = true;
|
||||
}
|
||||
} catch {}
|
||||
} else if (s.key && s.key.startsWith('applet.pinned:') && s.value) {
|
||||
const route = s.key.slice('applet.pinned:'.length);
|
||||
try {
|
||||
const parsed = JSON.parse(s.value);
|
||||
if (Array.isArray(parsed)) {
|
||||
current[route] = parsed;
|
||||
merged = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (merged) {
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: current, status: 'ready' });
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(current));
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
// Ignore network errors
|
||||
}
|
||||
|
||||
pinnedAppletAutomat.setState({ status: 'ready' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist pinned applets into company settings namespace and localStorage.
|
||||
* @param {string} [route] Optional route updated
|
||||
*/
|
||||
async save(route = null) {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
const { pinnedByRoute } = pinnedAppletAutomat.state;
|
||||
|
||||
if (companyId) {
|
||||
const storageKey = `dash_company_${companyId}_applet_pinned`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(pinnedByRoute));
|
||||
} catch (err) {
|
||||
console.warn('[pinnedAppletAutomat] localStorage write failed:', err);
|
||||
}
|
||||
|
||||
// Upsert into company settings namespace 'applet.pinned'
|
||||
try {
|
||||
await settingApi.upsert('applet.pinned', {
|
||||
value: JSON.stringify(pinnedByRoute),
|
||||
frontend: true,
|
||||
});
|
||||
} catch {
|
||||
// Backend key validation fallback
|
||||
}
|
||||
|
||||
// Also upsert route-specific key in namespace
|
||||
if (route) {
|
||||
try {
|
||||
const routeKey = `applet.pinned:${route}`;
|
||||
const routeVal = JSON.stringify(pinnedByRoute[route] || []);
|
||||
await settingApi.upsert(routeKey, {
|
||||
value: routeVal,
|
||||
frontend: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset on logout or company switch.
|
||||
*/
|
||||
reset() {
|
||||
pinnedAppletAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Pinned Applet state container.
|
||||
* @type {Automat<PinnedAppletState>}
|
||||
*/
|
||||
pinnedAppletAutomat = new Automat(initialState, actions, { name: 'pinnedApplet' });
|
||||
|
||||
// Subscribe to auth lifecycle
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void pinnedAppletAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
pinnedAppletAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-load if auth is already established when module loads
|
||||
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
|
||||
void pinnedAppletAutomat.actions.load();
|
||||
}
|
||||
|
||||
export default pinnedAppletAutomat;
|
||||
export const {
|
||||
isPinned,
|
||||
getPinnedForRoute,
|
||||
pinApplet,
|
||||
unpinApplet,
|
||||
togglePin,
|
||||
executeApplet,
|
||||
closeExecution,
|
||||
} = pinnedAppletAutomat.actions;
|
||||
240
src/automats/shipmentAutomat.js
Normal file
240
src/automats/shipmentAutomat.js
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @file shipmentAutomat.js
|
||||
* @description Manages cached and paginated company shipment manifests (Lieferscheine) (`GET /api/shipment`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
|
||||
* - `sseAutomat`: Triggers `actions.reload()` on `'shipment'` SSE events.
|
||||
*
|
||||
* Actions:
|
||||
* - `load()`: Fetches shipments with current filters.
|
||||
* - `reload()`: Silently re-fetches shipments.
|
||||
* - `setFilter(filter)`: Updates free-text search filter.
|
||||
* - `setStateFilter(state)`: Updates delivery state filter ('', 'new', 'delivered', 'returned').
|
||||
* - `updateState(id, state)`: Calls `PATCH /api/shipment/:id` and updates local cache.
|
||||
* - `deleteShipment(id)`: Calls `DELETE /api/shipment/:id` and removes from local cache.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { shipment as shipmentApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ShipmentSummary
|
||||
* @property {number} id
|
||||
* @property {string} number
|
||||
* @property {'shipment'} type
|
||||
* @property {string} date
|
||||
* @property {string|null} createdAt
|
||||
* @property {number|null} orderId
|
||||
* @property {string|null} orderNumber
|
||||
* @property {number|null} entityId
|
||||
* @property {string|null} customerName
|
||||
* @property {number} positions
|
||||
* @property {number} totalQuantity
|
||||
* @property {number} packageCount
|
||||
* @property {string|null} carrier
|
||||
* @property {string|null} trackingCode
|
||||
* @property {'new'|'delivered'|'returned'} state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ShipmentAutomatState
|
||||
* @property {ShipmentSummary[]|null} items
|
||||
* @property {number} total
|
||||
* @property {number} unfilteredTotal
|
||||
* @property {string} filter
|
||||
* @property {string} stateFilter
|
||||
* @property {number} seekMonths
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status
|
||||
* @property {Error|string|null} error
|
||||
*/
|
||||
|
||||
const initialState = {
|
||||
items: null,
|
||||
total: 0,
|
||||
unfilteredTotal: 0,
|
||||
filter: '',
|
||||
stateFilter: '',
|
||||
seekMonths: 0,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let automat;
|
||||
let inflightLoad = null;
|
||||
let debounceTimer = null;
|
||||
|
||||
// #region fetch helper
|
||||
async function fetchShipments(state) {
|
||||
const params = {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
};
|
||||
if (state.filter && state.filter.trim()) {
|
||||
params.search = state.filter.trim();
|
||||
}
|
||||
if (state.stateFilter) {
|
||||
params.state = state.stateFilter;
|
||||
}
|
||||
if (state.seekMonths) {
|
||||
params.seekMonths = state.seekMonths;
|
||||
}
|
||||
|
||||
const res = await shipmentApi.list(params);
|
||||
return {
|
||||
items: res?.shipments || [],
|
||||
total: res?.total ?? (res?.shipments?.length || 0),
|
||||
unfilteredTotal: res?.unfilteredTotal ?? res?.total ?? (res?.shipments?.length || 0),
|
||||
};
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const actions = {
|
||||
// #region load & reload
|
||||
async load() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
|
||||
automat.setState((s) => ({
|
||||
status: s.items === null ? 'loading' : s.status,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
inflightLoad = (async () => {
|
||||
try {
|
||||
const { items, total, unfilteredTotal } = await fetchShipments(automat.state);
|
||||
automat.setState({
|
||||
items,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
return items;
|
||||
} catch (err) {
|
||||
automat.setState({
|
||||
status: 'failed',
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
async reload() {
|
||||
try {
|
||||
const { items, total, unfilteredTotal } = await fetchShipments(automat.state);
|
||||
automat.setState({
|
||||
items,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
return items;
|
||||
} catch (err) {
|
||||
console.warn('[shipmentAutomat] silent reload failed:', err);
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region filters
|
||||
setFilter(filterText) {
|
||||
const next = typeof filterText === 'string' ? filterText : '';
|
||||
if (automat.state.filter === next) return;
|
||||
|
||||
automat.setState({ filter: next });
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
void automat.actions.load().catch(() => {});
|
||||
}, 200);
|
||||
},
|
||||
|
||||
setStateFilter(stateFilter) {
|
||||
const next = typeof stateFilter === 'string' ? stateFilter : '';
|
||||
if (automat.state.stateFilter === next) return;
|
||||
|
||||
automat.setState({ stateFilter: next });
|
||||
void automat.actions.load().catch(() => {});
|
||||
},
|
||||
|
||||
setSeekMonths(months) {
|
||||
automat.setState({ seekMonths: months });
|
||||
void automat.actions.load().catch(() => {});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region item mutations
|
||||
patchShipment(id, patch) {
|
||||
automat.setState((s) => {
|
||||
if (!Array.isArray(s.items)) return null;
|
||||
const next = s.items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||
return { items: next };
|
||||
});
|
||||
},
|
||||
|
||||
async updateState(id, newState) {
|
||||
// Optimistic update
|
||||
automat.actions.patchShipment(id, { state: newState });
|
||||
try {
|
||||
await shipmentApi.update(id, { state: newState });
|
||||
void orderAutomat.actions.reload().catch(() => {});
|
||||
} catch (err) {
|
||||
// Rollback on error
|
||||
void automat.actions.reload();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteShipment(id) {
|
||||
await shipmentApi.delete(id);
|
||||
automat.setState((s) => {
|
||||
if (!Array.isArray(s.items)) return null;
|
||||
const next = s.items.filter((item) => item.id !== id);
|
||||
return {
|
||||
items: next,
|
||||
total: Math.max(0, s.total - 1),
|
||||
unfilteredTotal: Math.max(0, s.unfilteredTotal - 1),
|
||||
};
|
||||
});
|
||||
void orderAutomat.actions.reload().catch(() => {});
|
||||
},
|
||||
|
||||
reset() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
inflightLoad = null;
|
||||
automat.setState({ ...initialState });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
automat = new Automat(initialState, actions, { name: 'shipment' });
|
||||
|
||||
// #region authAutomat synchronization
|
||||
let lastCompanyId = null;
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId ?? null;
|
||||
if (auth.status === 'anon') {
|
||||
automat.actions.reset();
|
||||
lastCompanyId = null;
|
||||
return;
|
||||
}
|
||||
if (lastCompanyId !== null && companyId !== lastCompanyId) {
|
||||
automat.actions.reset();
|
||||
}
|
||||
if (auth.status === 'authed' && companyId != null) {
|
||||
const isNew = lastCompanyId !== companyId;
|
||||
lastCompanyId = companyId;
|
||||
if (isNew) {
|
||||
void automat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
// #endregion
|
||||
|
||||
export default automat;
|
||||
@@ -34,6 +34,7 @@ import propertyAutomat from './propertyAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
import quoteAutomat from './quoteAutomat';
|
||||
import shipmentAutomat from './shipmentAutomat';
|
||||
import { handleDraftRequest, handleDraftEvent } from './draftSync';
|
||||
|
||||
|
||||
@@ -253,9 +254,12 @@ function connect() {
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'shipment', () => {
|
||||
void orderAutomat.actions.reload().catch((err) => {
|
||||
void shipmentAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
void orderAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] order refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'draft:request', (event) => {
|
||||
|
||||
Reference in New Issue
Block a user