This commit is contained in:
seb
2026-10-28 19:32:19 +01:00
parent 3c51de06c2
commit 550586f46e
6 changed files with 113 additions and 137 deletions

View File

@@ -609,6 +609,7 @@ _none_
- Returns: `200 SSE` - Returns: `200 SSE`
- Events: - Events:
- `user`: `{ selectedCompanyId }`
- `company`: `{ companyId }` - `company`: `{ companyId }`
- `category`: `{}` - `category`: `{}`
- `articles`: `{}` - `articles`: `{}`

View File

@@ -211,6 +211,7 @@ const initialUserSettings = readInitialUserSettings();
const initialState = { const initialState = {
// 1. Company profile // 1. Company profile
companies: [],
current: null, current: null,
status: 'idle', status: 'idle',
error: null, error: null,
@@ -385,18 +386,33 @@ async function fetchCarriersList() {
const actions = { const actions = {
// #region 1. Company profile & orchestration // #region 1. Company profile & orchestration
// MARK: calls company.getById from ../api/index.js // MARK: calls company.list from ../api/index.js
async load() { async load() {
const companyId = authAutomat.state.user?.selectedCompanyId; const hasAuth = Boolean(
if (!companyId) return null; authAutomat.state.hasAuth ??
(authAutomat.state.status === 'authed' && authAutomat.state.user),
);
if (!hasAuth) return null;
if (inflightCompany) return inflightCompany; if (inflightCompany) return inflightCompany;
companyAutomat.setState({ status: 'loading', error: null }); companyAutomat.setState({ status: 'loading', error: null });
inflightCompany = (async () => { inflightCompany = (async () => {
try { try {
const data = await companyApi.getById(companyId); const data = await companyApi.list();
const normalized = normalizeCompany(data.company); const companies = Array.isArray(data?.companies)
companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); ? data.companies.map((item) => normalizeCompany(item)).filter(Boolean)
return normalized; : [];
const companyId = authAutomat.state.user?.selectedCompanyId;
const current =
companyId != null
? (companies.find(
(item) => item.id === companyId || String(item.id) === String(companyId),
) ?? null)
: null;
if (companyId != null && !current) {
throw new Error('Unternehmen nicht gefunden.');
}
companyAutomat.setState({ companies, current, status: 'succeeded', error: null });
return current;
} catch (err) { } catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err; throw err;
@@ -407,16 +423,55 @@ const actions = {
return inflightCompany; return inflightCompany;
}, },
// MARK: calls company.update and company.getById from ../api/index.js // MARK: calls company.list from ../api/index.js
async reload() {
const hasAuth = Boolean(
authAutomat.state.hasAuth ??
(authAutomat.state.status === 'authed' && authAutomat.state.user),
);
if (!hasAuth) return null;
try {
const data = await companyApi.list();
const companies = Array.isArray(data?.companies)
? data.companies.map((item) => normalizeCompany(item)).filter(Boolean)
: [];
const companyId = authAutomat.state.user?.selectedCompanyId;
const current =
companyId != null
? (companies.find(
(item) => item.id === companyId || String(item.id) === String(companyId),
) ?? companyAutomat.state.current)
: null;
companyAutomat.setState({ companies, current, status: 'succeeded', error: null });
return current;
} catch (err) {
companyAutomat.setState({ error: err?.message ?? String(err) });
throw err;
}
},
// MARK: calls company.update and company.list from ../api/index.js
async update(payload) { async update(payload) {
const companyId = authAutomat.state.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.');
companyAutomat.setState({ status: 'loading', error: null }); companyAutomat.setState({ status: 'loading', error: null });
try { try {
await companyApi.update(companyId, payload); await companyApi.update(companyId, payload);
const data = await companyApi.getById(companyId); const data = await companyApi.list();
const normalized = normalizeCompany(data.company, payload); const companies = Array.isArray(data?.companies)
companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); ? data.companies
.map((item) =>
item.id === companyId || String(item.id) === String(companyId)
? normalizeCompany(item, payload)
: normalizeCompany(item),
)
.filter(Boolean)
: [];
const normalized =
companies.find((item) => item.id === companyId || String(item.id) === String(companyId)) ??
null;
if (!normalized) throw new Error('Unternehmen nicht gefunden.');
companyAutomat.setState({ companies, current: normalized, status: 'succeeded', error: null });
return normalized; return normalized;
} catch (err) { } catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
@@ -1391,17 +1446,24 @@ companyAutomat = new Automat(initialState, actions, { name: 'company' });
hydrateUserSettingsFromIdb(companyAutomat); hydrateUserSettingsFromIdb(companyAutomat);
// #region authAutomat synchronization // #region authAutomat synchronization
let lastCompanyKey; let lastAuthKey;
function syncWithAuth(auth) { function syncWithAuth(auth) {
const hasAuth = Boolean(
auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user),
);
const hasCompany = Boolean( const hasCompany = Boolean(
auth?.hasCompany ?? (auth?.status === 'authed' && auth?.user?.selectedCompanyId != null), auth?.hasCompany ?? (hasAuth && auth?.user?.selectedCompanyId != null),
); );
const companyId = hasCompany ? String(auth.user.selectedCompanyId) : null; const companyId = hasCompany ? String(auth.user.selectedCompanyId) : null;
if (companyId === lastCompanyKey) return; const authKey = hasAuth ? `authed:${companyId ?? ''}` : 'anon';
lastCompanyKey = companyId; if (authKey === lastAuthKey) return;
if (companyId == null) { lastAuthKey = authKey;
if (!hasAuth) {
companyAutomat.actions.reset(); companyAutomat.actions.reset();
} else if (companyId == null) {
companyAutomat.actions.reset();
void companyAutomat.actions.load().catch(() => {});
} else { } else {
companyAutomat.actions.reset(); companyAutomat.actions.reset();
void companyAutomat.actions.loadAll(); void companyAutomat.actions.loadAll();
@@ -1414,6 +1476,7 @@ syncWithAuth(authAutomat.state);
// #region selectors & exported action helpers // #region selectors & exported action helpers
// Company profile selectors // Company profile selectors
export const selectCompanies = () => companyAutomat.state.companies;
export const selectCurrentCompany = () => companyAutomat.state.current; export const selectCurrentCompany = () => companyAutomat.state.current;
export const selectCompanyId = () => companyAutomat.state.current?.id ?? null; export const selectCompanyId = () => companyAutomat.state.current?.id ?? null;
export const selectCompanyStatus = () => companyAutomat.state.status; export const selectCompanyStatus = () => companyAutomat.state.status;

View File

@@ -134,6 +134,7 @@ export {
export { default as companyAutomat } from './companyAutomat'; export { default as companyAutomat } from './companyAutomat';
export { export {
selectCompanies,
selectCurrentCompany, selectCurrentCompany,
selectCompanyId, selectCompanyId,
selectCompanyStatus, selectCompanyStatus,

View File

@@ -182,6 +182,15 @@ function connect() {
const es = new EventSource(url.href, { withCredentials: true }); const es = new EventSource(url.href, { withCredentials: true });
addTrackedListener(es, 'company', () => {
const comp = Automat.get('company');
if (comp?.actions?.reload) {
void comp.actions.reload().catch((err) => {
console.error('[sse] company refresh failed', err);
});
}
});
// Category tree changed: refresh the tree and every open category view. // Category tree changed: refresh the tree and every open category view.
addTrackedListener(es, 'category', () => { addTrackedListener(es, 'category', () => {
const cat = Automat.get('category'); const cat = Automat.get('category');

View File

@@ -1,4 +1,4 @@
import { Component } from 'react'; import { PureComponent } from 'react';
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';
@@ -36,8 +36,8 @@ import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn'; import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn';
import BackendError from './BackendError'; import BackendError from './BackendError';
import { company } from '../api'; import authAutomat from '../automats/authAutomat';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; import companyAutomat from '../automats/companyAutomat';
import { import {
defaultIso639, defaultIso639,
ISO639_OPTIONS, ISO639_OPTIONS,
@@ -126,12 +126,18 @@ function formatNumberPreview(prefix, padding, next, offset = 0) {
return `${prefix ?? ''}${numStr}`; return `${prefix ?? ''}${numStr}`;
} }
class CompanySetupDialog extends Component { function mapCompanyState(state) {
return {
companies: Array.isArray(state?.companies) ? state.companies : [],
loading: state?.status === 'idle' || state?.status === 'loading',
};
}
class CompanySetupDialog extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
companies: [], ...mapCompanyState(companyAutomat.state),
loading: true,
busy: false, busy: false,
error: null, error: null,
name: '', name: '',
@@ -150,26 +156,21 @@ class CompanySetupDialog extends Component {
} }
componentDidMount() { componentDidMount() {
void this.loadCompanies(); this.unsubscribe = companyAutomat.subscribe(this, mapCompanyState);
this.setState(mapCompanyState(companyAutomat.state));
if (companyAutomat.state.status !== 'succeeded') {
void companyAutomat.actions.load().catch(() => {});
}
} }
async loadCompanies() { componentWillUnmount() {
this.setState({ loading: true }); this.unsubscribe?.();
try {
const data = await company.list();
this.setState({ companies: data.companies ?? [] });
} catch {
this.setState({ companies: [] });
} finally {
this.setState({ loading: false });
}
} }
async handleSelect(companyId) { async handleSelect(companyId) {
this.setState({ busy: true, error: null }); this.setState({ busy: true, error: null });
try { try {
await authAutomat.actions.selectCompany(companyId); await authAutomat.actions.selectCompany(companyId);
await bootstrapAuthMe();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -224,8 +225,6 @@ class CompanySetupDialog extends Component {
reducedtaxrate: parsedReducedtaxrate, reducedtaxrate: parsedReducedtaxrate,
numbers, numbers,
}); });
await bootstrapAuthMe();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -236,7 +235,6 @@ class CompanySetupDialog extends Component {
async handleLogout() { async handleLogout() {
try { try {
await authAutomat.actions.logout(); await authAutomat.actions.logout();
await bootstrapAuthMe();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} }

View File

@@ -12,11 +12,10 @@ import CloseIcon from '@mui/icons-material/Close';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SensorsIcon from '@mui/icons-material/Sensors'; import SensorsIcon from '@mui/icons-material/Sensors';
import sseAutomat, { getSseClientId } from '../automats/sseAutomat'; import sseAutomat from '../automats/sseAutomat';
const PANEL_WIDTH = 420; const PANEL_WIDTH = 420;
const PANEL_HEIGHT = 380; const PANEL_HEIGHT = 380;
const RAW_EVENT_LIMIT = 500;
const pulseAnimationA = keyframes` const pulseAnimationA = keyframes`
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); } 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
35% { transform: scale(1.16); box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.55); } 35% { transform: scale(1.16); box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.55); }
@@ -43,15 +42,11 @@ class SseMonitor extends PureComponent {
this.state = { this.state = {
status: sseAutomat.state.status, status: sseAutomat.state.status,
companyId: sseAutomat.state.companyId, companyId: sseAutomat.state.companyId,
events: [], events: sseAutomat.state.events,
open: false, open: false,
position: null, position: null,
eventPulse: 0, eventPulse: 0,
}; };
this.nextRawId = 0;
this.rawAbort = null;
this.rawReconnectTimer = null;
this.rawBuffer = '';
this.panelRef = createRef(); this.panelRef = createRef();
this.handleToggle = this.handleToggle.bind(this); this.handleToggle = this.handleToggle.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
@@ -66,15 +61,14 @@ class SseMonitor extends PureComponent {
this.unsubscribe = sseAutomat.subscribe(this, (state) => ({ this.unsubscribe = sseAutomat.subscribe(this, (state) => ({
status: state.status, status: state.status,
companyId: state.companyId, companyId: state.companyId,
events: state.events,
})); }));
window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keydown', this.handleKeyDown);
this.startRawStream();
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubscribe?.(); this.unsubscribe?.();
window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keydown', this.handleKeyDown);
this.stopRawStream();
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
@@ -83,96 +77,6 @@ class SseMonitor extends PureComponent {
if (currentEventId != null && currentEventId !== previousEventId) { if (currentEventId != null && currentEventId !== previousEventId) {
this.setState((state) => ({ eventPulse: state.eventPulse + 1 })); this.setState((state) => ({ eventPulse: state.eventPulse + 1 }));
} }
if (prevState.companyId !== this.state.companyId) {
this.startRawStream();
}
}
pushRawEvent(type, data) {
const entry = {
id: ++this.nextRawId,
type: type || 'message',
data: data ?? '',
lastEventId: '',
time: Date.now(),
};
this.setState((state) => ({
events: [...state.events.slice(-(RAW_EVENT_LIMIT - 1)), entry],
}));
}
handleRawChunk(text) {
this.rawBuffer += text;
let boundary;
while ((boundary = this.rawBuffer.indexOf('\n\n')) !== -1) {
const frame = this.rawBuffer.slice(0, boundary);
this.rawBuffer = this.rawBuffer.slice(boundary + 2);
let type = '';
const dataLines = [];
let comment = '';
for (const line of frame.split('\n')) {
if (line.startsWith(':')) {
comment = line.slice(1).trim();
} else if (line.startsWith('event:')) {
type = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
// No filtering: show every frame, including heartbeats and
// events no automat listens for (brand, modelconversation, ...).
if (!type && comment) {
this.pushRawEvent(comment, '');
} else if (type || dataLines.length > 0) {
this.pushRawEvent(type || 'message', dataLines.join('\n'));
}
}
}
stopRawStream() {
if (this.rawReconnectTimer) {
clearTimeout(this.rawReconnectTimer);
this.rawReconnectTimer = null;
}
if (this.rawAbort) {
try {
this.rawAbort.abort();
} catch {}
this.rawAbort = null;
}
}
async startRawStream() {
this.stopRawStream();
this.rawBuffer = '';
const controller = new AbortController();
this.rawAbort = controller;
try {
const url = new URL('/api/events', window.location.origin);
try {
const cid = getSseClientId();
if (cid) url.searchParams.set('cid', `${cid}-monitor`);
} catch {}
const res = await fetch(url.href, {
credentials: 'include',
signal: controller.signal,
headers: { Accept: 'text/event-stream' },
});
if (!res.ok || !res.body) throw new Error(`SSE monitor HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
this.handleRawChunk(decoder.decode(value, { stream: true }));
}
} catch (err) {
if (controller.signal.aborted) return;
this.pushRawEvent('error', String(err?.message ?? err));
}
if (!controller.signal.aborted && !this.rawReconnectTimer) {
this.rawReconnectTimer = setTimeout(() => this.startRawStream(), 3000);
}
} }
handleKeyDown(event) { handleKeyDown(event) {
@@ -190,7 +94,7 @@ class SseMonitor extends PureComponent {
} }
handleClear() { handleClear() {
this.setState({ events: [] }); sseAutomat.actions.clearEvents();
} }
handlePointerDown(event) { handlePointerDown(event) {