automat
This commit is contained in:
@@ -90,6 +90,7 @@ export class Automat<T = any, A = Record<string, Function>> {
|
|||||||
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
|
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
|
||||||
|
|
||||||
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
|
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
|
||||||
|
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetchers & SSE
|
// Fetchers & SSE
|
||||||
@@ -168,6 +169,30 @@ const live = new Automat({}, {
|
|||||||
connectSSE(live, '/api/stream');
|
connectSSE(live, '/api/stream');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### G. Combine Automats
|
||||||
|
```js
|
||||||
|
const app = Automat.combine({
|
||||||
|
auth: authAutomat,
|
||||||
|
company: companyAutomat,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.state.auth; // authAutomat.state
|
||||||
|
app.state.company; // companyAutomat.state
|
||||||
|
app.actions.auth.login(); // authAutomat.actions.login()
|
||||||
|
app.actions.company.rename('Acme');
|
||||||
|
|
||||||
|
// Notified when either child changes; selectors can isolate a branch.
|
||||||
|
const unsubscribe = app.subscribe(this, (state) => ({
|
||||||
|
user: state.auth.user,
|
||||||
|
companyName: state.company.name,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
The aggregate is derived: child updates automatically update the matching key. Its
|
||||||
|
`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover
|
||||||
|
all children. Calling `app.dispose()` only removes aggregate subscriptions; it does
|
||||||
|
not dispose the child automats.
|
||||||
|
|
||||||
## 3. Decision Matrix: `setDirty()` vs `reload()`
|
## 3. Decision Matrix: `setDirty()` vs `reload()`
|
||||||
|
|
||||||
| Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case |
|
| Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case |
|
||||||
|
|||||||
31
src/App.jsx
31
src/App.jsx
@@ -1,9 +1,8 @@
|
|||||||
import { Component, Suspense } from 'react';
|
import { PureComponent, Suspense } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { selectAuthStatus, selectNeedsCompanySetup } from './store/authSlice';
|
import authAutomat from './automats/authAutomat';
|
||||||
import LoginPage from './components/LoginPage';
|
import LoginPage from './components/LoginPage';
|
||||||
import CompanySetupDialog from './components/CompanySetupDialog';
|
import CompanySetupDialog from './components/CompanySetupDialog';
|
||||||
import { router } from './appRouter';
|
import { router } from './appRouter';
|
||||||
@@ -24,9 +23,22 @@ function PageFallback() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class App extends Component {
|
class App extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = authAutomat.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { status, needsCompanySetup } = this.props;
|
const { status, user } = this.state;
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +60,7 @@ class App extends Component {
|
|||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsCompanySetup) {
|
if (user?.selectedCompanyId == null) {
|
||||||
return <CompanySetupDialog />;
|
return <CompanySetupDialog />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +72,4 @@ class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state) => ({
|
export default App;
|
||||||
status: selectAuthStatus(state),
|
|
||||||
needsCompanySetup: selectNeedsCompanySetup(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(App);
|
|
||||||
|
|||||||
180
src/automats/authAutomat.js
Normal file
180
src/automats/authAutomat.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { auth, company } from '../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AuthUser
|
||||||
|
* @property {string|number|null} [selectedCompanyId] Currently selected company.
|
||||||
|
* @property {*} [key] Additional user properties returned by the API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AuthState
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Emitted after an authenticated session has been established. */
|
||||||
|
export const AUTH_SESSION_ESTABLISHED = 'auth/sessionEstablished';
|
||||||
|
/** Emitted after the active company has changed. */
|
||||||
|
export const AUTH_COMPANY_CHANGED = 'auth/companyChanged';
|
||||||
|
/** Emitted after the current user has logged out. */
|
||||||
|
export const AUTH_LOGGED_OUT = 'auth/loggedOut';
|
||||||
|
|
||||||
|
/** @type {AuthState} */
|
||||||
|
const initialState = {
|
||||||
|
user: null,
|
||||||
|
googleClientId: null,
|
||||||
|
status: 'loading',
|
||||||
|
};
|
||||||
|
|
||||||
|
let emit = () => { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the application-level dispatcher used for authentication events.
|
||||||
|
*
|
||||||
|
* @param {(event: {type: string}) => void} dispatch Event dispatcher.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function setAuthEventDispatcher(dispatch) {
|
||||||
|
emit = dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges API user data while retaining the current company selection when the
|
||||||
|
* response does not contain one.
|
||||||
|
*
|
||||||
|
* @param {AuthUser|null} current Current user.
|
||||||
|
* @param {AuthUser|null|undefined} incoming User data returned by the API.
|
||||||
|
* @returns {AuthUser|null} Merged user.
|
||||||
|
*/
|
||||||
|
function mergeUser(current, incoming) {
|
||||||
|
if (!incoming) return current;
|
||||||
|
const selectedCompanyId =
|
||||||
|
incoming.selectedCompanyId ?? current?.selectedCompanyId ?? null;
|
||||||
|
return { ...current, ...incoming, selectedCompanyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
let authAutomat;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/**
|
||||||
|
* Restores an existing session or requests it from the authentication API.
|
||||||
|
*
|
||||||
|
* @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
|
||||||
|
* Optional preloaded session.
|
||||||
|
* @returns {Promise<Object>} Resolved session response.
|
||||||
|
*/
|
||||||
|
async bootstrapSession(session) {
|
||||||
|
try {
|
||||||
|
const nextSession = session ?? await auth.me();
|
||||||
|
const googleClientId =
|
||||||
|
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
||||||
|
|
||||||
|
if (!nextSession.user) {
|
||||||
|
if (authAutomat.state.status !== 'authed') {
|
||||||
|
authAutomat.setState({ user: null, googleClientId, status: 'anon' });
|
||||||
|
} else {
|
||||||
|
authAutomat.setState({ googleClientId });
|
||||||
|
}
|
||||||
|
return nextSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
||||||
|
authAutomat.setState({ user, googleClientId, status: 'authed' });
|
||||||
|
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||||
|
return nextSession;
|
||||||
|
} catch (error) {
|
||||||
|
if (authAutomat.state.status === 'loading') {
|
||||||
|
authAutomat.setState({ user: null, status: 'anon' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges a Google credential for an authenticated application session.
|
||||||
|
*
|
||||||
|
* @param {string} credential Google Identity Services credential.
|
||||||
|
* @returns {Promise<Object>} Authenticated session.
|
||||||
|
*/
|
||||||
|
async loginWithGoogle(credential) {
|
||||||
|
try {
|
||||||
|
const { session } = await auth.loginWithGoogle(credential);
|
||||||
|
if (!session?.user) {
|
||||||
|
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
|
||||||
|
}
|
||||||
|
authAutomat.setState({
|
||||||
|
user: session.user,
|
||||||
|
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||||
|
status: 'authed',
|
||||||
|
});
|
||||||
|
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
authAutomat.setState({ user: null, status: 'anon' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the company used by the current session.
|
||||||
|
*
|
||||||
|
* @param {string|number} companyId Company identifier.
|
||||||
|
* @returns {Promise<AuthUser>} Updated user.
|
||||||
|
*/
|
||||||
|
async selectCompany(companyId) {
|
||||||
|
const data = await company.select(companyId);
|
||||||
|
const user = mergeUser(
|
||||||
|
authAutomat.state.user,
|
||||||
|
data.user ?? { selectedCompanyId: companyId },
|
||||||
|
);
|
||||||
|
authAutomat.setState({ user, status: 'authed' });
|
||||||
|
emit({ type: AUTH_COMPANY_CHANGED });
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a company and selects it for the current user.
|
||||||
|
*
|
||||||
|
* @param {Object} payload Company creation payload.
|
||||||
|
* @returns {Promise<AuthUser>} Updated user.
|
||||||
|
*/
|
||||||
|
async createCompany(payload) {
|
||||||
|
const data = await company.create(payload);
|
||||||
|
let incoming = data.user;
|
||||||
|
if (!incoming && data.company?.id) {
|
||||||
|
const selected = await company.select(data.company.id);
|
||||||
|
incoming = selected.user ?? { selectedCompanyId: data.company.id };
|
||||||
|
}
|
||||||
|
if (!incoming) {
|
||||||
|
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = mergeUser(authAutomat.state.user, incoming);
|
||||||
|
authAutomat.setState({ user, status: 'authed' });
|
||||||
|
emit({ type: AUTH_COMPANY_CHANGED });
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the server session, clears the current user, and returns to `/`.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async logout() {
|
||||||
|
await auth.logout();
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
authAutomat.setState({ user: null, status: 'anon' });
|
||||||
|
emit({ type: AUTH_LOGGED_OUT });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global authentication state container.
|
||||||
|
*
|
||||||
|
* @type {Automat<AuthState>}
|
||||||
|
*/
|
||||||
|
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||||
|
|
||||||
|
export default authAutomat;
|
||||||
@@ -1,6 +1,58 @@
|
|||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import { supplier as supplierApi } from '../api';
|
import { supplier as supplierApi } from '../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DeliveryLine
|
||||||
|
* @property {string|number} key Stable editor key.
|
||||||
|
* @property {string|null} name Article name.
|
||||||
|
* @property {string|number|null} sku Supplier or internal article SKU.
|
||||||
|
* @property {number|null} amount Ordered amount.
|
||||||
|
* @property {number|null} pu Price unit.
|
||||||
|
* @property {string|null} unit Unit of measure.
|
||||||
|
* @property {number|null} netprice Net price.
|
||||||
|
* @property {number|null} taxrate Tax rate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SupplierArticle
|
||||||
|
* @property {string|number} [id] Supplier-article identifier.
|
||||||
|
* @property {string|number|null} [sku] Supplier SKU.
|
||||||
|
* @property {{sku?: string|number|null, eans?: Array<string|number>, articleean?: Array<string|number>}} [article]
|
||||||
|
* Linked internal article.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DeliveryEditorState
|
||||||
|
* @property {Array<Object>} deliveryEntities Available suppliers or delivery parties.
|
||||||
|
* @property {string|number|null} id Delivery identifier.
|
||||||
|
* @property {string|number|null} deliveryId Alias of `id`.
|
||||||
|
* @property {string|null} date Delivery date.
|
||||||
|
* @property {string|number|null} deliveryEntity Selected delivery party.
|
||||||
|
* @property {string|number|null} partyId Alias of `deliveryEntity`.
|
||||||
|
* @property {string|null} externalNumber External delivery number.
|
||||||
|
* @property {string|null} external_number API-compatible alias of `externalNumber`.
|
||||||
|
* @property {string|null} number Internal delivery number.
|
||||||
|
* @property {string|null} type Delivery type.
|
||||||
|
* @property {Array<DeliveryLine>} lines Editable lines.
|
||||||
|
* @property {Array<DeliveryLine>} lineitems Alias of `lines`.
|
||||||
|
* @property {Array<*>} notes Delivery notes.
|
||||||
|
* @property {Array<Object>} attachments Delivery attachments.
|
||||||
|
* @property {Array<Object>} files Alias of `attachments`.
|
||||||
|
* @property {Array<SupplierArticle>} supplierArticles Loaded supplier articles.
|
||||||
|
* @property {Object<string, SupplierArticle>} supplierArticlesMap Articles indexed by SKU or EAN.
|
||||||
|
* @property {Object<string, SupplierArticle|null>} supplierArticlesByLineKey Matched article per line.
|
||||||
|
* @property {boolean} supplierArticlesLoading Whether any supplier article request is pending.
|
||||||
|
* @property {Object<string, boolean>} loadingLineKeys Pending lookup state per line.
|
||||||
|
* @property {string|null} supplierArticlesError Last supplier article error.
|
||||||
|
* @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a partial line into an editor line with a stable key.
|
||||||
|
*
|
||||||
|
* @param {Partial<DeliveryLine>} [line] Initial line values.
|
||||||
|
* @returns {DeliveryLine} Normalized line.
|
||||||
|
*/
|
||||||
export function createEmptyLineitem(line = {}) {
|
export function createEmptyLineitem(line = {}) {
|
||||||
return {
|
return {
|
||||||
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
@@ -14,6 +66,14 @@ export function createEmptyLineitem(line = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indexes supplier articles by supplier SKU, internal SKU, and EAN. Text keys
|
||||||
|
* are stored both verbatim and lower-cased for case-insensitive lookup.
|
||||||
|
*
|
||||||
|
* @param {Array<SupplierArticle>} [articles] Articles to index.
|
||||||
|
* @param {Object<string, SupplierArticle>} [existingMap] Existing index to extend.
|
||||||
|
* @returns {Object<string, SupplierArticle>} Article lookup map.
|
||||||
|
*/
|
||||||
export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
|
export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
|
||||||
const map = { ...existingMap };
|
const map = { ...existingMap };
|
||||||
for (const item of articles) {
|
for (const item of articles) {
|
||||||
@@ -49,6 +109,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {Omit<DeliveryEditorState, 'deliveryEntities'>} */
|
||||||
export const initialDelivery = {
|
export const initialDelivery = {
|
||||||
id: null,
|
id: null,
|
||||||
deliveryId: null,
|
deliveryId: null,
|
||||||
@@ -74,11 +135,19 @@ export const initialDelivery = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/** @type {DeliveryEditorState} */
|
||||||
export const initialState = {
|
export const initialState = {
|
||||||
deliveryEntities: [],
|
deliveryEntities: [],
|
||||||
...initialDelivery,
|
...initialDelivery,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a delivery editor automat or returns the already registered named
|
||||||
|
* instance. Named instances allow independent state for multiple deliveries.
|
||||||
|
*
|
||||||
|
* @param {{name?: string|null}|string} [options] Automat options or name.
|
||||||
|
* @returns {Automat<DeliveryEditorState>} Delivery editor automat.
|
||||||
|
*/
|
||||||
export function createDeliveryEditorAutomat(options = {}) {
|
export function createDeliveryEditorAutomat(options = {}) {
|
||||||
const name = typeof options === 'string' ? options : options?.name ?? null;
|
const name = typeof options === 'string' ? options : options?.name ?? null;
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -88,14 +157,21 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
|
|
||||||
let automat;
|
let automat;
|
||||||
const actions = {
|
const actions = {
|
||||||
|
/** @param {string|number|null} id @returns {void} */
|
||||||
setId(id) {
|
setId(id) {
|
||||||
automat.setState({ id, deliveryId: id });
|
automat.setState({ id, deliveryId: id });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Array<Object>} deliveryEntities @returns {void} */
|
||||||
setDeliveryEntities(deliveryEntities) {
|
setDeliveryEntities(deliveryEntities) {
|
||||||
automat.setState({ deliveryEntities: deliveryEntities ?? [] });
|
automat.setState({ deliveryEntities: deliveryEntities ?? [] });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the edited delivery and normalizes API aliases.
|
||||||
|
* @param {Partial<DeliveryEditorState>} [delivery]
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
setDelivery(delivery = {}) {
|
setDelivery(delivery = {}) {
|
||||||
const rawLines = delivery.lines ?? delivery.lineitems ?? [];
|
const rawLines = delivery.lines ?? delivery.lineitems ?? [];
|
||||||
const lines = Array.isArray(rawLines) ? rawLines : [];
|
const lines = Array.isArray(rawLines) ? rawLines : [];
|
||||||
@@ -129,10 +205,16 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {string|null} date @returns {void} */
|
||||||
setDate(date) {
|
setDate(date) {
|
||||||
automat.setState({ date });
|
automat.setState({ date });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a delivery party and refreshes its supplier articles.
|
||||||
|
* @param {string|number|null} entityId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
setDeliveryEntity(entityId) {
|
setDeliveryEntity(entityId) {
|
||||||
const prevId = automat.state.deliveryEntity ?? automat.state.partyId;
|
const prevId = automat.state.deliveryEntity ?? automat.state.partyId;
|
||||||
const idChanged = prevId !== entityId;
|
const idChanged = prevId !== entityId;
|
||||||
@@ -155,15 +237,18 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {string|null} name @returns {void} */
|
||||||
setUnmatchedSupplierName(name) {
|
setUnmatchedSupplierName(name) {
|
||||||
automat.setState({ unmatchedSupplierName: name ?? null });
|
automat.setState({ unmatchedSupplierName: name ?? null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/** @param {string|number|null} partyId @returns {void} */
|
||||||
setPartyId(partyId) {
|
setPartyId(partyId) {
|
||||||
automat.actions.setDeliveryEntity(partyId);
|
automat.actions.setDeliveryEntity(partyId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {string|null} externalNumber @returns {void} */
|
||||||
setExternalNumber(externalNumber) {
|
setExternalNumber(externalNumber) {
|
||||||
automat.setState({
|
automat.setState({
|
||||||
externalNumber,
|
externalNumber,
|
||||||
@@ -171,24 +256,29 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {string|null} number @returns {void} */
|
||||||
setNumber(number) {
|
setNumber(number) {
|
||||||
automat.setState({ number });
|
automat.setState({ number });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {string|null} type @returns {void} */
|
||||||
setType(type) {
|
setType(type) {
|
||||||
automat.setState({ type });
|
automat.setState({ type });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Array<DeliveryLine>} lines @returns {void} */
|
||||||
setLines(lines) {
|
setLines(lines) {
|
||||||
const cleanLines = lines ?? [];
|
const cleanLines = lines ?? [];
|
||||||
automat.setState({ lines: cleanLines, lineitems: cleanLines });
|
automat.setState({ lines: cleanLines, lineitems: cleanLines });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Array<DeliveryLine>} lines @returns {void} */
|
||||||
setLineitems(lines) {
|
setLineitems(lines) {
|
||||||
const cleanLines = lines ?? [];
|
const cleanLines = lines ?? [];
|
||||||
automat.setState({ lines: cleanLines, lineitems: cleanLines });
|
automat.setState({ lines: cleanLines, lineitems: cleanLines });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Array<*>|*} notes @returns {void} */
|
||||||
setNotes(notes) {
|
setNotes(notes) {
|
||||||
const cleanNotes = Array.isArray(notes)
|
const cleanNotes = Array.isArray(notes)
|
||||||
? notes
|
? notes
|
||||||
@@ -196,16 +286,23 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
automat.setState({ notes: cleanNotes });
|
automat.setState({ notes: cleanNotes });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
|
||||||
addLineitem(line = {}) {
|
addLineitem(line = {}) {
|
||||||
const item = createEmptyLineitem(line);
|
const item = createEmptyLineitem(line);
|
||||||
const nextLines = [...(automat.state.lines ?? []), item];
|
const nextLines = [...(automat.state.lines ?? []), item];
|
||||||
automat.setState({ lines: nextLines, lineitems: nextLines });
|
automat.setState({ lines: nextLines, lineitems: nextLines });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Partial<DeliveryLine>} [line] @returns {void} */
|
||||||
addLine(line = {}) {
|
addLine(line = {}) {
|
||||||
automat.actions.addLineitem(line);
|
automat.actions.addLineitem(line);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a line selected by index or key.
|
||||||
|
* @param {{index?: number, key?: string|number, [key: string]: *}} payload
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
updateLineitem(payload) {
|
updateLineitem(payload) {
|
||||||
const { index, key, ...fields } = payload || {};
|
const { index, key, ...fields } = payload || {};
|
||||||
const currentLines = automat.state.lines ?? [];
|
const currentLines = automat.state.lines ?? [];
|
||||||
@@ -223,10 +320,12 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {{index?: number, key?: string|number, [key: string]: *}} payload @returns {void} */
|
||||||
updateLine(payload) {
|
updateLine(payload) {
|
||||||
automat.actions.updateLineitem(payload);
|
automat.actions.updateLineitem(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {number|string|{key: string|number}} payload @returns {void} */
|
||||||
removeLineitem(payload) {
|
removeLineitem(payload) {
|
||||||
const currentLines = automat.state.lines ?? [];
|
const currentLines = automat.state.lines ?? [];
|
||||||
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
|
let index = typeof payload === 'number' && !Number.isNaN(payload) ? payload : -1;
|
||||||
@@ -251,10 +350,17 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {number|string|{key: string|number}} payload @returns {void} */
|
||||||
removeLine(payload) {
|
removeLine(payload) {
|
||||||
automat.actions.removeLineitem(payload);
|
automat.actions.removeLineitem(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads supplier articles for all current SKUs and rebuilds line matches.
|
||||||
|
* @param {string|number|null} [targetEntityId] Supplier to query.
|
||||||
|
* @param {Array<DeliveryLine>} [targetLines] Lines whose SKUs should be queried.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async fetchSupplierArticles(targetEntityId, targetLines) {
|
async fetchSupplierArticles(targetEntityId, targetLines) {
|
||||||
const entityId = targetEntityId ?? automat.state.deliveryEntity ?? automat.state.partyId;
|
const entityId = targetEntityId ?? automat.state.deliveryEntity ?? automat.state.partyId;
|
||||||
if (!entityId) {
|
if (!entityId) {
|
||||||
@@ -311,6 +417,12 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and matches a supplier article for one line SKU.
|
||||||
|
* @param {string|number|null} sku SKU to query.
|
||||||
|
* @param {string|number} [lineKey] Editor line key receiving the match.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async fetchSupplierArticleForSku(sku, lineKey) {
|
async fetchSupplierArticleForSku(sku, lineKey) {
|
||||||
const entityId = automat.state.deliveryEntity ?? automat.state.partyId;
|
const entityId = automat.state.deliveryEntity ?? automat.state.partyId;
|
||||||
const trimmed = sku != null ? String(sku).trim() : '';
|
const trimmed = sku != null ? String(sku).trim() : '';
|
||||||
@@ -370,6 +482,7 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Object} attachment @returns {void} */
|
||||||
addAttachment(attachment) {
|
addAttachment(attachment) {
|
||||||
const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const id = attachment.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const item = { ...attachment, id };
|
const item = { ...attachment, id };
|
||||||
@@ -377,11 +490,13 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
automat.setState({ attachments: next, files: next });
|
automat.setState({ attachments: next, files: next });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {Array<Object>} attachments @returns {void} */
|
||||||
setAttachments(attachments) {
|
setAttachments(attachments) {
|
||||||
const clean = Array.isArray(attachments) ? attachments : [];
|
const clean = Array.isArray(attachments) ? attachments : [];
|
||||||
automat.setState({ attachments: clean, files: clean });
|
automat.setState({ attachments: clean, files: clean });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {{id: string|number, [key: string]: *}} payload @returns {void} */
|
||||||
updateAttachment(payload) {
|
updateAttachment(payload) {
|
||||||
const { id, ...fields } = payload || {};
|
const { id, ...fields } = payload || {};
|
||||||
const current = automat.state.attachments ?? [];
|
const current = automat.state.attachments ?? [];
|
||||||
@@ -393,6 +508,7 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** @param {string|number|{id?: string|number, fileId?: string|number}} idOrPayload @returns {void} */
|
||||||
removeAttachment(idOrPayload) {
|
removeAttachment(idOrPayload) {
|
||||||
const current = automat.state.attachments ?? [];
|
const current = automat.state.attachments ?? [];
|
||||||
const targetId =
|
const targetId =
|
||||||
@@ -411,12 +527,14 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
automat.setState({ attachments: next, files: next });
|
automat.setState({ attachments: next, files: next });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Resets delivery fields while retaining the available entities. @returns {void} */
|
||||||
resetDelivery() {
|
resetDelivery() {
|
||||||
automat.setState({
|
automat.setState({
|
||||||
...initialDelivery,
|
...initialDelivery,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Resets the complete editor state. @returns {void} */
|
||||||
resetDeliveryEditor() {
|
resetDeliveryEditor() {
|
||||||
automat.setState({
|
automat.setState({
|
||||||
...initialState,
|
...initialState,
|
||||||
@@ -432,9 +550,17 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
return automat;
|
return automat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared automat used for new, unsaved deliveries. */
|
||||||
export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' });
|
export const deliveryDraftAutomat = createDeliveryEditorAutomat({ name: 'delivery-draft' });
|
||||||
|
/** Backward-compatible alias of {@link deliveryDraftAutomat}. */
|
||||||
export const deliveryEditorAutomat = deliveryDraftAutomat;
|
export const deliveryEditorAutomat = deliveryDraftAutomat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the draft automat or the named automat belonging to a saved delivery.
|
||||||
|
*
|
||||||
|
* @param {string|number|null} docId Delivery identifier; `null` selects the draft.
|
||||||
|
* @returns {Automat<DeliveryEditorState>} Delivery-specific editor automat.
|
||||||
|
*/
|
||||||
export function getDeliveryEditorAutomat(docId) {
|
export function getDeliveryEditorAutomat(docId) {
|
||||||
if (docId == null) {
|
if (docId == null) {
|
||||||
return deliveryDraftAutomat;
|
return deliveryDraftAutomat;
|
||||||
@@ -478,22 +604,38 @@ export const {
|
|||||||
} = deliveryEditorAutomat.actions;
|
} = deliveryEditorAutomat.actions;
|
||||||
|
|
||||||
// Selectors
|
// Selectors
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
|
||||||
export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id;
|
export const selectDeliveryId = (state = deliveryEditorAutomat.state) => state.deliveryId ?? state.id;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
|
||||||
export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities;
|
export const selectDeliveryEntities = (state = deliveryEditorAutomat.state) => state.deliveryEntities;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||||
export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date;
|
export const selectDeliveryDate = (state = deliveryEditorAutomat.state) => state.date;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {string|number|null} */
|
||||||
export const selectDeliveryEntity = (state = deliveryEditorAutomat.state) => state.deliveryEntity ?? state.partyId;
|
export const selectDeliveryEntity = (state = deliveryEditorAutomat.state) => state.deliveryEntity ?? state.partyId;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||||
export const selectUnmatchedSupplierName = (state = deliveryEditorAutomat.state) => state.unmatchedSupplierName;
|
export const selectUnmatchedSupplierName = (state = deliveryEditorAutomat.state) => state.unmatchedSupplierName;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||||
export const selectDeliveryExternalNumber = (state = deliveryEditorAutomat.state) => state.externalNumber ?? state.external_number;
|
export const selectDeliveryExternalNumber = (state = deliveryEditorAutomat.state) => state.externalNumber ?? state.external_number;
|
||||||
|
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||||
export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number;
|
export const selectDeliveryNumber = (state = deliveryEditorAutomat.state) => state.number;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {string|null} */
|
||||||
export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type;
|
export const selectDeliveryType = (state = deliveryEditorAutomat.state) => state.type;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Array<DeliveryLine>} */
|
||||||
export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems;
|
export const selectDeliveryLineitems = (state = deliveryEditorAutomat.state) => state.lines ?? state.lineitems;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Array<*>} */
|
||||||
export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes;
|
export const selectDeliveryNotes = (state = deliveryEditorAutomat.state) => state.notes;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Array<Object>} */
|
||||||
export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments;
|
export const selectDeliveryAttachments = (state = deliveryEditorAutomat.state) => state.attachments;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Array<SupplierArticle>} */
|
||||||
export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles;
|
export const selectSupplierArticles = (state = deliveryEditorAutomat.state) => state.supplierArticles;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle>} */
|
||||||
export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap;
|
export const selectSupplierArticlesMap = (state = deliveryEditorAutomat.state) => state.supplierArticlesMap;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Object<string, SupplierArticle|null>} */
|
||||||
export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey;
|
export const selectSupplierArticlesByLineKey = (state = deliveryEditorAutomat.state) => state.supplierArticlesByLineKey;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {boolean} */
|
||||||
export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading;
|
export const selectSupplierArticlesLoading = (state = deliveryEditorAutomat.state) => state.supplierArticlesLoading;
|
||||||
|
/** @param {DeliveryEditorState} [state] @returns {Object<string, boolean>} */
|
||||||
export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys;
|
export const selectLoadingLineKeys = (state = deliveryEditorAutomat.state) => state.loadingLineKeys;
|
||||||
|
|
||||||
export default deliveryEditorAutomat;
|
export default deliveryEditorAutomat;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './deliveryEditorAutomat';
|
export * from './deliveryEditorAutomat';
|
||||||
|
export { default as authAutomat } from './authAutomat';
|
||||||
|
export * from './authAutomat';
|
||||||
export { default as navigationAutomat } from './navigationAutomat';
|
export { default as navigationAutomat } from './navigationAutomat';
|
||||||
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
|
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation automat to store last visited delivery and article paths.
|
* @typedef {Object} NavigationState
|
||||||
* Used to return users to their previous context when clicking main menu items.
|
* @property {string|null} lastDeliveryPath Last delivery path visited by the user.
|
||||||
*
|
* @property {string|null} lastArticleSku Last article SKU visited by the user.
|
||||||
* State shape:
|
|
||||||
* {
|
|
||||||
* lastDeliveryPath: string | null, // Last visited delivery path (e.g., '/lieferung/19' or '/lieferung/neu')
|
|
||||||
* lastArticleSku: string | null, // Last visited article SKU
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** @type {NavigationState} */
|
||||||
const initialState = {
|
const initialState = {
|
||||||
lastDeliveryPath: null,
|
lastDeliveryPath: null,
|
||||||
lastArticleSku: null,
|
lastArticleSku: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the user's most recently visited delivery and article so main-menu
|
||||||
|
* navigation can restore the previous context.
|
||||||
|
*
|
||||||
|
* @type {Automat<NavigationState>}
|
||||||
|
*/
|
||||||
const navigationAutomat = new Automat(
|
const navigationAutomat = new Automat(
|
||||||
initialState,
|
initialState,
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Set the last visited delivery path.
|
* Set the last visited delivery path.
|
||||||
* @param {string|null} path - Full path like '/lieferung/19' or '/lieferung/neu'
|
*
|
||||||
|
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
setLastDeliveryPath(path) {
|
setLastDeliveryPath(path) {
|
||||||
navigationAutomat.setState({ lastDeliveryPath: path });
|
navigationAutomat.setState({ lastDeliveryPath: path });
|
||||||
@@ -28,7 +33,9 @@ const navigationAutomat = new Automat(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the last visited article SKU.
|
* Set the last visited article SKU.
|
||||||
|
*
|
||||||
* @param {string|null} sku
|
* @param {string|null} sku
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
setLastArticleSku(sku) {
|
setLastArticleSku(sku) {
|
||||||
navigationAutomat.setState({ lastArticleSku: sku });
|
navigationAutomat.setState({ lastArticleSku: sku });
|
||||||
@@ -36,6 +43,8 @@ const navigationAutomat = new Automat(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all navigation history.
|
* Clear all navigation history.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
clearNavigation() {
|
clearNavigation() {
|
||||||
navigationAutomat.setState({
|
navigationAutomat.setState({
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||||
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
|
import authAutomat from '../automats/authAutomat';
|
||||||
|
|
||||||
let sessionBootstrapped = false;
|
let sessionBootstrapped = false;
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ class AuthBootstrap extends Component {
|
|||||||
if (session.forbidden) {
|
if (session.forbidden) {
|
||||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||||
}
|
}
|
||||||
return this.props.bootstrapSession(session).unwrap();
|
return authAutomat.actions.bootstrapSession(session);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -31,8 +30,4 @@ class AuthBootstrap extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
export default AuthBootstrap;
|
||||||
bootstrapSession: (session) => dispatch(bootstrapSessionThunk(session)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(AuthBootstrap);
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
@@ -23,11 +22,7 @@ import BusinessIcon from '@mui/icons-material/Business';
|
|||||||
import BackendError from './BackendError';
|
import BackendError from './BackendError';
|
||||||
import { company } from '../api';
|
import { company } from '../api';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||||
import {
|
import authAutomat from '../automats/authAutomat';
|
||||||
createCompany as createCompanyThunk,
|
|
||||||
logout as logoutThunk,
|
|
||||||
selectCompany as selectCompanyThunk,
|
|
||||||
} from '../store/authSlice';
|
|
||||||
import {
|
import {
|
||||||
defaultIso639,
|
defaultIso639,
|
||||||
ISO639_OPTIONS,
|
ISO639_OPTIONS,
|
||||||
@@ -75,7 +70,7 @@ class CompanySetupDialog extends Component {
|
|||||||
async handleSelect(companyId) {
|
async handleSelect(companyId) {
|
||||||
this.setState({ busy: true, error: null });
|
this.setState({ busy: true, error: null });
|
||||||
try {
|
try {
|
||||||
await this.props.selectCompany(companyId).unwrap();
|
await authAutomat.actions.selectCompany(companyId);
|
||||||
await this.context.updateAuthMe();
|
await this.context.updateAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
@@ -102,14 +97,12 @@ class CompanySetupDialog extends Component {
|
|||||||
|
|
||||||
this.setState({ busy: true, error: null });
|
this.setState({ busy: true, error: null });
|
||||||
try {
|
try {
|
||||||
await this.props
|
await authAutomat.actions.createCompany({
|
||||||
.createCompany({
|
name: name.trim(),
|
||||||
name: name.trim(),
|
iso639,
|
||||||
iso639,
|
taxrate: parsedTaxrate,
|
||||||
taxrate: parsedTaxrate,
|
reducedtaxrate: parsedReducedtaxrate,
|
||||||
reducedtaxrate: parsedReducedtaxrate,
|
});
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
await this.context.updateAuthMe();
|
await this.context.updateAuthMe();
|
||||||
await this.context.updateCompany();
|
await this.context.updateCompany();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -121,7 +114,7 @@ class CompanySetupDialog extends Component {
|
|||||||
|
|
||||||
async handleLogout() {
|
async handleLogout() {
|
||||||
try {
|
try {
|
||||||
await this.props.logout().unwrap();
|
await authAutomat.actions.logout();
|
||||||
await this.context.updateAuthMe();
|
await this.context.updateAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
@@ -293,10 +286,4 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
export default CompanySetupDialog;
|
||||||
createCompany: (payload) => dispatch(createCompanyThunk(payload)),
|
|
||||||
selectCompany: (companyId) => dispatch(selectCompanyThunk(companyId)),
|
|
||||||
logout: () => dispatch(logoutThunk()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(CompanySetupDialog);
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Component } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import GoogleIcon from '@mui/icons-material/Google';
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyErrorMessage } from '../utils/notify';
|
import { notifyErrorMessage } from '../utils/notify';
|
||||||
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
|
import authAutomat from '../automats/authAutomat';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||||
import {
|
import {
|
||||||
abortGoogleSignIn,
|
abortGoogleSignIn,
|
||||||
@@ -16,17 +15,25 @@ import {
|
|||||||
requestGoogleIdToken,
|
requestGoogleIdToken,
|
||||||
} from '../auth/googleSignIn';
|
} from '../auth/googleSignIn';
|
||||||
|
|
||||||
class LoginPage extends Component {
|
class LoginPage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { busy: false };
|
this.state = { ...authAutomat.state, busy: false };
|
||||||
this.handleLogin = this.handleLogin.bind(this);
|
this.handleLogin = this.handleLogin.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubscribe = authAutomat.subscribe(this, ['status', 'googleClientId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
}
|
||||||
|
|
||||||
async handleLogin() {
|
async handleLogin() {
|
||||||
const { googleClientId } = this.props;
|
const { googleClientId } = this.state;
|
||||||
abortGoogleSignIn();
|
abortGoogleSignIn();
|
||||||
try {
|
try {
|
||||||
const idToken = await requestGoogleIdToken(googleClientId, {
|
const idToken = await requestGoogleIdToken(googleClientId, {
|
||||||
@@ -38,7 +45,7 @@ class LoginPage extends Component {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
await this.props.loginWithGoogle(idToken).unwrap();
|
await authAutomat.actions.loginWithGoogle(idToken);
|
||||||
await this.context.updateAuthMe();
|
await this.context.updateAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GoogleSignInCancelledError) return;
|
if (err instanceof GoogleSignInCancelledError) return;
|
||||||
@@ -74,8 +81,7 @@ class LoginPage extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { busy } = this.state;
|
const { busy, status, googleClientId } = this.state;
|
||||||
const { status, googleClientId } = this.props;
|
|
||||||
const loading = status === 'loading';
|
const loading = status === 'loading';
|
||||||
const ready = Boolean(googleClientId);
|
const ready = Boolean(googleClientId);
|
||||||
|
|
||||||
@@ -103,13 +109,4 @@ class LoginPage extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state) => ({
|
export default LoginPage;
|
||||||
status: selectAuthStatus(state),
|
|
||||||
googleClientId: selectGoogleClientId(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
loginWithGoogle: (credential) => dispatch(loginWithGoogle(credential)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(LoginPage);
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import Avatar from '@mui/material/Avatar';
|
import Avatar from '@mui/material/Avatar';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
@@ -11,12 +10,12 @@ import LogoutIcon from '@mui/icons-material/Logout';
|
|||||||
import BusinessIcon from '@mui/icons-material/Business';
|
import BusinessIcon from '@mui/icons-material/Business';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||||
import { logout } from '../store/authSlice';
|
import authAutomat from '../automats/authAutomat';
|
||||||
import CompanySettingsDialog from './CompanySettingsDialog';
|
import CompanySettingsDialog from './CompanySettingsDialog';
|
||||||
import SettingsDialog from './settings/SettingsDialog';
|
import SettingsDialog from './settings/SettingsDialog';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
|
|
||||||
class UserMenu extends Component {
|
class UserMenu extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -25,7 +24,7 @@ class UserMenu extends Component {
|
|||||||
anchorEl: null,
|
anchorEl: null,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
numbersOpen: false,
|
numbersOpen: false,
|
||||||
user: null,
|
user: authAutomat.state.user,
|
||||||
};
|
};
|
||||||
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
||||||
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
||||||
@@ -37,16 +36,11 @@ class UserMenu extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
void this.loadUser();
|
this.unsubscribe = authAutomat.subscribe(this, 'user');
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUser() {
|
componentWillUnmount() {
|
||||||
try {
|
this.unsubscribe?.();
|
||||||
const session = await this.context.authMePromise;
|
|
||||||
this.setState({ user: session.user });
|
|
||||||
} catch (err) {
|
|
||||||
notifyError(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenMenu(event) {
|
handleOpenMenu(event) {
|
||||||
@@ -76,7 +70,7 @@ class UserMenu extends Component {
|
|||||||
async handleLogout() {
|
async handleLogout() {
|
||||||
this.setState({ anchorEl: null });
|
this.setState({ anchorEl: null });
|
||||||
try {
|
try {
|
||||||
await this.props.logout().unwrap();
|
await authAutomat.actions.logout();
|
||||||
await this.context.updateAuthMe();
|
await this.context.updateAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -134,8 +128,4 @@ class UserMenu extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
export default UserMenu;
|
||||||
logout: () => dispatch(logout()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(UserMenu);
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
|
||||||
import { auth, company } from '../api';
|
|
||||||
|
|
||||||
export const bootstrapSession = createAsyncThunk('auth/bootstrap', async (session) => {
|
|
||||||
if (session) return session;
|
|
||||||
return auth.me();
|
|
||||||
});
|
|
||||||
|
|
||||||
export const loginWithGoogle = createAsyncThunk('auth/login', async (credential) => {
|
|
||||||
const { session } = await auth.loginWithGoogle(credential);
|
|
||||||
if (!session?.user) {
|
|
||||||
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const selectCompany = createAsyncThunk('auth/selectCompany', async (companyId) => {
|
|
||||||
const data = await company.select(companyId);
|
|
||||||
return data.user ?? { selectedCompanyId: companyId };
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createCompany = createAsyncThunk('auth/createCompany', async (payload) => {
|
|
||||||
const data = await company.create(payload);
|
|
||||||
if (data.user) return data.user;
|
|
||||||
if (data.company?.id) {
|
|
||||||
const selected = await company.select(data.company.id);
|
|
||||||
return selected.user ?? { selectedCompanyId: data.company.id };
|
|
||||||
}
|
|
||||||
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
|
|
||||||
});
|
|
||||||
|
|
||||||
export const logout = createAsyncThunk('auth/logout', async () => {
|
|
||||||
await auth.logout();
|
|
||||||
window.history.replaceState(null, '', '/');
|
|
||||||
});
|
|
||||||
|
|
||||||
function mergeUser(state, incoming) {
|
|
||||||
if (!incoming) return state.user;
|
|
||||||
const selectedCompanyId =
|
|
||||||
incoming.selectedCompanyId ?? state.user?.selectedCompanyId ?? null;
|
|
||||||
return { ...state.user, ...incoming, selectedCompanyId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
user: null,
|
|
||||||
googleClientId: null,
|
|
||||||
status: 'loading',
|
|
||||||
};
|
|
||||||
|
|
||||||
const authSlice = createSlice({
|
|
||||||
name: 'auth',
|
|
||||||
initialState,
|
|
||||||
reducers: {},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder
|
|
||||||
.addCase(bootstrapSession.fulfilled, (state, action) => {
|
|
||||||
const session = action.payload;
|
|
||||||
state.googleClientId = session.googleClientId ?? state.googleClientId;
|
|
||||||
if (!session.user) {
|
|
||||||
if (state.status !== 'authed') {
|
|
||||||
state.user = null;
|
|
||||||
state.status = 'anon';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.user = mergeUser(state, session.user);
|
|
||||||
state.status = 'authed';
|
|
||||||
})
|
|
||||||
.addCase(bootstrapSession.rejected, (state) => {
|
|
||||||
if (state.status === 'loading') {
|
|
||||||
state.user = null;
|
|
||||||
state.status = 'anon';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.addCase(loginWithGoogle.fulfilled, (state, action) => {
|
|
||||||
const session = action.payload;
|
|
||||||
state.user = session.user;
|
|
||||||
state.googleClientId = session.googleClientId ?? state.googleClientId;
|
|
||||||
state.status = 'authed';
|
|
||||||
})
|
|
||||||
.addCase(loginWithGoogle.rejected, (state) => {
|
|
||||||
state.user = null;
|
|
||||||
state.status = 'anon';
|
|
||||||
})
|
|
||||||
.addCase(selectCompany.fulfilled, (state, action) => {
|
|
||||||
state.user = mergeUser(state, action.payload);
|
|
||||||
state.status = 'authed';
|
|
||||||
})
|
|
||||||
.addCase(createCompany.fulfilled, (state, action) => {
|
|
||||||
state.user = mergeUser(state, action.payload);
|
|
||||||
state.status = 'authed';
|
|
||||||
})
|
|
||||||
.addCase(createCompany.rejected, (state, action) => {
|
|
||||||
console.error('[redux] createCompany.rejected', action.error?.message, action.payload);
|
|
||||||
})
|
|
||||||
.addCase(logout.fulfilled, (state) => {
|
|
||||||
state.user = null;
|
|
||||||
state.status = 'anon';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const selectAuthStatus = (state) => state.auth.status;
|
|
||||||
export const selectAuthUser = (state) => state.auth.user;
|
|
||||||
export const selectGoogleClientId = (state) => state.auth.googleClientId;
|
|
||||||
export const selectNeedsCompanySetup = (state) =>
|
|
||||||
state.auth.status === 'authed' && state.auth.user?.selectedCompanyId == null;
|
|
||||||
|
|
||||||
export default authSlice.reducer;
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
import { brand as brandApi } from '../api';
|
import { brand as brandApi } from '../api';
|
||||||
|
import authAutomat, {
|
||||||
|
AUTH_COMPANY_CHANGED,
|
||||||
|
AUTH_LOGGED_OUT,
|
||||||
|
AUTH_SESSION_ESTABLISHED,
|
||||||
|
} from '../automats/authAutomat';
|
||||||
import { requireArray, requireObject } from '../utils/apiShape';
|
import { requireArray, requireObject } from '../utils/apiShape';
|
||||||
import { catchBackendError } from '../utils/backendError';
|
import { catchBackendError } from '../utils/backendError';
|
||||||
import {
|
|
||||||
bootstrapSession,
|
|
||||||
createCompany,
|
|
||||||
loginWithGoogle,
|
|
||||||
logout,
|
|
||||||
selectCompany,
|
|
||||||
} from './authSlice';
|
|
||||||
|
|
||||||
function sortBrands(brands) {
|
function sortBrands(brands) {
|
||||||
return [...brands].sort((a, b) =>
|
return [...brands].sort((a, b) =>
|
||||||
@@ -71,9 +69,8 @@ const brandSlice = createSlice({
|
|||||||
row,
|
row,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
.addCase(selectCompany.fulfilled, () => initialState)
|
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
|
||||||
.addCase(createCompany.fulfilled, () => initialState)
|
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||||
.addCase(logout.fulfilled, () => initialState);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,12 +81,9 @@ export const selectBrandsError = (state) => state.brand.error;
|
|||||||
export function brandLoadListener(listenerMiddleware) {
|
export function brandLoadListener(listenerMiddleware) {
|
||||||
listenerMiddleware.startListening({
|
listenerMiddleware.startListening({
|
||||||
matcher: (action) =>
|
matcher: (action) =>
|
||||||
bootstrapSession.fulfilled.match(action) ||
|
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||||
loginWithGoogle.fulfilled.match(action) ||
|
|
||||||
selectCompany.fulfilled.match(action) ||
|
|
||||||
createCompany.fulfilled.match(action),
|
|
||||||
effect: async (_action, api) => {
|
effect: async (_action, api) => {
|
||||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
if (companyId) {
|
if (companyId) {
|
||||||
api.dispatch(loadBrands());
|
api.dispatch(loadBrands());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
|||||||
import { category, article } from '../api';
|
import { category, article } from '../api';
|
||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
import { catchBackendError, resolveBackendError } from '../utils/backendError';
|
import { catchBackendError, resolveBackendError } from '../utils/backendError';
|
||||||
import { logout } from './authSlice';
|
import { AUTH_LOGGED_OUT } from '../automats/authAutomat';
|
||||||
import { loadCurrentCompany } from './companySlice';
|
import { loadCurrentCompany } from './companySlice';
|
||||||
|
|
||||||
export const ROOT_CATEGORY_NAME = 'Home';
|
export const ROOT_CATEGORY_NAME = 'Home';
|
||||||
@@ -645,7 +645,7 @@ const categorySlice = createSlice({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addCase(logout.fulfilled, () => initialState);
|
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
import { company } from '../api';
|
import { company } from '../api';
|
||||||
|
import authAutomat, {
|
||||||
|
AUTH_COMPANY_CHANGED,
|
||||||
|
AUTH_LOGGED_OUT,
|
||||||
|
AUTH_SESSION_ESTABLISHED,
|
||||||
|
} from '../automats/authAutomat';
|
||||||
import { normalizeCompany } from '../utils/companyForm';
|
import { normalizeCompany } from '../utils/companyForm';
|
||||||
import {
|
|
||||||
bootstrapSession,
|
|
||||||
createCompany,
|
|
||||||
loginWithGoogle,
|
|
||||||
logout,
|
|
||||||
selectCompany,
|
|
||||||
} from './authSlice';
|
|
||||||
|
|
||||||
export const loadCurrentCompany = createAsyncThunk(
|
export const loadCurrentCompany = createAsyncThunk(
|
||||||
'company/loadCurrent',
|
'company/loadCurrent',
|
||||||
async (_, { getState }) => {
|
async () => {
|
||||||
const companyId = getState().auth.user?.selectedCompanyId;
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
if (!companyId) return null;
|
if (!companyId) return null;
|
||||||
const data = await company.getById(companyId);
|
const data = await company.getById(companyId);
|
||||||
return normalizeCompany(data.company);
|
return normalizeCompany(data.company);
|
||||||
@@ -21,8 +19,8 @@ export const loadCurrentCompany = createAsyncThunk(
|
|||||||
|
|
||||||
export const updateCompany = createAsyncThunk(
|
export const updateCompany = createAsyncThunk(
|
||||||
'company/update',
|
'company/update',
|
||||||
async (payload, { getState }) => {
|
async (payload) => {
|
||||||
const companyId = getState().auth.user?.selectedCompanyId;
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
|
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
|
||||||
await company.update(companyId, payload);
|
await company.update(companyId, payload);
|
||||||
const data = await company.getById(companyId);
|
const data = await company.getById(companyId);
|
||||||
@@ -66,7 +64,7 @@ const companySlice = createSlice({
|
|||||||
state.status = 'failed';
|
state.status = 'failed';
|
||||||
state.error = action.error.message;
|
state.error = action.error.message;
|
||||||
})
|
})
|
||||||
.addCase(logout.fulfilled, (state) => {
|
.addCase(AUTH_LOGGED_OUT, (state) => {
|
||||||
state.current = null;
|
state.current = null;
|
||||||
state.status = 'idle';
|
state.status = 'idle';
|
||||||
state.error = null;
|
state.error = null;
|
||||||
@@ -87,12 +85,9 @@ export const selectCompanyRootCategoryId = (state) => state.company.current?.roo
|
|||||||
export function companyLoadListener(listenerMiddleware) {
|
export function companyLoadListener(listenerMiddleware) {
|
||||||
listenerMiddleware.startListening({
|
listenerMiddleware.startListening({
|
||||||
matcher: (action) =>
|
matcher: (action) =>
|
||||||
bootstrapSession.fulfilled.match(action) ||
|
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||||
loginWithGoogle.fulfilled.match(action) ||
|
|
||||||
selectCompany.fulfilled.match(action) ||
|
|
||||||
createCompany.fulfilled.match(action),
|
|
||||||
effect: async (_action, api) => {
|
effect: async (_action, api) => {
|
||||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
if (companyId) {
|
if (companyId) {
|
||||||
api.dispatch(loadCurrentCompany());
|
api.dispatch(loadCurrentCompany());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||||
import authReducer from './authSlice';
|
import { setAuthEventDispatcher } from '../automats/authAutomat';
|
||||||
import brandReducer, { brandLoadListener } from './brandSlice';
|
import brandReducer, { brandLoadListener } from './brandSlice';
|
||||||
import categoryReducer from './categorySlice';
|
import categoryReducer from './categorySlice';
|
||||||
import companyReducer, { companyLoadListener } from './companySlice';
|
import companyReducer, { companyLoadListener } from './companySlice';
|
||||||
@@ -25,16 +25,10 @@ function reduxDevLogger() {
|
|||||||
) {
|
) {
|
||||||
return next(action);
|
return next(action);
|
||||||
}
|
}
|
||||||
const before = storeApi.getState();
|
|
||||||
const result = next(action);
|
const result = next(action);
|
||||||
const after = storeApi.getState();
|
const after = storeApi.getState();
|
||||||
console.log('[redux]', action.type, {
|
console.log('[redux]', action.type, {
|
||||||
payload: action.payload,
|
payload: action.payload,
|
||||||
auth: {
|
|
||||||
selectedCompanyId: after.auth.user?.selectedCompanyId ?? null,
|
|
||||||
needsCompanySetup:
|
|
||||||
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
|
|
||||||
},
|
|
||||||
company: after.company.current,
|
company: after.company.current,
|
||||||
brands: after.brand.brands.length,
|
brands: after.brand.brands.length,
|
||||||
posterminals: after.posterminal.terminals.length,
|
posterminals: after.posterminal.terminals.length,
|
||||||
@@ -46,7 +40,6 @@ function reduxDevLogger() {
|
|||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
auth: authReducer,
|
|
||||||
company: companyReducer,
|
company: companyReducer,
|
||||||
category: categoryReducer,
|
category: categoryReducer,
|
||||||
brand: brandReducer,
|
brand: brandReducer,
|
||||||
@@ -58,3 +51,5 @@ export const store = configureStore({
|
|||||||
.concat(listenerMiddleware.middleware)
|
.concat(listenerMiddleware.middleware)
|
||||||
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
|
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setAuthEventDispatcher(store.dispatch);
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
import { posterminal } from '../api';
|
import { posterminal } from '../api';
|
||||||
|
import authAutomat, {
|
||||||
|
AUTH_COMPANY_CHANGED,
|
||||||
|
AUTH_LOGGED_OUT,
|
||||||
|
AUTH_SESSION_ESTABLISHED,
|
||||||
|
} from '../automats/authAutomat';
|
||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
import { catchBackendError, resolveBackendError } from '../utils/backendError';
|
import { catchBackendError, resolveBackendError } from '../utils/backendError';
|
||||||
import {
|
|
||||||
bootstrapSession,
|
|
||||||
createCompany,
|
|
||||||
loginWithGoogle,
|
|
||||||
logout,
|
|
||||||
selectCompany,
|
|
||||||
} from './authSlice';
|
|
||||||
import { loadCurrentCompany } from './companySlice';
|
import { loadCurrentCompany } from './companySlice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,7 +234,7 @@ const posterminalSlice = createSlice({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Reset on logout
|
// Reset on logout
|
||||||
.addCase(logout.fulfilled, () => initialState);
|
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,12 +247,9 @@ export const { resetPosterminalState } = posterminalSlice.actions;
|
|||||||
export function posterminalLoadListener(listenerMiddleware) {
|
export function posterminalLoadListener(listenerMiddleware) {
|
||||||
listenerMiddleware.startListening({
|
listenerMiddleware.startListening({
|
||||||
matcher: (action) =>
|
matcher: (action) =>
|
||||||
bootstrapSession.fulfilled.match(action) ||
|
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||||
loginWithGoogle.fulfilled.match(action) ||
|
|
||||||
selectCompany.fulfilled.match(action) ||
|
|
||||||
createCompany.fulfilled.match(action),
|
|
||||||
effect: async (action, api) => {
|
effect: async (action, api) => {
|
||||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
console.log('[posterminalLoadListener]', action.type, { companyId });
|
console.log('[posterminalLoadListener]', action.type, { companyId });
|
||||||
if (companyId) {
|
if (companyId) {
|
||||||
api.dispatch(loadPosterminals());
|
api.dispatch(loadPosterminals());
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
import { unit as unitApi } from '../api';
|
import { unit as unitApi } from '../api';
|
||||||
|
import authAutomat, {
|
||||||
|
AUTH_COMPANY_CHANGED,
|
||||||
|
AUTH_LOGGED_OUT,
|
||||||
|
AUTH_SESSION_ESTABLISHED,
|
||||||
|
} from '../automats/authAutomat';
|
||||||
import { requireArray, requireObject } from '../utils/apiShape';
|
import { requireArray, requireObject } from '../utils/apiShape';
|
||||||
import {
|
|
||||||
bootstrapSession,
|
|
||||||
createCompany,
|
|
||||||
loginWithGoogle,
|
|
||||||
logout,
|
|
||||||
selectCompany,
|
|
||||||
} from './authSlice';
|
|
||||||
|
|
||||||
function sortUnits(units) {
|
function sortUnits(units) {
|
||||||
return [...units].sort((a, b) => {
|
return [...units].sort((a, b) => {
|
||||||
@@ -69,9 +67,8 @@ const unitSlice = createSlice({
|
|||||||
.addCase(deleteUnit.fulfilled, (state, action) => {
|
.addCase(deleteUnit.fulfilled, (state, action) => {
|
||||||
state.units = state.units.filter((u) => u.si !== action.payload);
|
state.units = state.units.filter((u) => u.si !== action.payload);
|
||||||
})
|
})
|
||||||
.addCase(selectCompany.fulfilled, () => initialState)
|
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
|
||||||
.addCase(createCompany.fulfilled, () => initialState)
|
.addCase(AUTH_LOGGED_OUT, () => initialState);
|
||||||
.addCase(logout.fulfilled, () => initialState);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,12 +79,9 @@ export const selectUnitsError = (state) => state.unit.error;
|
|||||||
export function unitLoadListener(listenerMiddleware) {
|
export function unitLoadListener(listenerMiddleware) {
|
||||||
listenerMiddleware.startListening({
|
listenerMiddleware.startListening({
|
||||||
matcher: (action) =>
|
matcher: (action) =>
|
||||||
bootstrapSession.fulfilled.match(action) ||
|
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
|
||||||
loginWithGoogle.fulfilled.match(action) ||
|
|
||||||
selectCompany.fulfilled.match(action) ||
|
|
||||||
createCompany.fulfilled.match(action),
|
|
||||||
effect: async (_action, api) => {
|
effect: async (_action, api) => {
|
||||||
const companyId = api.getState().auth.user?.selectedCompanyId;
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
if (companyId) {
|
if (companyId) {
|
||||||
api.dispatch(loadUnits());
|
api.dispatch(loadUnits());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user