From 550586f46e913bd1da342ff629b679e97d16fc0a Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 28 Oct 2026 19:32:19 +0100 Subject: [PATCH] u --- .agents/skills/backend/SKILL.md | 1 + src/automats/companyAutomat.js | 95 +++++++++++++++++++---- src/automats/index.js | 1 + src/automats/sseAutomat.js | 9 +++ src/components/CompanySetupDialog.jsx | 40 +++++----- src/devtools/SseMonitor.jsx | 104 +------------------------- 6 files changed, 113 insertions(+), 137 deletions(-) diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index dc8720f..f57b9e0 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -609,6 +609,7 @@ _none_ - Returns: `200 SSE` - Events: + - `user`: `{ selectedCompanyId }` - `company`: `{ companyId }` - `category`: `{}` - `articles`: `{}` diff --git a/src/automats/companyAutomat.js b/src/automats/companyAutomat.js index 9b1c932..0fd8276 100644 --- a/src/automats/companyAutomat.js +++ b/src/automats/companyAutomat.js @@ -211,6 +211,7 @@ const initialUserSettings = readInitialUserSettings(); const initialState = { // 1. Company profile + companies: [], current: null, status: 'idle', error: null, @@ -385,18 +386,33 @@ async function fetchCarriersList() { const actions = { // #region 1. Company profile & orchestration - // MARK: calls company.getById from ../api/index.js + // MARK: calls company.list from ../api/index.js async load() { - const companyId = authAutomat.state.user?.selectedCompanyId; - if (!companyId) return null; + const hasAuth = Boolean( + authAutomat.state.hasAuth ?? + (authAutomat.state.status === 'authed' && authAutomat.state.user), + ); + if (!hasAuth) return null; if (inflightCompany) return inflightCompany; companyAutomat.setState({ status: 'loading', error: null }); inflightCompany = (async () => { try { - const data = await companyApi.getById(companyId); - const normalized = normalizeCompany(data.company); - companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); - return normalized; + 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), + ) ?? null) + : null; + if (companyId != null && !current) { + throw new Error('Unternehmen nicht gefunden.'); + } + companyAutomat.setState({ companies, current, status: 'succeeded', error: null }); + return current; } catch (err) { companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); throw err; @@ -407,16 +423,55 @@ const actions = { 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) { const companyId = authAutomat.state.user?.selectedCompanyId; if (!companyId) throw new Error('Kein Unternehmen ausgewählt.'); companyAutomat.setState({ status: 'loading', error: null }); try { await companyApi.update(companyId, payload); - const data = await companyApi.getById(companyId); - const normalized = normalizeCompany(data.company, payload); - companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); + const data = await companyApi.list(); + const companies = Array.isArray(data?.companies) + ? 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; } catch (err) { companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); @@ -1391,17 +1446,24 @@ companyAutomat = new Automat(initialState, actions, { name: 'company' }); hydrateUserSettingsFromIdb(companyAutomat); // #region authAutomat synchronization -let lastCompanyKey; +let lastAuthKey; function syncWithAuth(auth) { + const hasAuth = Boolean( + auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user), + ); 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; - if (companyId === lastCompanyKey) return; - lastCompanyKey = companyId; - if (companyId == null) { + const authKey = hasAuth ? `authed:${companyId ?? ''}` : 'anon'; + if (authKey === lastAuthKey) return; + lastAuthKey = authKey; + if (!hasAuth) { companyAutomat.actions.reset(); + } else if (companyId == null) { + companyAutomat.actions.reset(); + void companyAutomat.actions.load().catch(() => {}); } else { companyAutomat.actions.reset(); void companyAutomat.actions.loadAll(); @@ -1414,6 +1476,7 @@ syncWithAuth(authAutomat.state); // #region selectors & exported action helpers // Company profile selectors +export const selectCompanies = () => companyAutomat.state.companies; export const selectCurrentCompany = () => companyAutomat.state.current; export const selectCompanyId = () => companyAutomat.state.current?.id ?? null; export const selectCompanyStatus = () => companyAutomat.state.status; diff --git a/src/automats/index.js b/src/automats/index.js index b912ab9..80fec28 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -134,6 +134,7 @@ export { export { default as companyAutomat } from './companyAutomat'; export { + selectCompanies, selectCurrentCompany, selectCompanyId, selectCompanyStatus, diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 3679447..386b895 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -182,6 +182,15 @@ function connect() { 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. addTrackedListener(es, 'category', () => { const cat = Automat.get('category'); diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx index a25fecd..9980fd3 100644 --- a/src/components/CompanySetupDialog.jsx +++ b/src/components/CompanySetupDialog.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { PureComponent } from 'react'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; 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 AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn'; import BackendError from './BackendError'; -import { company } from '../api'; -import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; +import authAutomat from '../automats/authAutomat'; +import companyAutomat from '../automats/companyAutomat'; import { defaultIso639, ISO639_OPTIONS, @@ -126,12 +126,18 @@ function formatNumberPreview(prefix, padding, next, offset = 0) { 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) { super(props); this.state = { - companies: [], - loading: true, + ...mapCompanyState(companyAutomat.state), busy: false, error: null, name: '', @@ -150,26 +156,21 @@ class CompanySetupDialog extends Component { } 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() { - this.setState({ loading: true }); - try { - const data = await company.list(); - this.setState({ companies: data.companies ?? [] }); - } catch { - this.setState({ companies: [] }); - } finally { - this.setState({ loading: false }); - } + componentWillUnmount() { + this.unsubscribe?.(); } async handleSelect(companyId) { this.setState({ busy: true, error: null }); try { await authAutomat.actions.selectCompany(companyId); - await bootstrapAuthMe(); } catch (err) { this.setState({ error: backendErrorFrom(err) }); } finally { @@ -224,8 +225,6 @@ class CompanySetupDialog extends Component { reducedtaxrate: parsedReducedtaxrate, numbers, }); - - await bootstrapAuthMe(); } catch (err) { this.setState({ error: backendErrorFrom(err) }); } finally { @@ -236,7 +235,6 @@ class CompanySetupDialog extends Component { async handleLogout() { try { await authAutomat.actions.logout(); - await bootstrapAuthMe(); } catch (err) { this.setState({ error: backendErrorFrom(err) }); } diff --git a/src/devtools/SseMonitor.jsx b/src/devtools/SseMonitor.jsx index 4a2a491..74ee0f3 100644 --- a/src/devtools/SseMonitor.jsx +++ b/src/devtools/SseMonitor.jsx @@ -12,11 +12,10 @@ import CloseIcon from '@mui/icons-material/Close'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import SensorsIcon from '@mui/icons-material/Sensors'; -import sseAutomat, { getSseClientId } from '../automats/sseAutomat'; +import sseAutomat from '../automats/sseAutomat'; const PANEL_WIDTH = 420; const PANEL_HEIGHT = 380; -const RAW_EVENT_LIMIT = 500; const pulseAnimationA = keyframes` 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); } @@ -43,15 +42,11 @@ class SseMonitor extends PureComponent { this.state = { status: sseAutomat.state.status, companyId: sseAutomat.state.companyId, - events: [], + events: sseAutomat.state.events, open: false, position: null, eventPulse: 0, }; - this.nextRawId = 0; - this.rawAbort = null; - this.rawReconnectTimer = null; - this.rawBuffer = ''; this.panelRef = createRef(); this.handleToggle = this.handleToggle.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); @@ -66,15 +61,14 @@ class SseMonitor extends PureComponent { this.unsubscribe = sseAutomat.subscribe(this, (state) => ({ status: state.status, companyId: state.companyId, + events: state.events, })); window.addEventListener('keydown', this.handleKeyDown); - this.startRawStream(); } componentWillUnmount() { this.unsubscribe?.(); window.removeEventListener('keydown', this.handleKeyDown); - this.stopRawStream(); } componentDidUpdate(prevProps, prevState) { @@ -83,96 +77,6 @@ class SseMonitor extends PureComponent { if (currentEventId != null && currentEventId !== previousEventId) { 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) { @@ -190,7 +94,7 @@ class SseMonitor extends PureComponent { } handleClear() { - this.setState({ events: [] }); + sseAutomat.actions.clearEvents(); } handlePointerDown(event) {