u
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* @file accountAutomat.js
|
||||
* @description Manages cached payment accounts (`GET /api/account`) for the selected company.
|
||||
*/
|
||||
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
import { account as accountApi } from '../api';
|
||||
|
||||
export const accountAutomat = createListAutomat({
|
||||
name: 'account',
|
||||
fetch: async () => {
|
||||
const res = await accountApi.list();
|
||||
return Array.isArray(res?.accounts) ? res.accounts : [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) =>
|
||||
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')
|
||||
),
|
||||
});
|
||||
|
||||
export default accountAutomat;
|
||||
@@ -286,6 +286,9 @@ categoryAutomat.subscribe((state) => {
|
||||
scheduleRebuild();
|
||||
}
|
||||
});
|
||||
if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) {
|
||||
scheduleRebuild();
|
||||
}
|
||||
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') actions.reset();
|
||||
|
||||
@@ -14,8 +14,7 @@ import { Automat } from 'react-automat';
|
||||
import FlexSearch from 'flexsearch';
|
||||
import articleIndexAutomat from './articleIndexAutomat';
|
||||
import categoryAutomat from './categoryAutomat';
|
||||
import brandAutomat from './brandAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
import { parseFilter } from '../components/article/searchFormat';
|
||||
|
||||
// #region Types & Initial State
|
||||
@@ -585,7 +584,7 @@ function buildCategoryFacets(matchedArticles) {
|
||||
*/
|
||||
function buildBrandFacets(matchedArticles) {
|
||||
const facets = {};
|
||||
const allBrands = brandAutomat.state?.brands ?? [];
|
||||
const allBrands = companyAutomat.state?.brands ?? [];
|
||||
const brandById = new Map();
|
||||
const brandByName = new Map();
|
||||
for (const b of allBrands) {
|
||||
@@ -633,8 +632,8 @@ function buildBrandFacets(matchedArticles) {
|
||||
*/
|
||||
function buildSupplierFacets(matchedArticles) {
|
||||
const facets = {};
|
||||
const suppliers = supplierAutomat.state?.suppliers ?? [];
|
||||
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {};
|
||||
const suppliers = companyAutomat.state?.suppliers ?? [];
|
||||
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||
|
||||
const supplierArticleSets = new Map();
|
||||
for (const s of suppliers) {
|
||||
@@ -738,7 +737,7 @@ const actions = {
|
||||
// Apply supplier filter if set
|
||||
const selectedSuppliers = parseFilter(supplier);
|
||||
if (selectedSuppliers.length > 0) {
|
||||
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {};
|
||||
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||
const allowedSkus = new Set();
|
||||
for (const sId of selectedSuppliers) {
|
||||
const list = articlesBySupplier[String(sId)] ?? [];
|
||||
@@ -898,15 +897,10 @@ categoryAutomat.subscribe((catState) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update facets when brands are loaded or updated.
|
||||
brandAutomat.subscribe(() => {
|
||||
// Update facets when brands, suppliers, or supplier articles are loaded or updated.
|
||||
companyAutomat.subscribe(() => {
|
||||
actions.runSearch();
|
||||
});
|
||||
|
||||
// Update facets when suppliers or supplier articles are loaded or updated.
|
||||
supplierAutomat.subscribe(() => {
|
||||
actions.runSearch();
|
||||
});
|
||||
}, ['brands', 'suppliers', 'articlesBySupplier']);
|
||||
// #endregion
|
||||
|
||||
// #region Selectors & Exports
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* @file attributeAutomat.js
|
||||
* @description Manages catalog product attributes and their discrete values
|
||||
* (e.g. Color, Size) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.load()` upon receiving an `'attributename'` SSE event.
|
||||
* - UI / Dialogs: Mutating actions (`createAttribute`, `updateAttribute`,
|
||||
* `deleteAttribute`, `addValue`, `deleteValue`, `reorderValues`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { attributename as attributenameApi, attributevalue as attributevalueApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttributeValue
|
||||
* @property {number} id
|
||||
* @property {number} attributeId
|
||||
* @property {string} value
|
||||
* @property {number} sort
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttributeName
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {number} sort
|
||||
* @property {boolean} exclusive
|
||||
* @property {AttributeValue[]} values
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttributeState
|
||||
* @property {AttributeName[]} attributes
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {AttributeState} */
|
||||
const initialState = {
|
||||
attributes: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
function sortValues(values) {
|
||||
if (!Array.isArray(values)) return [];
|
||||
return [...values].sort((a, b) => {
|
||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
||||
if (sDiff !== 0) return sDiff;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAttributes(attributes) {
|
||||
if (!Array.isArray(attributes)) return [];
|
||||
return [...attributes]
|
||||
.map((attr) => ({
|
||||
...attr,
|
||||
exclusive: Boolean(attr.exclusive),
|
||||
values: sortValues(attr.values),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
||||
if (sDiff !== 0) return sDiff;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
let attributeAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all attribute names and their values for the selected company. */
|
||||
async load() {
|
||||
attributeAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await attributenameApi.list();
|
||||
const attributes = normalizeAttributes(res.attributenames);
|
||||
attributeAutomat.setState({ attributes, status: 'succeeded' });
|
||||
return attributes;
|
||||
} catch (err) {
|
||||
attributeAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create an attribute name.
|
||||
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async createAttribute(body) {
|
||||
const res = await attributenameApi.create(body);
|
||||
await actions.load();
|
||||
return res.attributename;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an attribute name.
|
||||
* @param {number} id
|
||||
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async updateAttribute(id, body) {
|
||||
const res = await attributenameApi.update(id, body);
|
||||
await actions.load();
|
||||
return res.attributename;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an attribute name.
|
||||
* @param {number} id
|
||||
*/
|
||||
async deleteAttribute(id) {
|
||||
await attributenameApi.delete(id);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete multiple attribute names.
|
||||
* @param {number[]} ids
|
||||
*/
|
||||
async deleteAttributes(ids) {
|
||||
for (const id of ids) {
|
||||
await attributenameApi.delete(id);
|
||||
}
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder attribute names.
|
||||
* @param {number[]} attributenameIds
|
||||
*/
|
||||
async reorderAttributes(attributenameIds) {
|
||||
await attributenameApi.order(attributenameIds);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a value for an attribute.
|
||||
* @param {number} attributeId
|
||||
* @param {{ value: string, sort?: number }} body
|
||||
*/
|
||||
async createValue(attributeId, body) {
|
||||
const res = await attributevalueApi.create(attributeId, body);
|
||||
await actions.load();
|
||||
return res.attributevalue;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an attribute value.
|
||||
* @param {number} id
|
||||
* @param {{ value?: string, sort?: number }} body
|
||||
*/
|
||||
async updateValue(id, body) {
|
||||
const res = await attributevalueApi.update(id, body);
|
||||
await actions.load();
|
||||
return res.attributevalue;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an attribute value.
|
||||
* @param {number} id
|
||||
*/
|
||||
async deleteValue(id) {
|
||||
await attributevalueApi.delete(id);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete multiple attribute values.
|
||||
* @param {number[]} ids
|
||||
*/
|
||||
async deleteValues(ids) {
|
||||
for (const id of ids) {
|
||||
await attributevalueApi.delete(id);
|
||||
}
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder values of an attribute.
|
||||
* @param {number} attributeId
|
||||
* @param {number[]} attributevalueIds
|
||||
*/
|
||||
async reorderValues(attributeId, attributevalueIds) {
|
||||
await attributevalueApi.order(attributeId, attributevalueIds);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
attributeAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global attribute state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<AttributeState>}
|
||||
*/
|
||||
attributeAutomat = new Automat(initialState, actions, { name: 'attribute' });
|
||||
|
||||
// Selectors
|
||||
export function selectAttributes() {
|
||||
return attributeAutomat.state.attributes;
|
||||
}
|
||||
|
||||
export function selectAttributeStatus() {
|
||||
return attributeAutomat.state.status;
|
||||
}
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void attributeAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
attributeAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
export default attributeAutomat;
|
||||
@@ -1,30 +1,24 @@
|
||||
/**
|
||||
* @file authAutomat.js
|
||||
* @description Central authentication and multi-tenant session manager.
|
||||
* Tracks current user, Google OAuth client ID, and active company selection.
|
||||
* Tracks current user, Google OAuth client ID, active company selection, and
|
||||
* the two core session bits: `hasAuth` and `hasCompany` (`hasCompany` requires `hasAuth`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load.
|
||||
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)` or `loginWithCredentials(body)`.
|
||||
* - `UserMenu.jsx`: Calls `switchCompany(companyId)` or `logout()`.
|
||||
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)`.
|
||||
* - `CompanySetupDialog.jsx` / `UserMenu.jsx`: Calls `selectCompany(companyId)`,
|
||||
* `createCompany(payload)`, or `logout()`.
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* Subscribed to by almost all company-scoped automats, orchestrating their loading
|
||||
* when authenticated (`authed` + `selectedCompanyId`) and reset on logout (`anon`):
|
||||
* - `companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`
|
||||
* - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat`
|
||||
* - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company
|
||||
* - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations
|
||||
* - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from
|
||||
* the category views once the session is established
|
||||
* - `createListAutomat` instances (`deliveryAutomat`, `quoteAutomat`,
|
||||
* `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`)
|
||||
* - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`)
|
||||
* - `artikelAutomat`: Clears cached articles on logout
|
||||
* - With `hasAuth`: SSE (`sseAutomat`) is connected to `/api/events`.
|
||||
* - With `hasAuth && !hasCompany`: `CompanySetupDialog` is shown (`companyAutomat` not instantiated yet).
|
||||
* - With `hasCompany` (requires `hasAuth`): 1st-level menu (`AppShell`) and `companyAutomat`
|
||||
* are mounted and company APIs are triggered.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { auth, company } from '../api';
|
||||
import { auth, company, number as numberApi } from '../api';
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthUser
|
||||
@@ -37,6 +31,8 @@ import { auth, company } from '../api';
|
||||
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
|
||||
* @property {string|null} googleClientId Google Identity Services client ID.
|
||||
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
|
||||
* @property {boolean} hasAuth True when the session has a valid authenticated user.
|
||||
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
|
||||
*/
|
||||
|
||||
/** Emitted after an authenticated session has been established. */
|
||||
@@ -51,10 +47,13 @@ const initialState = {
|
||||
user: null,
|
||||
googleClientId: null,
|
||||
status: 'loading',
|
||||
hasAuth: false,
|
||||
hasCompany: false,
|
||||
};
|
||||
|
||||
let emit = () => { };
|
||||
|
||||
// #region helpers
|
||||
/**
|
||||
* Registers the application-level dispatcher used for authentication events.
|
||||
*
|
||||
@@ -93,9 +92,47 @@ function mergeUser(current, incoming) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes `hasAuth` and `hasCompany` (`hasCompany` strictly requires `hasAuth`).
|
||||
*
|
||||
* @param {AuthUser|null} user
|
||||
* @param {'loading'|'anon'|'authed'} status
|
||||
* @param {object} [extra]
|
||||
* @returns {Partial<AuthState>}
|
||||
*/
|
||||
function buildAuthState(user, status, extra = {}) {
|
||||
const hasAuth = status === 'authed' && Boolean(user);
|
||||
const hasCompany = hasAuth && user?.selectedCompanyId != null;
|
||||
return {
|
||||
user: hasAuth ? user : null,
|
||||
status,
|
||||
hasAuth,
|
||||
hasCompany,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function syncSse(state) {
|
||||
if (state.hasAuth) {
|
||||
void import('./sseAutomat')
|
||||
.then(({ default: sseAutomat }) => {
|
||||
sseAutomat.actions.syncWithAuth?.(state);
|
||||
})
|
||||
.catch(() => { });
|
||||
} else {
|
||||
const sseAutomat = Automat.get('sse');
|
||||
if (sseAutomat?.actions?.syncWithAuth) {
|
||||
sseAutomat.actions.syncWithAuth(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
let authAutomat;
|
||||
|
||||
const actions = {
|
||||
// #region bootstrapSession
|
||||
// MARK: calls auth.me from ../api
|
||||
/**
|
||||
* Restores an existing session or requests it from the authentication API.
|
||||
*
|
||||
@@ -111,7 +148,10 @@ const actions = {
|
||||
|
||||
if (!nextSession.user) {
|
||||
if (authAutomat.state.status !== 'authed') {
|
||||
authAutomat.setState({ user: null, googleClientId, status: 'anon' });
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(null, 'anon', { googleClientId })
|
||||
);
|
||||
syncSse(nextState);
|
||||
} else {
|
||||
authAutomat.setState({ googleClientId });
|
||||
}
|
||||
@@ -119,17 +159,24 @@ const actions = {
|
||||
}
|
||||
|
||||
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
||||
authAutomat.setState({ user, googleClientId, status: 'authed' });
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(user, 'authed', { googleClientId })
|
||||
);
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||
return nextSession;
|
||||
} catch (error) {
|
||||
if (authAutomat.state.status === 'loading') {
|
||||
authAutomat.setState({ user: null, status: 'anon' });
|
||||
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||
syncSse(nextState);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region loginWithGoogle
|
||||
// MARK: calls auth.loginWithGoogle from ../api
|
||||
/**
|
||||
* Exchanges a Google credential for an authenticated application session.
|
||||
*
|
||||
@@ -143,19 +190,24 @@ const actions = {
|
||||
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
|
||||
}
|
||||
const user = mergeUser(authAutomat.state.user, session.user);
|
||||
authAutomat.setState({
|
||||
user,
|
||||
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||
status: 'authed',
|
||||
});
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(user, 'authed', {
|
||||
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||
})
|
||||
);
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||
return session;
|
||||
} catch (error) {
|
||||
authAutomat.setState({ user: null, status: 'anon' });
|
||||
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||
syncSse(nextState);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region selectCompany
|
||||
// MARK: calls company.select from ../api
|
||||
/**
|
||||
* Selects the company used by the current session.
|
||||
*
|
||||
@@ -168,11 +220,15 @@ const actions = {
|
||||
authAutomat.state.user,
|
||||
data.user ?? { selectedCompanyId: companyId },
|
||||
);
|
||||
authAutomat.setState({ user, status: 'authed' });
|
||||
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_COMPANY_CHANGED });
|
||||
return user;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region createCompany
|
||||
// MARK: calls company.create and company.select from ../api
|
||||
/**
|
||||
* Creates a company and selects it for the current user.
|
||||
*
|
||||
@@ -180,7 +236,8 @@ const actions = {
|
||||
* @returns {Promise<AuthUser>} Updated user.
|
||||
*/
|
||||
async createCompany(payload) {
|
||||
const data = await company.create(payload);
|
||||
const { numbers, ...companyPayload } = payload || {};
|
||||
const data = await company.create(companyPayload);
|
||||
let incoming = data.user;
|
||||
if (!incoming && data.company?.id) {
|
||||
const selected = await company.select(data.company.id);
|
||||
@@ -190,12 +247,34 @@ const actions = {
|
||||
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
|
||||
}
|
||||
|
||||
if (numbers) {
|
||||
try {
|
||||
const listRes = await numberApi.list();
|
||||
const serverNumbers = listRes?.numbers ?? [];
|
||||
for (const serverRow of serverNumbers) {
|
||||
const userConfig = numbers[serverRow.type];
|
||||
if (userConfig) {
|
||||
const prefix = String(userConfig.prefix ?? '');
|
||||
const padding = Math.max(0, Number(userConfig.padding) || 0);
|
||||
const next = Math.max(1, Number(userConfig.next) || 1);
|
||||
await numberApi.update(serverRow.id, { prefix, padding, next });
|
||||
}
|
||||
}
|
||||
} catch (numErr) {
|
||||
console.warn('Initial number sequences could not be saved:', numErr);
|
||||
}
|
||||
}
|
||||
|
||||
const user = mergeUser(authAutomat.state.user, incoming);
|
||||
authAutomat.setState({ user, status: 'authed' });
|
||||
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_COMPANY_CHANGED });
|
||||
return user;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region logout
|
||||
// MARK: calls auth.logout from ../api
|
||||
/**
|
||||
* Ends the server session, clears the current user, and returns to `/`.
|
||||
*
|
||||
@@ -204,9 +283,11 @@ const actions = {
|
||||
async logout() {
|
||||
await auth.logout();
|
||||
window.history.replaceState(null, '', '/');
|
||||
authAutomat.setState({ user: null, status: 'anon' });
|
||||
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_LOGGED_OUT });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -216,6 +297,12 @@ const actions = {
|
||||
*/
|
||||
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||
|
||||
// #region selectors
|
||||
export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth);
|
||||
export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
|
||||
export const selectSelectedCompanyId = () =>
|
||||
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
|
||||
|
||||
/**
|
||||
* Re-reads `GET /api/auth/me` from the server.
|
||||
*
|
||||
@@ -227,5 +314,6 @@ authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||
export function bootstrapAuthMe() {
|
||||
return auth.me();
|
||||
}
|
||||
// #endregion
|
||||
|
||||
export default authAutomat;
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* @file brandAutomat.js
|
||||
* @description Manages catalog brands and manufacturers for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `sseAutomat`: Subscribes to the single `'brand'` event carrying
|
||||
* `{ sender, brandId }` — any create/rename/delete/picture change triggers
|
||||
* `actions.load(true)`, which silently re-fetches the list. Subscribed views
|
||||
* (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then
|
||||
* re-render from the updated state. Events sent by this tab (matching `sender`)
|
||||
* are ignored.
|
||||
* - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { brand as brandApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import sseAutomat, { getSseClientId } from './sseAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} BrandState
|
||||
* @property {Array<object>} brands All brands (full list, decoupled from page UI).
|
||||
* @property {Array<object>} filteredBrands Filtered brands according to active filter query.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {BrandState} */
|
||||
const initialState = {
|
||||
brands: [],
|
||||
filteredBrands: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
const sortBrands = (brands) =>
|
||||
[...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||
|
||||
const applyFilter = (brands, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands;
|
||||
};
|
||||
|
||||
let brandAutomat;
|
||||
let inflightLoad = null;
|
||||
const FETCH_PAGE_SIZE = 100;
|
||||
// #region fetch
|
||||
async function fetchAllBrands() {
|
||||
let offset = 0, total = Infinity;
|
||||
const collected = [];
|
||||
while (offset === 0 || collected.length < total) {
|
||||
const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE });
|
||||
if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0);
|
||||
const page = Array.isArray(res.brands) ? res.brands : [];
|
||||
collected.push(...page);
|
||||
offset += page.length;
|
||||
if (page.length === 0) break;
|
||||
}
|
||||
return { brands: sortBrands(collected) };
|
||||
}
|
||||
|
||||
const updateStateWithBrands = (brands, extra = {}) => {
|
||||
const filteredBrands = applyFilter(brands, brandAutomat.state.filter);
|
||||
brandAutomat.setState({ brands, filteredBrands, ...extra });
|
||||
return brands;
|
||||
};
|
||||
|
||||
const actions = {
|
||||
/** Sets the active brand search filter and updates filteredBrands. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all brands for the selected company (pages through the API).
|
||||
* @param {boolean} [silent=false] If true, re-fetches without setting status to 'loading'.
|
||||
*/
|
||||
async load(silent = false) {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
if (!silent) {
|
||||
brandAutomat.setState({ status: 'loading', error: null });
|
||||
}
|
||||
return (inflightLoad = fetchAllBrands()
|
||||
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null }))
|
||||
.catch((err) => {
|
||||
const error = err?.message ?? String(err);
|
||||
brandAutomat.setState(silent ? { error } : { status: 'failed', error });
|
||||
throw err;
|
||||
})
|
||||
.finally(() => { inflightLoad = null; }));
|
||||
},
|
||||
// #region add
|
||||
async create(body) {
|
||||
const res = await brandApi.create(body);
|
||||
const row = res.brand;
|
||||
updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row]));
|
||||
return row;
|
||||
},
|
||||
// #region rename
|
||||
async rename(id, body) {
|
||||
const res = await brandApi.rename(id, body);
|
||||
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
|
||||
updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b))));
|
||||
return res;
|
||||
},
|
||||
// #region del
|
||||
async remove(id) {
|
||||
await brandApi.delete(id);
|
||||
updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id));
|
||||
},
|
||||
async removeBrands(ids) {
|
||||
const idSet = new Set(ids);
|
||||
for (const id of ids) {
|
||||
await brandApi.delete(id);
|
||||
}
|
||||
updateStateWithBrands(brandAutomat.state.brands.filter((b) => !idSet.has(b.id)));
|
||||
await actions.load(true);
|
||||
},
|
||||
// #region addPic
|
||||
async addImage(id, file) {
|
||||
const res = await brandApi.addImage(id, file);
|
||||
await brandAutomat.actions.load(true);
|
||||
return res.brand;
|
||||
},
|
||||
// #region delPic
|
||||
async deleteImage(id) {
|
||||
const res = await brandApi.deleteImage(id);
|
||||
const updated = res.brand ?? { pics: [] };
|
||||
updateStateWithBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated, pics: [] } : b)));
|
||||
return res.brand;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Global brand state container.
|
||||
* @type {Automat<BrandState>}
|
||||
*/
|
||||
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
|
||||
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) void brandAutomat.actions.load();
|
||||
else if (auth.status === 'anon') brandAutomat.setState(initialState);
|
||||
});
|
||||
|
||||
// #region onSse
|
||||
sseAutomat.actions.on('brand', (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event?.data ?? '{}');
|
||||
if (payload?.sender && payload.sender === getSseClientId()) return;
|
||||
} catch { }
|
||||
void brandAutomat.actions.load(true).catch(() => { });
|
||||
});
|
||||
|
||||
export default brandAutomat;
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* @file carrierAutomat.js
|
||||
* @description Manages cached shipping carriers / Versandarten (`GET /api/carrier`) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'carrier'` SSE event.
|
||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or mutation actions.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { carrier as carrierApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Carrier
|
||||
* @property {number} id Carrier identifier.
|
||||
* @property {string} name Carrier name.
|
||||
* @property {number} sort Sort order.
|
||||
* @property {boolean} hasPackages Whether carrier uses package tracking.
|
||||
* @property {boolean} isReverse Whether carrier is used for returns.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CarrierState
|
||||
* @property {Array<Carrier>} carriers Company-scoped carriers.
|
||||
* @property {Array<Carrier>} filteredCarriers Filtered carriers according to active filter query.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {CarrierState} */
|
||||
const initialState = {
|
||||
carriers: [],
|
||||
filteredCarriers: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let carrierAutomat;
|
||||
|
||||
const applyFilter = (carriers, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query ? carriers.filter((c) => String(c.name ?? '').toLowerCase().includes(query)) : carriers;
|
||||
};
|
||||
|
||||
const updateStateWithCarriers = (carriers, extra = {}) => {
|
||||
const list = Array.isArray(carriers) ? carriers : [];
|
||||
const filteredCarriers = applyFilter(list, carrierAutomat.state.filter);
|
||||
carrierAutomat.setState({ carriers: list, filteredCarriers, ...extra });
|
||||
return list;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the company-scoped carrier list.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Carrier list.
|
||||
*/
|
||||
async function fetchCarriers() {
|
||||
const data = await carrierApi.list();
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data.carriers)) return data.carriers;
|
||||
return [];
|
||||
}
|
||||
|
||||
/** In-flight `load()` promise so concurrent callers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Loads the carrier list once, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Loaded carriers.
|
||||
*/
|
||||
function loadCarriers() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
carrierAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const carriers = await fetchCarriers();
|
||||
updateStateWithCarriers(carriers, { status: 'ready', error: null });
|
||||
return carriers;
|
||||
} catch (error) {
|
||||
carrierAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active carrier search filter and updates filteredCarriers. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
carrierAutomat.setState({
|
||||
filter,
|
||||
filteredCarriers: applyFilter(carrierAutomat.state.carriers, filter),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the carrier list, exposing a loading/failed lifecycle.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Loaded carriers.
|
||||
*/
|
||||
load() {
|
||||
return loadCarriers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the carrier list while keeping the current state.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Fresh carriers.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const carriers = await fetchCarriers();
|
||||
updateStateWithCarriers(carriers, { status: 'ready', error: null });
|
||||
return carriers;
|
||||
} catch (error) {
|
||||
carrierAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {{name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Creation payload.
|
||||
* @returns {Promise<Carrier>} Created carrier.
|
||||
*/
|
||||
async create(body) {
|
||||
const data = await carrierApi.create(body);
|
||||
const created = data?.carrier || data;
|
||||
await carrierAutomat.actions.reload();
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Carrier identifier.
|
||||
* @param {{name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Update payload.
|
||||
* @returns {Promise<Carrier>} Updated carrier.
|
||||
*/
|
||||
async update(id, body) {
|
||||
const data = await carrierApi.update(id, body);
|
||||
const updated = data?.carrier || data;
|
||||
await carrierAutomat.actions.reload();
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Carrier identifier.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id) {
|
||||
await carrierApi.delete(id);
|
||||
await carrierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Persists a new sort order for all carriers.
|
||||
*
|
||||
* @param {Array<string|number>} carrierIds IDs in the desired order.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reorder(carrierIds) {
|
||||
const byId = new Map(carrierAutomat.state.carriers.map((c) => [c.id, c]));
|
||||
const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean);
|
||||
if (reordered.length === carrierAutomat.state.carriers.length) {
|
||||
updateStateWithCarriers(reordered);
|
||||
}
|
||||
await carrierApi.order({ carrierIds });
|
||||
await carrierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<Carrier>} carriers Carriers to cache.
|
||||
* @returns {void}
|
||||
*/
|
||||
setCarriers(carriers) {
|
||||
updateStateWithCarriers(carriers, {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the cached list and returns to the initial state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
carrierAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped carrier cache. Loads automatically once an authenticated
|
||||
* session with a selected company exists and is cleared when auth is gone.
|
||||
*
|
||||
* @type {Automat<CarrierState>}
|
||||
*/
|
||||
carrierAutomat = new Automat(initialState, actions, { name: 'carrier' });
|
||||
|
||||
/**
|
||||
* Derives the cache key from the auth state: the selected company while
|
||||
* authenticated, otherwise `null`.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {string|null} Cache key.
|
||||
*/
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
|
||||
/**
|
||||
* Loads the carrier list when auth/company becomes available and forgets it
|
||||
* when the session ends.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
carrierAutomat.actions.clear();
|
||||
} else {
|
||||
void carrierAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
|
||||
export default carrierAutomat;
|
||||
@@ -452,6 +452,11 @@ const actions = {
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
},
|
||||
|
||||
/** Invalidate all loaded category views. */
|
||||
invalidateAllCategoryViews() {
|
||||
invalidateAllCategoryViews();
|
||||
},
|
||||
|
||||
/** Reset state to idle (logout / company switch). */
|
||||
reset() {
|
||||
disposeAllCategoryViewAutomats();
|
||||
@@ -479,9 +484,9 @@ categoryAutomat.views = categoryViews;
|
||||
|
||||
// Load tree whenever the company data changes (or first becomes available)
|
||||
let _lastCompanyId = null;
|
||||
companyAutomat.subscribe((company) => {
|
||||
const id = company.current?.id ?? null;
|
||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
||||
function syncCategoryWithCompany(company) {
|
||||
const id = company?.current?.id ?? null;
|
||||
const rootCategoryId = company?.current?.rootCategoryId ?? null;
|
||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||
const isCompanySwitch = _lastCompanyId !== null;
|
||||
_lastCompanyId = id;
|
||||
@@ -494,7 +499,10 @@ companyAutomat.subscribe((company) => {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
companyAutomat.subscribe(syncCategoryWithCompany);
|
||||
syncCategoryWithCompany(companyAutomat.state);
|
||||
|
||||
// Reset tree on logout
|
||||
authAutomat.subscribe((auth) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* @file createListAutomat.js
|
||||
* @description Factory function for company-scoped list automats with
|
||||
* deduplicated `load()`, silent `reload()`, and automatic session synchronization.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes each created list automat to auth state — calls
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
*
|
||||
* Used by:
|
||||
* - `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
|
||||
* `entityAutomat`, `customergroupAutomat`, `numberAutomat`.
|
||||
*
|
||||
* NOT used by `articleIndexAutomat`: that store is derived from the loaded
|
||||
* category views rather than fetched from a whole-company endpoint.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ListAutomatState
|
||||
* @property {Array<object>} items Loaded records (empty until the first success).
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|string|null} error Last load error.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a company-scoped list store following the brand/unit/warehouse
|
||||
* pattern: `load()` deduplicates concurrent callers, `reload()` refreshes
|
||||
* silently, and `reset()` clears on logout.
|
||||
*
|
||||
* This replaces the stale-while-revalidate list caches that previously lived
|
||||
* in `StorageManager` (articleIndex, delivery, quote, order, entity,
|
||||
* customergroup, number).
|
||||
*
|
||||
* @template T
|
||||
* @param {object} options Factory options.
|
||||
* @param {string} options.name Automat registry name (also used for logging).
|
||||
* @param {() => Promise<Array<T>>} options.fetch Loads the full record list.
|
||||
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
|
||||
* @returns {Automat<ListAutomatState>} Configured list automat instance.
|
||||
*/
|
||||
export function createListAutomat({ name, fetch: fetchItems, sort }) {
|
||||
const initialState = { items: [], status: 'idle', error: null };
|
||||
|
||||
let automat;
|
||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Fetches and normalizes the record list.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Records from the backend.
|
||||
*/
|
||||
const fetchRecords = async () => {
|
||||
const items = await fetchItems();
|
||||
return sort ? sort(items) : items;
|
||||
};
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads the list, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
load() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
automat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the list while keeping the current state, so
|
||||
* subscribers do not flash a loading state.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Fresh records.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<T>} items Records to cache.
|
||||
* @returns {Array<T>} Cached records.
|
||||
*/
|
||||
replace(items) {
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-fetches the list once the selected company is known.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
ensureLoaded() {
|
||||
if (automat.state.status === 'ready') {
|
||||
return Promise.resolve(automat.state.items);
|
||||
}
|
||||
return actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout). */
|
||||
reset() {
|
||||
inflightLoad = null;
|
||||
automat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
automat = new Automat(initialState, actions, { name });
|
||||
|
||||
// Load on session/company establishment, reset on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void actions.load().catch(() => {});
|
||||
} else if (auth.status === 'anon') {
|
||||
actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return automat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the record list of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {Array<object>} Current records.
|
||||
*/
|
||||
export function selectListItems(automat) {
|
||||
return automat.state.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the load status of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
|
||||
*/
|
||||
export function selectListStatus(automat) {
|
||||
return automat.state.status;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* @file customergroupAutomat.js
|
||||
* @description Manages cached customer groups (`GET /api/customergroup`) for
|
||||
* the active company. Follows the brand/warehouse automat pattern with
|
||||
* filtering, deduplicated loads, silent reloads, and CRUD actions.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`,
|
||||
* and `actions.reset()` on `'anon'`.
|
||||
* - UI / Routes: Mutating actions (`create`, `update`, `remove`, `reorder`, `filter`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { customergroup as customergroupApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} CustomerGroup
|
||||
* @property {string|number} id Customer group id.
|
||||
* @property {string} name Group name.
|
||||
* @property {number} sort Sort index.
|
||||
* @property {boolean} editable False for reserved POS / Lieferanten groups.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CustomerGroupState
|
||||
* @property {Array<CustomerGroup>} items Loaded records.
|
||||
* @property {Array<CustomerGroup>} filteredItems Records matching active search filter.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|string|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {CustomerGroupState} */
|
||||
const initialState = {
|
||||
items: [],
|
||||
filteredItems: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
const applyFilter = (groups, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query
|
||||
? groups.filter((g) => String(g.name ?? '').toLowerCase().includes(query))
|
||||
: groups;
|
||||
};
|
||||
|
||||
const sortGroups = (items) =>
|
||||
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0));
|
||||
|
||||
let customergroupAutomat;
|
||||
|
||||
const updateStateWithGroups = (groups, extra = {}) => {
|
||||
const list = sortGroups(Array.isArray(groups) ? groups : []);
|
||||
const filteredItems = applyFilter(list, customergroupAutomat.state.filter);
|
||||
customergroupAutomat.setState({ items: list, filteredItems, ...extra });
|
||||
return list;
|
||||
};
|
||||
|
||||
async function fetchGroups() {
|
||||
const data = await customergroupApi.list();
|
||||
requireObject(data, 'GET /api/customergroup');
|
||||
return requireArray(data.customergroups, 'customergroups');
|
||||
}
|
||||
|
||||
let inflightLoad = null;
|
||||
|
||||
function loadGroups() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
customergroupAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const groups = await fetchGroups();
|
||||
updateStateWithGroups(groups, { status: 'ready', error: null });
|
||||
return groups;
|
||||
} catch (error) {
|
||||
customergroupAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active search filter query and updates filteredItems. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
customergroupAutomat.setState({
|
||||
filter,
|
||||
filteredItems: applyFilter(customergroupAutomat.state.items, filter),
|
||||
});
|
||||
},
|
||||
|
||||
/** Loads the list, deduplicating concurrent callers. */
|
||||
load() {
|
||||
return loadGroups();
|
||||
},
|
||||
|
||||
/** Silently refreshes the customer groups without flashing loading state. */
|
||||
async reload() {
|
||||
try {
|
||||
const groups = await fetchGroups();
|
||||
updateStateWithGroups(groups, { status: 'ready', error: null });
|
||||
return groups;
|
||||
} catch (error) {
|
||||
customergroupAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** Replaces the cached list without an API call. */
|
||||
replace(items) {
|
||||
return updateStateWithGroups(items, { status: 'ready', error: null });
|
||||
},
|
||||
|
||||
/** Returns cached items if ready, otherwise loads. */
|
||||
ensureLoaded() {
|
||||
if (customergroupAutomat.state.status === 'ready') {
|
||||
return Promise.resolve(customergroupAutomat.state.items);
|
||||
}
|
||||
return actions.load();
|
||||
},
|
||||
|
||||
/** Resets state to initial idle values. */
|
||||
reset() {
|
||||
inflightLoad = null;
|
||||
customergroupAutomat.setState(initialState);
|
||||
},
|
||||
|
||||
/** Creates a customer group and refreshes the cache. */
|
||||
async create(body) {
|
||||
const data = await customergroupApi.create(body);
|
||||
requireObject(data, 'POST /api/customergroup');
|
||||
requireObject(data.customergroup, 'customergroup');
|
||||
requireDefined(data.customergroup.id, 'customergroup.id');
|
||||
await customergroupAutomat.actions.reload();
|
||||
return data.customergroup;
|
||||
},
|
||||
|
||||
/** Updates a customer group name and refreshes the cache. */
|
||||
async update(id, body) {
|
||||
const data = await customergroupApi.update(id, body);
|
||||
requireObject(data, 'PATCH /api/customergroup/:id');
|
||||
requireObject(data.customergroup, 'customergroup');
|
||||
requireDefined(data.customergroup.id, 'customergroup.id');
|
||||
await customergroupAutomat.actions.reload();
|
||||
return data.customergroup;
|
||||
},
|
||||
|
||||
rename(id, body) {
|
||||
return actions.update(id, body);
|
||||
},
|
||||
|
||||
/** Deletes a customer group and refreshes the cache. */
|
||||
async remove(id) {
|
||||
await customergroupApi.delete(id);
|
||||
await customergroupAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/** Reorders customer groups and refreshes the cache. */
|
||||
async reorder(customergroupIds) {
|
||||
const data = await customergroupApi.order({ customergroupIds });
|
||||
requireObject(data, 'PUT /api/customergroup/order');
|
||||
await customergroupAutomat.actions.reload();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company customer groups (`GET /api/customergroup`).
|
||||
*
|
||||
* Reserved groups (`POS`, `Lieferanten`) are flagged `editable: false` and must
|
||||
* not be renamed or deleted by the UI.
|
||||
*
|
||||
* @type {Automat<CustomerGroupState>}
|
||||
*/
|
||||
customergroupAutomat = new Automat(initialState, actions, {
|
||||
name: 'customergroup',
|
||||
});
|
||||
|
||||
export { customergroupAutomat };
|
||||
|
||||
// Load on session/company establishment, reset on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void actions.load().catch(() => {});
|
||||
} else if (auth.status === 'anon') {
|
||||
actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
export default customergroupAutomat;
|
||||
@@ -14,7 +14,7 @@ import { Automat } from 'react-automat';
|
||||
import { entity as entityApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
import customergroupAutomat from './customergroupAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Entity
|
||||
@@ -48,7 +48,7 @@ const initialState = {
|
||||
const applyFilter = (entities, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
if (!query) return entities;
|
||||
const groups = customergroupAutomat?.state?.items ?? [];
|
||||
const groups = companyAutomat?.state?.customergroups ?? [];
|
||||
const groupsById = Object.fromEntries(groups.map((g) => [g.id, g]));
|
||||
|
||||
return entities.filter((e) => {
|
||||
@@ -200,13 +200,15 @@ entityAutomat = new Automat(initialState, actions, {
|
||||
export { entityAutomat };
|
||||
|
||||
// Load on session/company establishment, reset on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const syncEntityWithAuth = (auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void actions.load().catch(() => {});
|
||||
} else if (auth.status === 'anon') {
|
||||
actions.reset();
|
||||
}
|
||||
});
|
||||
};
|
||||
authAutomat.subscribe(syncEntityWithAuth);
|
||||
syncEntityWithAuth(authAutomat.state);
|
||||
|
||||
export default entityAutomat;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file index.js
|
||||
* @description Central barrel exporting all domain, configuration, and list
|
||||
* @description Central barrel exporting domain, configuration, and list
|
||||
* automats, along with their action creators and reactive selectors.
|
||||
*/
|
||||
|
||||
@@ -22,9 +22,6 @@ export {
|
||||
clearNavigation,
|
||||
} from './navigationAutomat';
|
||||
|
||||
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
||||
export { setViewerPosition } from './userSettingsAutomat';
|
||||
|
||||
export { default as helpAutomat } from './helpAutomat';
|
||||
export {
|
||||
toggleHelp,
|
||||
@@ -44,10 +41,6 @@ export {
|
||||
applyPwaUpdate,
|
||||
} from './pwaAutomat';
|
||||
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
export { default as carrierAutomat } from './carrierAutomat';
|
||||
|
||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||
export {
|
||||
default as articleSearchAutomat,
|
||||
@@ -112,9 +105,6 @@ export {
|
||||
getChunkStats as getRetoureChunkStats,
|
||||
subscribeChunkStats as subscribeRetoureChunkStats,
|
||||
} from './retoureAutomat';
|
||||
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,
|
||||
@@ -127,8 +117,6 @@ export {
|
||||
getChunkStats as getShipmentChunkStats,
|
||||
subscribeChunkStats as subscribeShipmentChunkStats,
|
||||
} from './shipmentAutomat';
|
||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||
|
||||
|
||||
/** Per-SKU article detail views keyed by SKU. */
|
||||
export { articleHistoryAutomat, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
@@ -156,19 +144,33 @@ export {
|
||||
selectCompanyReducedTaxrate,
|
||||
selectCompanyTaxfreerate,
|
||||
selectCompanyRootCategoryId,
|
||||
} from './companyAutomat';
|
||||
|
||||
export { default as brandAutomat } from './brandAutomat';
|
||||
export { default as unitAutomat } from './unitAutomat';
|
||||
export { default as attributeAutomat, selectAttributes, selectAttributeStatus } from './attributeAutomat';
|
||||
export { default as propertyAutomat, selectProperties, selectPropertyStatus } from './propertyAutomat';
|
||||
|
||||
export { default as posterminalAutomat } from './posterminalAutomat';
|
||||
export {
|
||||
selectNumbers,
|
||||
selectNumbersStatus,
|
||||
selectPosterminals,
|
||||
selectPosterminalStatus,
|
||||
selectHasPosterminals,
|
||||
} from './posterminalAutomat';
|
||||
selectSuppliers,
|
||||
selectSuppliersStatus,
|
||||
selectUnits,
|
||||
selectUnitsStatus,
|
||||
setViewerPosition,
|
||||
setChatDrawerWidth,
|
||||
selectChatDrawerWidth,
|
||||
selectWarehouses,
|
||||
selectWarehousesStatus,
|
||||
selectProperties,
|
||||
selectPropertyStatus,
|
||||
selectCustomergroups,
|
||||
selectCustomergroupsStatus,
|
||||
selectCarriers,
|
||||
selectCarriersStatus,
|
||||
selectBrands,
|
||||
selectBrandsStatus,
|
||||
selectAttributes,
|
||||
selectAttributeStatus,
|
||||
selectAccounts,
|
||||
selectAccountsStatus,
|
||||
} from './companyAutomat';
|
||||
|
||||
export { default as categoryAutomat } from './categoryAutomat';
|
||||
export {
|
||||
@@ -232,84 +234,3 @@ export {
|
||||
closeExecution,
|
||||
normalizeRoute,
|
||||
} from './pinnedAppletAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* Overview of Automats in Dashfront
|
||||
* ============================================================================
|
||||
*
|
||||
* Automats are lightweight, reactive, out-of-tree state containers powered by
|
||||
* `react-automat`. They manage all client-side state without React context,
|
||||
* hooks, or HOCs, enabling pure, zero-flash rendering in `React.PureComponent`
|
||||
* via constructor reads and fine-grained subscriptions.
|
||||
*
|
||||
* 1. Multi-Tenant Session & Security
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `authAutomat`: Tracks authenticated session (`user`, `googleClientId`,
|
||||
* `selectedCompanyId`, and auth status `'loading' | 'anon' | 'authed'`).
|
||||
* Coordinates session bootstrap, company switching, and acts as the root
|
||||
* lifecycle trigger that loads or resets all company-scoped automats.
|
||||
*
|
||||
* 2. Real-Time SSE Hub
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `sseAutomat`: Manages the single persistent Server-Sent Events connection
|
||||
* (`GET /api/events`). Automatically reconnects when the active company changes
|
||||
* and terminates on logout. Dispatches incoming server mutation signals into
|
||||
* targeted automats (`artikelAutomat`, `articleDetailAutomat`, `categoryAutomat`,
|
||||
* `articleIndexAutomat`, `warehouseAutomat`, `supplierAutomat`, `attributeAutomat`,
|
||||
* `propertyAutomat`).
|
||||
*
|
||||
* 3. Domain & Entity Caches
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `artikelAutomat`: In-memory cache for full article records (`GET /api/article/:sku`)
|
||||
* with inflight request deduplication, optimistic renames, and reactive selectors.
|
||||
* - `articleDetailAutomat`: Keyed automats (`articleHistoryAutomat`, `articleSupplierAutomat`)
|
||||
* providing per-SKU supplier catalog bindings and change history.
|
||||
* - `deliveryEditorAutomat`: Interactive draft state machine for incoming goods receipts
|
||||
* and invoice reconciliation (line items, supplier article auto-matching, PDF/receipt OCR).
|
||||
*
|
||||
* 4. Company Configuration & Catalogs
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `companyAutomat`: Active company settings (tax rates, ISO language, root category ID).
|
||||
* - `categoryAutomat`: Hierarchical category tree (ID -> node map), mutations (create,
|
||||
* rename, move, reorder, delete), and breadcrumb resolution.
|
||||
* - `categoryViewAutomat`: Keyed view container (`categoryViews`) holding articles and child
|
||||
* categories per slug path, auto-preloaded when category tree finishes loading.
|
||||
* - `brandAutomat`: Catalog manufacturer and brand records.
|
||||
* - `unitAutomat`: Allowed units of measure (Stück, kg, Liter, etc.).
|
||||
* - `posterminalAutomat`: Point-of-sale hardware terminals and enabled category filters.
|
||||
* - `attributeAutomat`: Product attribute definitions and discrete values (e.g. Size, Color).
|
||||
* - `propertyAutomat`: Custom product property and metadata names.
|
||||
* - `supplierAutomat`: Directory of vendor entities and supplier article mappings.
|
||||
* - `warehouseAutomat`: Warehouse locations and stock storage areas.
|
||||
*
|
||||
* 5. Company-Scoped List Automats (`createListAutomat`)
|
||||
* ----------------------------------------------------------------------------
|
||||
* Built with the shared list factory with deduplicated `load()`, silent `reload()`,
|
||||
* and automatic synchronization with `authAutomat`:
|
||||
* - `deliveryAutomat`: Delivery receipt list (`GET /api/delivery`).
|
||||
* - `quoteAutomat`: Sales quotes / Angebote (`GET /api/quote`).
|
||||
* - `orderAutomat`: Sales orders / Bestellungen (`GET /api/order`).
|
||||
* - `entityAutomat`: Customers and business partners (`GET /api/entity`).
|
||||
* - `customergroupAutomat`: Customer discount groups (`GET /api/customergroup`).
|
||||
* - `numberAutomat`: Document numbering sequences (`GET /api/number`).
|
||||
*
|
||||
* 6. Search & Infinite-Scroll Grid Automats
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `articleIndexAutomat`: Fast client-side search index, **derived** from the loaded
|
||||
* category views (`GET /api/category/:id/articles`) — no dedicated index endpoint.
|
||||
* - `articleSearchAutomat`: Client-side search engine querying `articleIndexAutomat`.
|
||||
* - `articleHistoryGridAutomat`: Virtualized infinite-scroll grid for article history.
|
||||
* - `globalHistoryGridAutomat`: Virtualized infinite-scroll grid for global audit logs.
|
||||
*
|
||||
* 7. UI State & Utilities
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `chatAutomat`: AI assistant chat drawer state, conversations, and live SSE streaming tokens.
|
||||
* - `navigationAutomat`: Persists last visited delivery document path and article SKU.
|
||||
* - `userSettingsAutomat`: User layout preferences (viewer position, drawer widths in IndexedDB).
|
||||
* - `helpAutomat`: Interactive help badge states and popper positioning (in IndexedDB).
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @file numberAutomat.js
|
||||
* @description Manages cached document numbering sequences (`GET /api/number`)
|
||||
* (e.g. delivery, entity) for the active company. Built via `createListAutomat`.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
|
||||
* a `selectedCompanyId`, and `reset()` on `'anon'`.
|
||||
* - UI / Routes: Manual calls to `load()` or `reload()`.
|
||||
*/
|
||||
|
||||
import { number as numberApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NumberSequence
|
||||
* @property {string|number} id Sequence id.
|
||||
* @property {string} type Document type key (e.g. `delivery`, `entity`).
|
||||
* @property {string} prefix Prefix rendered before the counter.
|
||||
* @property {number} padding Zero-padding width.
|
||||
* @property {number} next Next counter value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company document number sequences (`GET /api/number`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const numberAutomat = createListAutomat({
|
||||
name: 'number',
|
||||
fetch: async () => {
|
||||
const data = await numberApi.list();
|
||||
return data.numbers ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) =>
|
||||
String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'),
|
||||
),
|
||||
});
|
||||
|
||||
export default numberAutomat;
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* @file posterminalAutomat.js
|
||||
* @description Manages registered Point-of-Sale (POS) hardware terminals and
|
||||
* their enabled category mappings for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `companyAutomat`: Reads `companyAutomat.state.current?.id` as fallback when loading.
|
||||
* - UI / Settings: POS terminal actions (`create`, `delete`, `toggleCategory`, `setCategoryEnabled`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { posterminal as posterminalApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} PosterminalState
|
||||
* @property {Array<object>} terminals
|
||||
* @property {number|string|null} companyId
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {PosterminalState} */
|
||||
const initialState = {
|
||||
terminals: [],
|
||||
companyId: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let posterminalAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all posterminals for the selected company. */
|
||||
async load() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
|
||||
if (!companyId) return [];
|
||||
posterminalAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await posterminalApi.list();
|
||||
const terminals = Array.isArray(res.posterminals) ? res.posterminals : [];
|
||||
posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' });
|
||||
return terminals;
|
||||
} catch (err) {
|
||||
posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new posterminal.
|
||||
* @param {{ name: string }} param0
|
||||
*/
|
||||
async create({ name }) {
|
||||
const res = await posterminalApi.create({ name });
|
||||
const terminal = res.posterminal;
|
||||
posterminalAutomat.setState({
|
||||
terminals: [...posterminalAutomat.state.terminals, terminal],
|
||||
});
|
||||
return terminal;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a posterminal.
|
||||
* @param {number} terminalId
|
||||
* @param {object} patch
|
||||
*/
|
||||
async update(terminalId, patch) {
|
||||
const res = await posterminalApi.update(terminalId, patch);
|
||||
const updated = res.posterminal;
|
||||
posterminalAutomat.setState({
|
||||
terminals: posterminalAutomat.state.terminals.map((t) =>
|
||||
t.id === terminalId ? { ...t, ...updated } : t
|
||||
),
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a posterminal.
|
||||
* @param {number} terminalId
|
||||
*/
|
||||
async delete(terminalId) {
|
||||
await posterminalApi.delete(terminalId);
|
||||
posterminalAutomat.setState({
|
||||
terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable all categories on a terminal.
|
||||
* @param {{ terminalId: number }} param0
|
||||
*/
|
||||
async enableAll({ terminalId }) {
|
||||
const res = await posterminalApi.enableAllCategories(terminalId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable all categories on a terminal.
|
||||
* @param {{ terminalId: number }} param0
|
||||
*/
|
||||
async disableAll({ terminalId }) {
|
||||
const res = await posterminalApi.disableAllCategories(terminalId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable a specific category on a terminal.
|
||||
* @param {{ terminalId: number, categoryId: number }} param0
|
||||
*/
|
||||
async enableCategory({ terminalId, categoryId }) {
|
||||
const res = await posterminalApi.enableCategory(terminalId, categoryId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable a specific category on a terminal.
|
||||
* @param {{ terminalId: number, categoryId: number }} param0
|
||||
*/
|
||||
async disableCategory({ terminalId, categoryId }) {
|
||||
const res = await posterminalApi.disableCategory(terminalId, categoryId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
posterminalAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
function patchTerminalCategories(terminalId, categoryIds) {
|
||||
posterminalAutomat.setState({
|
||||
terminals: posterminalAutomat.state.terminals.map((t) =>
|
||||
t.id === terminalId ? { ...t, categoryIds } : t,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Global posterminal state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<PosterminalState>}
|
||||
*/
|
||||
posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' });
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void posterminalAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
posterminalAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Selectors
|
||||
export const selectPosterminals = () => posterminalAutomat.state.terminals;
|
||||
export const selectPosterminalStatus = () => posterminalAutomat.state.status;
|
||||
export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0;
|
||||
|
||||
export default posterminalAutomat;
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* @file propertyAutomat.js
|
||||
* @description Manages catalog product property names (custom metadata definitions)
|
||||
* for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.load()` upon receiving a `'propertyname'` SSE event.
|
||||
* - UI / Settings: Mutating actions (`createProperty`, `updateProperty`,
|
||||
* `deleteProperty`, `reorderProperties`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { propertyname as propertynameApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} PropertyName
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {number} sort
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PropertyState
|
||||
* @property {PropertyName[]} properties
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {PropertyState} */
|
||||
const initialState = {
|
||||
properties: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
function normalizeProperties(properties) {
|
||||
if (!Array.isArray(properties)) return [];
|
||||
return [...properties]
|
||||
.map((p) => ({
|
||||
...p,
|
||||
exclusive: Boolean(p.exclusive),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
||||
if (sDiff !== 0) return sDiff;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
let propertyAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all property names for the selected company. */
|
||||
async load() {
|
||||
propertyAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await propertynameApi.list();
|
||||
const properties = normalizeProperties(res.propertynames);
|
||||
propertyAutomat.setState({ properties, status: 'succeeded' });
|
||||
return properties;
|
||||
} catch (err) {
|
||||
propertyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a property name.
|
||||
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async createProperty(body) {
|
||||
const res = await propertynameApi.create(body);
|
||||
await actions.load();
|
||||
return res.propertyname;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a property name.
|
||||
* @param {number} id
|
||||
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async updateProperty(id, body) {
|
||||
const res = await propertynameApi.update(id, body);
|
||||
await actions.load();
|
||||
return res.propertyname;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a property name.
|
||||
* @param {number} id
|
||||
*/
|
||||
async deleteProperty(id) {
|
||||
await propertynameApi.delete(id);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete multiple property names.
|
||||
* @param {number[]} ids
|
||||
*/
|
||||
async deleteProperties(ids) {
|
||||
for (const id of ids) {
|
||||
await propertynameApi.delete(id);
|
||||
}
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder property names.
|
||||
* @param {number[]} propertynameIds
|
||||
*/
|
||||
async reorderProperties(propertynameIds) {
|
||||
await propertynameApi.order(propertynameIds);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
propertyAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global property state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<PropertyState>}
|
||||
*/
|
||||
propertyAutomat = new Automat(initialState, actions, { name: 'property' });
|
||||
|
||||
// Selectors
|
||||
export function selectProperties() {
|
||||
return propertyAutomat.state.properties;
|
||||
}
|
||||
|
||||
export function selectPropertyStatus() {
|
||||
return propertyAutomat.state.status;
|
||||
}
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void propertyAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
propertyAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
export default propertyAutomat;
|
||||
@@ -1,44 +1,26 @@
|
||||
/**
|
||||
* @file sseAutomat.js
|
||||
* @description Central Server-Sent Events hub managing the single persistent
|
||||
* `GET /api/events` connection and fanning mutation signals into domain automats.
|
||||
* `GET /api/events` connection and fanning mutation signals into active automats.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — reconnects stream when
|
||||
* `selectedCompanyId` changes/becomes available; disconnects on `'anon'` (logout).
|
||||
* - `authAutomat`: Connects stream when `hasAuth` is true (and reconnects when
|
||||
* `selectedCompanyId` changes); disconnects when `hasAuth` is false (logout).
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* Dispatches real-time SSE mutation events into specific automats:
|
||||
* - `artikelAutomat`: Re-fetches article via `reloadArticle(sku)` on `article-<sku>` signals.
|
||||
* - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`):
|
||||
* Reloads SKU-keyed views on `article-<sku>` signals.
|
||||
* - `categoryAutomat`: Triggers `actions.loadTree()` and `invalidateAllCategoryViews()`
|
||||
* on `'category'` SSE events.
|
||||
* - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events;
|
||||
* the index is derived from the reloaded category views, so no extra request.
|
||||
* - `warehouseAutomat`: Triggers `actions.reload()` on `'warehouse'` SSE events.
|
||||
* - `supplierAutomat`: Triggers `actions.reload()` on `'supplier'` SSE events.
|
||||
* - `attributeAutomat`: Triggers `actions.load()` on `'attributename'` SSE events.
|
||||
* - `propertyAutomat`: Triggers `actions.load()` on `'propertyname'` SSE events.
|
||||
* Dispatches real-time SSE mutation events into active automats without eagerly
|
||||
* importing or instantiating unmounted stores:
|
||||
* - `companyAutomat`: Refreshes warehouses, carriers, attributes, properties,
|
||||
* suppliers, and brands when `companyAutomat` is active.
|
||||
* - `categoryAutomat`: Refreshes category tree and open category views on `'category'`.
|
||||
* - `articleIndexAutomat`: Refreshes derived article index on `'articles'` / `'warehouse'`.
|
||||
* - `artikelAutomat` & `articleDetailAutomat`: Refreshes watched SKU on `article-<sku>`.
|
||||
* - `orderAutomat`, `quoteAutomat`, `shipmentAutomat`, `deliveryAutomat`, `retoureAutomat`:
|
||||
* Reloads when those automats are active.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
import artikelAutomat from './artikelAutomat';
|
||||
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
import { articleIndexAutomat } from './articleIndexAutomat';
|
||||
import categoryAutomat, { invalidateAllCategoryViews } from './categoryAutomat';
|
||||
import warehouseAutomat from './warehouseAutomat';
|
||||
import attributeAutomat from './attributeAutomat';
|
||||
import propertyAutomat from './propertyAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
import deliveryAutomat from './deliveryAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
import quoteAutomat from './quoteAutomat';
|
||||
import retoureAutomat from './retoureAutomat';
|
||||
import shipmentAutomat from './shipmentAutomat';
|
||||
import carrierAutomat from './carrierAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} SseState
|
||||
@@ -49,7 +31,6 @@ import carrierAutomat from './carrierAutomat';
|
||||
*/
|
||||
|
||||
const EVENT_HISTORY_LIMIT = 200;
|
||||
|
||||
const SSE_CLIENT_ID_KEY = 'sse.cid';
|
||||
|
||||
function createClientId() {
|
||||
@@ -63,8 +44,6 @@ function createClientId() {
|
||||
|
||||
/**
|
||||
* Returns the tab-scoped SSE client id, creating and persisting it on first use.
|
||||
* Created when the automat module is evaluated so every `EventSource` in this
|
||||
* tab shares the same `cid`.
|
||||
*
|
||||
* @returns {string} Client id.
|
||||
*/
|
||||
@@ -81,7 +60,6 @@ export function getSseClientId() {
|
||||
}
|
||||
}
|
||||
|
||||
// Created once per tab at automat creation time and stored to sessionStorage.
|
||||
const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null;
|
||||
|
||||
/** @type {SseState} */
|
||||
@@ -172,10 +150,6 @@ function attachHandlers(es) {
|
||||
/**
|
||||
* Binds the single `article-<sku>` listener for a watched SKU.
|
||||
*
|
||||
* The listener resolves the handler set at call time, so a SKU that lost all
|
||||
* watchers turns into a cheap no-op instead of needing removal. Listeners are
|
||||
* reclaimed whenever the stream is rebuilt on a company switch.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
@@ -189,119 +163,179 @@ function attachArticleListener(sku) {
|
||||
});
|
||||
}
|
||||
|
||||
// #region connect
|
||||
// MARK: opens GET /api/events EventSource when hasAuth is true
|
||||
function connect() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
const auth = authAutomat.state;
|
||||
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
|
||||
if (!hasAuth) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
const companyId =
|
||||
auth.user?.selectedCompanyId != null ? String(auth.user.selectedCompanyId) : null;
|
||||
|
||||
const url = new URL('/api/events', window.location.origin);
|
||||
if (sseClientId) url.searchParams.set('cid', sseClientId);
|
||||
console.log(`[sse] connecting`);
|
||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||
console.log('[sse] connecting');
|
||||
sseAutomat.setState({ status: 'connecting', companyId });
|
||||
|
||||
const es = new EventSource(url.href, { withCredentials: true });
|
||||
|
||||
// Category tree changed: refresh the tree and every open category view.
|
||||
addTrackedListener(es, 'category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||
console.error('[sse] category tree refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
const cat = Automat.get('category');
|
||||
if (cat?.actions?.loadTree) {
|
||||
void cat.actions.loadTree().catch((err) => {
|
||||
console.error('[sse] category tree refresh failed', err);
|
||||
});
|
||||
}
|
||||
cat?.actions?.invalidateAllCategoryViews?.();
|
||||
});
|
||||
|
||||
// Article index changed: refresh the index and open category views.
|
||||
addTrackedListener(es, 'articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh failed', err);
|
||||
});
|
||||
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||
const idx = Automat.get('articleIndex');
|
||||
if (idx?.actions?.reload) {
|
||||
void idx.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'warehouse', () => {
|
||||
void warehouseAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] warehouse refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh on warehouse event failed', err);
|
||||
});
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.reloadWarehouses) {
|
||||
void comp.actions.reloadWarehouses().catch((err) => {
|
||||
console.error('[sse] warehouse refresh failed', err);
|
||||
});
|
||||
}
|
||||
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||
const idx = Automat.get('articleIndex');
|
||||
if (idx?.actions?.reload) {
|
||||
void idx.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh on warehouse event failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'carrier', () => {
|
||||
void carrierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] carrier refresh failed', err);
|
||||
});
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.reloadCarriers) {
|
||||
void comp.actions.reloadCarriers().catch((err) => {
|
||||
console.error('[sse] carrier refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] attributename refresh failed', err);
|
||||
});
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.loadAttributes) {
|
||||
void comp.actions.loadAttributes().catch((err) => {
|
||||
console.error('[sse] attributename refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'propertyname', () => {
|
||||
void propertyAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] propertyname refresh failed', err);
|
||||
});
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.loadProperties) {
|
||||
void comp.actions.loadProperties().catch((err) => {
|
||||
console.error('[sse] propertyname refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'supplier', () => {
|
||||
void supplierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] supplier refresh failed', err);
|
||||
});
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.reloadSuppliers) {
|
||||
void comp.actions.reloadSuppliers().catch((err) => {
|
||||
console.error('[sse] supplier refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'brand', (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event?.data ?? '{}');
|
||||
if (payload?.sender && payload.sender === getSseClientId()) return;
|
||||
} catch {}
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.loadBrands) {
|
||||
void comp.actions.loadBrands(true).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'order', () => {
|
||||
void orderAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] order refresh failed', err);
|
||||
});
|
||||
void shipmentAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
const ord = Automat.get('order');
|
||||
if (ord?.actions?.reload) {
|
||||
void ord.actions.reload().catch((err) => {
|
||||
console.error('[sse] order refresh failed', err);
|
||||
});
|
||||
}
|
||||
const shp = Automat.get('shipment');
|
||||
if (shp?.actions?.reload) {
|
||||
void shp.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'quote', () => {
|
||||
void quoteAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] quote refresh failed', err);
|
||||
});
|
||||
const q = Automat.get('quote');
|
||||
if (q?.actions?.reload) {
|
||||
void q.actions.reload().catch((err) => {
|
||||
console.error('[sse] quote refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'shipment', () => {
|
||||
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);
|
||||
});
|
||||
const shp = Automat.get('shipment');
|
||||
if (shp?.actions?.reload) {
|
||||
void shp.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
}
|
||||
const ord = Automat.get('order');
|
||||
if (ord?.actions?.reload) {
|
||||
void ord.actions.reload().catch((err) => {
|
||||
console.error('[sse] order refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'delivery', () => {
|
||||
void deliveryAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] delivery refresh failed', err);
|
||||
});
|
||||
const del = Automat.get('delivery');
|
||||
if (del?.actions?.reload) {
|
||||
void del.actions.reload().catch((err) => {
|
||||
console.error('[sse] delivery refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'retoure', () => {
|
||||
void retoureAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] retoure refresh failed', err);
|
||||
});
|
||||
void shipmentAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
const ret = Automat.get('retoure');
|
||||
if (ret?.actions?.reload) {
|
||||
void ret.actions.reload().catch((err) => {
|
||||
console.error('[sse] retoure refresh failed', err);
|
||||
});
|
||||
}
|
||||
const shp = Automat.get('shipment');
|
||||
if (shp?.actions?.reload) {
|
||||
void shp.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
addTrackedListener(es, 'open', () => {
|
||||
sseAutomat.setState({ status: 'connected' });
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'message', () => {});
|
||||
|
||||
// EventSource reconnects on its own; only surface the state.
|
||||
es.onerror = (e) => {
|
||||
console.error('[sse] event stream error', e);
|
||||
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
|
||||
@@ -311,11 +345,37 @@ function connect() {
|
||||
eventSource = es;
|
||||
attachHandlers(es);
|
||||
}
|
||||
// #endregion
|
||||
|
||||
let wasAuthed = false;
|
||||
|
||||
function handleAuthSync(auth) {
|
||||
const isAuthed = Boolean(auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user));
|
||||
const companyId =
|
||||
isAuthed && auth?.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
const relogin = isAuthed && !wasAuthed;
|
||||
wasAuthed = isAuthed;
|
||||
|
||||
if (!isAuthed) {
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!relogin && eventSource && companyId === sseAutomat.state.companyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Closes and reopens the event stream. Required after a company switch,
|
||||
* because the stream is scoped to the selected company at connect time.
|
||||
* Closes and reopens the event stream.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
@@ -324,6 +384,15 @@ const actions = {
|
||||
connect();
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronizes the SSE connection with `authAutomat` state.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState
|
||||
*/
|
||||
syncWithAuth(authState) {
|
||||
handleAuthSync(authState ?? authAutomat.state);
|
||||
},
|
||||
|
||||
clearEvents() {
|
||||
sseAutomat.setState({ events: [] });
|
||||
},
|
||||
@@ -331,9 +400,6 @@ const actions = {
|
||||
/**
|
||||
* Registers a handler for a statically named backend event.
|
||||
*
|
||||
* Registration order does not matter: handlers added while disconnected are
|
||||
* attached on the next connect.
|
||||
*
|
||||
* @param {string} event Event name (e.g. `category`, `articles`).
|
||||
* @param {(event: MessageEvent) => void} handler Signal handler.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
@@ -360,9 +426,6 @@ const actions = {
|
||||
/**
|
||||
* Subscribes to `article-<sku>` signals for one SKU.
|
||||
*
|
||||
* The backend emits this event whenever that article changes. Exactly one
|
||||
* listener is attached per SKU while at least one caller is subscribed.
|
||||
*
|
||||
* @param {string} sku Article SKU to watch.
|
||||
* @param {(sku: string) => void} handler Called with the changed SKU.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
@@ -389,9 +452,6 @@ const actions = {
|
||||
/**
|
||||
* Global `GET /api/events` connection.
|
||||
*
|
||||
* Owns the single `EventSource` for the tab and dispatches signals into the
|
||||
* domain automats. Reconnects whenever the selected company changes.
|
||||
*
|
||||
* @type {Automat<SseState>}
|
||||
*/
|
||||
sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
||||
@@ -399,48 +459,26 @@ sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
||||
/**
|
||||
* Refreshes the cached article plus any open history/supplier view.
|
||||
*
|
||||
* Background refreshes stay silent: an article that is not on screen has no
|
||||
* visible subscriber to notify, so failures are swallowed.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function invalidateArticle(sku) {
|
||||
const key = String(sku);
|
||||
void artikelAutomat.actions.reloadArticle(key).catch(() => {});
|
||||
if (articleHistoryAutomat.has(key)) {
|
||||
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||
const art = Automat.get('artikel');
|
||||
if (art?.actions?.reloadArticle) {
|
||||
void art.actions.reloadArticle(key).catch(() => {});
|
||||
}
|
||||
if (articleSupplierAutomat.has(key)) {
|
||||
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||
const hist = Automat.get('articleHistory');
|
||||
if (hist?.has?.(key)) {
|
||||
void hist.reload(key).catch(() => {});
|
||||
}
|
||||
const sup = Automat.get('articleSupplier');
|
||||
if (sup?.has?.(key)) {
|
||||
void sup.reload(key).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect when the selected company changes; disconnect on logout.
|
||||
// On logout the stream must close; on relogin (anon -> authed with a company)
|
||||
// the stream is re-established even if the company id matches the previous one.
|
||||
let wasAuthed = false;
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
const isAuthed = companyId != null;
|
||||
const relogin = isAuthed && !wasAuthed;
|
||||
wasAuthed = isAuthed;
|
||||
|
||||
if (!relogin && companyId === sseAutomat.state.companyId) return;
|
||||
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// A different company (or a fresh login) means a different stream scope.
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
});
|
||||
authAutomat.subscribe(handleAuthSync);
|
||||
handleAuthSync(authAutomat.state);
|
||||
|
||||
export default sseAutomat;
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
/**
|
||||
* @file supplierAutomat.js
|
||||
* @description Manages cached supplier list (`GET /api/supplier`) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'supplier'` SSE event.
|
||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setSuppliers()`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { supplier as supplierApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Supplier
|
||||
* @property {string|number} id Supplier identifier.
|
||||
* @property {string} name Supplier name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SupplierState
|
||||
* @property {Array<Supplier>} suppliers Company-scoped suppliers.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {SupplierState} */
|
||||
const initialState = {
|
||||
suppliers: [],
|
||||
articlesBySupplier: {},
|
||||
articlesLoading: false,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let supplierAutomat;
|
||||
|
||||
/**
|
||||
* Fetches the company-scoped supplier list.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Supplier list.
|
||||
*/
|
||||
async function fetchSuppliers() {
|
||||
const data = await supplierApi.list();
|
||||
requireObject(data, 'GET /api/supplier');
|
||||
return requireArray(data.suppliers, 'suppliers');
|
||||
}
|
||||
|
||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Loads the supplier list once, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
|
||||
*/
|
||||
function loadSuppliers() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
supplierAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const suppliers = await fetchSuppliers();
|
||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
||||
void actions.loadAllSupplierArticles().catch(() => {});
|
||||
return suppliers;
|
||||
} catch (error) {
|
||||
supplierAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads the supplier list, exposing a loading/failed lifecycle.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
|
||||
*/
|
||||
load() {
|
||||
return loadSuppliers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the supplier list while keeping the current state.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Fresh suppliers.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const suppliers = await fetchSuppliers();
|
||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
||||
void actions.loadAllSupplierArticles().catch(() => {});
|
||||
return suppliers;
|
||||
} catch (error) {
|
||||
supplierAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads articles for a single supplier, caching them in state.articlesBySupplier.
|
||||
*
|
||||
* @param {string|number} supplierId
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
async loadArticlesForSupplier(supplierId) {
|
||||
const sId = String(supplierId);
|
||||
if (supplierAutomat.state.articlesBySupplier?.[sId]) {
|
||||
return supplierAutomat.state.articlesBySupplier[sId];
|
||||
}
|
||||
const res = await supplierApi.getArticles(supplierId);
|
||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||
supplierAutomat.setState({
|
||||
articlesBySupplier: {
|
||||
...(supplierAutomat.state.articlesBySupplier || {}),
|
||||
[sId]: articles,
|
||||
},
|
||||
});
|
||||
return articles;
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads articles for all suppliers in the supplier list, caching them in state.articlesBySupplier.
|
||||
*
|
||||
* @returns {Promise<Record<string, Array<object>>>}
|
||||
*/
|
||||
async loadAllSupplierArticles() {
|
||||
const suppliers = supplierAutomat.state.suppliers;
|
||||
if (!suppliers.length) return {};
|
||||
supplierAutomat.setState({ articlesLoading: true });
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
suppliers.map(async (s) => {
|
||||
const res = await supplierApi.getArticles(s.id);
|
||||
return { id: String(s.id), articles: Array.isArray(res?.articles) ? res.articles : [] };
|
||||
}),
|
||||
);
|
||||
const nextMap = { ...(supplierAutomat.state.articlesBySupplier || {}) };
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
nextMap[r.value.id] = r.value.articles;
|
||||
}
|
||||
}
|
||||
supplierAutomat.setState({ articlesBySupplier: nextMap, articlesLoading: false });
|
||||
return nextMap;
|
||||
} catch {
|
||||
supplierAutomat.setState({ articlesLoading: false });
|
||||
return supplierAutomat.state.articlesBySupplier || {};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a supplier and refreshes the cached list.
|
||||
*
|
||||
* @param {{name: string}} body Creation payload.
|
||||
* @returns {Promise<Supplier>} Created supplier.
|
||||
*/
|
||||
async create(body) {
|
||||
const data = await supplierApi.create(body);
|
||||
requireObject(data, 'POST /api/supplier');
|
||||
requireObject(data.supplier, 'supplier');
|
||||
requireDefined(data.supplier.id, 'supplier.id');
|
||||
await supplierAutomat.actions.reload();
|
||||
return data.supplier;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renames a supplier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Supplier identifier.
|
||||
* @param {{name: string}} body Update payload.
|
||||
* @returns {Promise<Supplier>} Updated supplier.
|
||||
*/
|
||||
async update(id, body) {
|
||||
const data = await supplierApi.update(id, body);
|
||||
requireObject(data, 'PATCH /api/supplier/:id');
|
||||
requireObject(data.supplier, 'supplier');
|
||||
requireDefined(data.supplier.id, 'supplier.id');
|
||||
await supplierAutomat.actions.reload();
|
||||
return data.supplier;
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a supplier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Supplier identifier.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id) {
|
||||
await supplierApi.delete(id);
|
||||
await supplierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<Supplier>} suppliers Suppliers to cache.
|
||||
* @returns {void}
|
||||
*/
|
||||
setSuppliers(suppliers) {
|
||||
supplierAutomat.setState({
|
||||
suppliers: Array.isArray(suppliers) ? suppliers : [],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the cached list and returns to the initial state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
supplierAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped supplier cache. Loads automatically once an authenticated
|
||||
* session with a selected company exists and is cleared when auth is gone.
|
||||
*
|
||||
* @type {Automat<SupplierState>}
|
||||
*/
|
||||
supplierAutomat = new Automat(initialState, actions, { name: 'supplier' });
|
||||
|
||||
/**
|
||||
* Derives the cache key from the auth state: the selected company while
|
||||
* authenticated, otherwise `null`.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {string|null} Cache key.
|
||||
*/
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
|
||||
/**
|
||||
* Loads the supplier list when auth/company becomes available and forgets it
|
||||
* when the session ends.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
supplierAutomat.actions.clear();
|
||||
} else {
|
||||
void supplierAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
|
||||
export default supplierAutomat;
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* @file unitAutomat.js
|
||||
* @description Manages allowable units of measure (Stück, kg, Liter, etc.)
|
||||
* for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - UI / Settings: Mutating actions (`upsert`, `delete`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { unit as unitApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} UnitState
|
||||
* @property {Array<object>} units
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {UnitState} */
|
||||
const initialState = {
|
||||
units: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
function sortUnits(units) {
|
||||
return [...units].sort((a, b) => {
|
||||
const left = a.symbol ?? a.si ?? '';
|
||||
const right = b.symbol ?? b.si ?? '';
|
||||
return String(left).localeCompare(String(right), 'de');
|
||||
});
|
||||
}
|
||||
|
||||
let unitAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all units for the selected company. */
|
||||
async load() {
|
||||
unitAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await unitApi.list();
|
||||
const units = sortUnits(Array.isArray(res.units) ? res.units : []);
|
||||
unitAutomat.setState({ units, status: 'succeeded' });
|
||||
return units;
|
||||
} catch (err) {
|
||||
unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upsert (create or update) a unit.
|
||||
* @param {object} body
|
||||
*/
|
||||
async upsert(body) {
|
||||
const res = await unitApi.upsert(body);
|
||||
const row = res.unit;
|
||||
const units = sortUnits([
|
||||
...unitAutomat.state.units.filter((u) => u.si !== row.si),
|
||||
row,
|
||||
]);
|
||||
unitAutomat.setState({ units });
|
||||
return units;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a unit by SI symbol.
|
||||
* @param {string} si
|
||||
*/
|
||||
async delete(si) {
|
||||
await unitApi.delete(si);
|
||||
unitAutomat.setState({
|
||||
units: unitAutomat.state.units.filter((u) => u.si !== si),
|
||||
});
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
unitAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global unit state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<UnitState>}
|
||||
*/
|
||||
unitAutomat = new Automat(initialState, actions, { name: 'unit' });
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void unitAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
unitAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
export default unitAutomat;
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @file userSettingsAutomat.js
|
||||
* @description Manages client-side user UI layout preferences (attachment viewer
|
||||
* position, chat drawer width). Persisted in IndexedDB.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - UI / User layout preferences: Calls to `setViewerPosition(position)` and
|
||||
* `setChatDrawerWidth(width)`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
|
||||
/**
|
||||
* @typedef {'top'|'right'|'below'} ViewerPosition
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserSettingsState
|
||||
* @property {ViewerPosition} viewerPosition Preferred docking position of the
|
||||
* attachment viewer (text editor / PDF preview) relative to the article list.
|
||||
* @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels.
|
||||
*/
|
||||
|
||||
/** @type {UserSettingsState} */
|
||||
const initialState = {
|
||||
viewerPosition: 'top',
|
||||
chatDrawerWidth: 460,
|
||||
};
|
||||
|
||||
/**
|
||||
* Persisted per-user UI preferences. Unlike the other automats this one is
|
||||
* backed by IndexedDB (`persist: true`) so settings survive reloads and
|
||||
* sessions.
|
||||
*
|
||||
* @type {Automat<UserSettingsState>}
|
||||
*/
|
||||
const userSettingsAutomat = new Automat(
|
||||
initialState,
|
||||
{
|
||||
/**
|
||||
* Set the docking position of the attachment viewer.
|
||||
*
|
||||
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
|
||||
* @returns {void}
|
||||
*/
|
||||
setViewerPosition(position) {
|
||||
if (position !== 'top' && position !== 'right' && position !== 'below') {
|
||||
return;
|
||||
}
|
||||
userSettingsAutomat.setState({ viewerPosition: position });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the width of the chat drawer in pixels.
|
||||
*
|
||||
* @param {number} width
|
||||
* @returns {void}
|
||||
*/
|
||||
setChatDrawerWidth(width) {
|
||||
const num = Number(width);
|
||||
if (Number.isNaN(num) || num < 280) {
|
||||
return;
|
||||
}
|
||||
userSettingsAutomat.setState({ chatDrawerWidth: Math.round(num) });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userSettings',
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export default userSettingsAutomat;
|
||||
export const { setViewerPosition, setChatDrawerWidth } = userSettingsAutomat.actions;
|
||||
export const selectChatDrawerWidth = () => userSettingsAutomat.state.chatDrawerWidth || 460;
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* @file warehouseAutomat.js
|
||||
* @description Manages cached warehouse locations (`GET /api/warehouse`) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'warehouse'` SSE event.
|
||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setWarehouses()`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { warehouse as warehouseApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Warehouse
|
||||
* @property {string|number} id Warehouse identifier.
|
||||
* @property {string} name Warehouse name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WarehouseState
|
||||
* @property {Array<Warehouse>} warehouses Company-scoped warehouses.
|
||||
* @property {Array<Warehouse>} filteredWarehouses Filtered warehouses according to active filter query.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {WarehouseState} */
|
||||
const initialState = {
|
||||
warehouses: [],
|
||||
filteredWarehouses: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let warehouseAutomat;
|
||||
|
||||
const applyFilter = (warehouses, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query ? warehouses.filter((w) => String(w.name ?? '').toLowerCase().includes(query)) : warehouses;
|
||||
};
|
||||
|
||||
const updateStateWithWarehouses = (warehouses, extra = {}) => {
|
||||
const list = Array.isArray(warehouses) ? warehouses : [];
|
||||
const filteredWarehouses = applyFilter(list, warehouseAutomat.state.filter);
|
||||
warehouseAutomat.setState({ warehouses: list, filteredWarehouses, ...extra });
|
||||
return list;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the company-scoped warehouse list.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Warehouse list.
|
||||
*/
|
||||
async function fetchWarehouses() {
|
||||
const data = await warehouseApi.list();
|
||||
requireObject(data, 'GET /api/warehouse');
|
||||
return requireArray(data.warehouses, 'warehouses');
|
||||
}
|
||||
|
||||
/** In-flight `load()` promise so concurrent callers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Loads the warehouse list once, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
||||
*/
|
||||
function loadWarehouses() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
warehouseAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const warehouses = await fetchWarehouses();
|
||||
updateStateWithWarehouses(warehouses, { status: 'ready', error: null });
|
||||
return warehouses;
|
||||
} catch (error) {
|
||||
warehouseAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active warehouse search filter and updates filteredWarehouses. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
warehouseAutomat.setState({
|
||||
filter,
|
||||
filteredWarehouses: applyFilter(warehouseAutomat.state.warehouses, filter),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the warehouse list, exposing a loading/failed lifecycle.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
||||
*/
|
||||
load() {
|
||||
return loadWarehouses();
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the warehouse list while keeping the current state.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Fresh warehouses.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const warehouses = await fetchWarehouses();
|
||||
updateStateWithWarehouses(warehouses, { status: 'ready', error: null });
|
||||
return warehouses;
|
||||
} catch (error) {
|
||||
warehouseAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a warehouse and refreshes the cached list.
|
||||
*
|
||||
* @param {{name: string}} body Creation payload.
|
||||
* @returns {Promise<Warehouse>} Created warehouse.
|
||||
*/
|
||||
async create(body) {
|
||||
const data = await warehouseApi.create(body);
|
||||
requireObject(data, 'POST /api/warehouse');
|
||||
requireObject(data.warehouse, 'warehouse');
|
||||
requireDefined(data.warehouse.id, 'warehouse.id');
|
||||
await warehouseAutomat.actions.reload();
|
||||
return data.warehouse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renames a warehouse and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Warehouse identifier.
|
||||
* @param {{name: string}} body Update payload.
|
||||
* @returns {Promise<Warehouse>} Updated warehouse.
|
||||
*/
|
||||
async update(id, body) {
|
||||
const data = await warehouseApi.update(id, body);
|
||||
requireObject(data, 'PATCH /api/warehouse/:id');
|
||||
requireObject(data.warehouse, 'warehouse');
|
||||
requireDefined(data.warehouse.id, 'warehouse.id');
|
||||
await warehouseAutomat.actions.reload();
|
||||
return data.warehouse;
|
||||
},
|
||||
|
||||
rename(id, body) {
|
||||
return actions.update(id, body);
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a warehouse and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Warehouse identifier.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id) {
|
||||
await warehouseApi.delete(id);
|
||||
await warehouseAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Persists a new sort order for all warehouses.
|
||||
*
|
||||
* @param {Array<string|number>} warehouseIds IDs in the desired order.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reorder(warehouseIds) {
|
||||
const byId = new Map(warehouseAutomat.state.warehouses.map((w) => [w.id, w]));
|
||||
const reordered = warehouseIds.map((id) => byId.get(id)).filter(Boolean);
|
||||
if (reordered.length === warehouseAutomat.state.warehouses.length) {
|
||||
updateStateWithWarehouses(reordered);
|
||||
}
|
||||
await warehouseApi.order({ warehouseIds });
|
||||
await warehouseAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<Warehouse>} warehouses Warehouses to cache.
|
||||
* @returns {void}
|
||||
*/
|
||||
setWarehouses(warehouses) {
|
||||
updateStateWithWarehouses(warehouses, {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the cached list and returns to the initial state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
warehouseAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped warehouse cache. Loads automatically once an authenticated
|
||||
* session with a selected company exists and is cleared when auth is gone.
|
||||
*
|
||||
* @type {Automat<WarehouseState>}
|
||||
*/
|
||||
warehouseAutomat = new Automat(initialState, actions, { name: 'warehouse' });
|
||||
|
||||
/**
|
||||
* Derives the cache key from the auth state: the selected company while
|
||||
* authenticated, otherwise `null`.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {string|null} Cache key.
|
||||
*/
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
|
||||
/**
|
||||
* Loads the warehouse list when auth/company becomes available and forgets it
|
||||
* when the session ends.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
warehouseAutomat.actions.clear();
|
||||
} else {
|
||||
void warehouseAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
|
||||
export default warehouseAutomat;
|
||||
Reference in New Issue
Block a user