diff --git a/src/api/backup.js b/src/api/backup.js new file mode 100644 index 0000000..8dfd74a --- /dev/null +++ b/src/api/backup.js @@ -0,0 +1,31 @@ +import { http } from './http.js'; + +/** + * Backup API for listing backups, checking service status, and triggering manual backups. + */ +export const backup = { + /** + * GET /api/backup + * Returns: 200 { status: BackupStatus, backups: { counts, hourly, daily, monthly } } + */ + // #region GET /api/backup — getStatus + // MARK: calls GET /api/backup from ./api/index.js + getStatus() { + return http('/api/backup'); + }, + // #endregion + + /** + * POST /api/backup/run + * Triggers a manual backup run. + * Returns: 200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: object } + */ + // #region POST /api/backup/run — run + // MARK: calls POST /api/backup/run from ./api/index.js + run() { + return http('/api/backup/run', { + method: 'POST', + }); + }, + // #endregion +}; diff --git a/src/api/index.js b/src/api/index.js index 0780025..f51f868 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -31,4 +31,4 @@ export { shipping } from './shipping.js'; export { payment } from './payment.js'; export { account } from './account.js'; export { mcp } from './mcp.js'; - +export { backup } from './backup.js'; diff --git a/src/api/vfs.js b/src/api/vfs.js index e562694..aacf7db 100644 --- a/src/api/vfs.js +++ b/src/api/vfs.js @@ -143,4 +143,42 @@ export const vfs = { }); }, // #endregion + + /** + * POST /api/vfs/drafts/response + * Send response to virtual draft request (action: read or list). + * @param {{ requestId: string, content?: object|string, files?: Array, error?: string }} body + */ + // #region POST /api/vfs/drafts/response — respondDraft + async respondDraft(body) { + try { + const res = await fetch('/api/vfs/drafts/response', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...clientIdHeaders(), + }, + credentials: 'include', + body: JSON.stringify(body), + }); + if (!res.ok) { + let errText = ''; + try { + const errData = await res.json(); + errText = errData.error || errData.message; + } catch { + errText = await res.text().catch(() => ''); + } + console.error('[vfs] respondDraft failed:', res.status, errText); + return { ok: false, error: errText }; + } + if (res.status === 204) return { ok: true }; + const text = await res.text(); + return text ? JSON.parse(text) : { ok: true }; + } catch (err) { + console.error('[vfs] respondDraft error:', err); + return { ok: false, error: err.message }; + } + }, + // #endregion }; diff --git a/src/automats/backupAutomat.js b/src/automats/backupAutomat.js new file mode 100644 index 0000000..8d18f43 --- /dev/null +++ b/src/automats/backupAutomat.js @@ -0,0 +1,209 @@ +/** + * @file backupAutomat.js + * @description Manages backup status and backup archives (`GET /backup`, `POST /backup/run`). + * + * Upstream Triggers: + * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on + * `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`. + * - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently. + * - UI / Settings: Calls to `actions.load()`, `actions.reload()`, or `actions.runBackup()`. + */ + +import { Automat } from 'react-automat'; +import { backup as backupApi } from '../api'; +import authAutomat from './authAutomat'; +import sseAutomat from './sseAutomat'; + +/** + * @typedef {Object} BackupItem + * @property {string} name Archive file or directory name + * @property {string} path Full storage path + * @property {number} totalSizeBytes Total size in bytes + * @property {string|null} timestamp Creation timestamp + * @property {object|null} [manifest] Backup manifest details + */ + +/** + * @typedef {Object} BackupStatus + * @property {boolean} enabled Whether automated backups are enabled + * @property {boolean} isRunning Whether a backup is currently executing + * @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql') + * @property {boolean} mssqlWarning Whether an MSSQL warning is present + * @property {string} backupDir Root directory where backups are stored + * @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration + * @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups + * @property {BackupItem|null} [latestHourly] Most recent hourly backup + * @property {BackupItem|null} [latestDaily] Most recent daily backup + * @property {BackupItem|null} [latestMonthly] Most recent monthly backup + */ + +/** + * @typedef {Object} BackupState + * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state + * @property {string|null} error Last error message + * @property {boolean} runningBackup Whether a manual backup run is currently in progress + * @property {BackupStatus|null} backupStatus Service status and retention settings + * @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives + * @property {object|null} lastRunResult Manifest/result from the most recent manual backup run + */ + +/** @type {BackupState} */ +const initialState = { + status: 'idle', + error: null, + runningBackup: false, + backupStatus: null, + backups: { + counts: { hourly: 0, daily: 0, monthly: 0 }, + hourly: [], + daily: [], + monthly: [], + }, + lastRunResult: null, +}; + +let backupAutomat; +let inflightLoad = null; + +// #region fetch +// MARK: calls GET /api/backup from ./api/index.js +async function fetchBackupData() { + const data = await backupApi.getStatus(); + const status = data?.status ?? null; + const rawBackups = data?.backups ?? {}; + + const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : []; + const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : []; + const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : []; + + const counts = rawBackups.counts ?? { + hourly: hourly.length, + daily: daily.length, + monthly: monthly.length, + }; + + return { + backupStatus: status, + backups: { + counts, + hourly, + daily, + monthly, + }, + }; +} +// #endregion + +const actions = { + // #region load + // MARK: calls GET /api/backup from ./api/index.js + /** + * Loads backup status and archive listing. + * Deduplicates concurrent callers. + * + * @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading' + * @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>} + */ + load(silent = false) { + if (inflightLoad) return inflightLoad; + if (!silent) { + backupAutomat.setState({ status: 'loading', error: null }); + } + inflightLoad = (async () => { + try { + const { backupStatus, backups } = await fetchBackupData(); + backupAutomat.setState({ + backupStatus, + backups, + status: 'ready', + error: null, + }); + return { backupStatus, backups }; + } catch (err) { + const error = err?.message ?? String(err); + backupAutomat.setState(silent ? { error } : { status: 'failed', error }); + throw err; + } finally { + inflightLoad = null; + } + })(); + return inflightLoad; + }, + // #endregion + + // #region reload + // MARK: calls GET /api/backup from ./api/index.js + /** + * Silently re-fetches backup status and archives in the background. + */ + async reload() { + return actions.load(true); + }, + // #endregion + + // #region runBackup + // MARK: calls POST /api/backup/run from ./api/index.js + /** + * Triggers an immediate manual backup run on the backend. + * + * @returns {Promise} The backup run result containing manifest and pruneResult + */ + async runBackup() { + backupAutomat.setState({ runningBackup: true, error: null }); + try { + const result = await backupApi.run(); + backupAutomat.setState({ lastRunResult: result }); + await actions.reload(); + return result; + } catch (err) { + const error = err?.message ?? String(err); + backupAutomat.setState({ error }); + throw err; + } finally { + backupAutomat.setState({ runningBackup: false }); + } + }, + // #endregion + + /** + * Resets the automat state to initial empty values. + */ + clear() { + backupAutomat.setState({ ...initialState }); + }, +}; + +backupAutomat = new Automat(initialState, actions, { name: 'backup' }); + +// #region sseListener +if (sseAutomat?.actions?.on) { + sseAutomat.actions.on('backup', () => { + void backupAutomat.actions.reload().catch(() => {}); + }); +} +// #endregion + +// #region authSync +function authKey(authState) { + if (authState?.status !== 'authed' || !authState.user) return null; + const companyId = authState.user.selectedCompanyId; + return companyId == null ? null : String(companyId); +} + +let lastAuthKey; +function syncWithAuth(authState) { + const key = authKey(authState); + if (key === lastAuthKey) return; + lastAuthKey = key; + if (key == null) { + backupAutomat.actions.clear(); + } else { + void backupAutomat.actions.load().catch(() => {}); + } +} + +authAutomat.subscribe(syncWithAuth); +syncWithAuth(authAutomat.state); +// #endregion + +export default backupAutomat; diff --git a/src/automats/deliveryEditorAutomat.js b/src/automats/deliveryEditorAutomat.js index 9805b6a..ff4621e 100644 --- a/src/automats/deliveryEditorAutomat.js +++ b/src/automats/deliveryEditorAutomat.js @@ -638,6 +638,37 @@ export function createDeliveryEditorAutomat(options = {}) { ...initialState, }); }, + + /** Serializes the delivery into a VFS draft manifest format. @returns {object} */ + getManifest() { + const state = automat.state; + const rawLines = state.lines ?? state.lineitems ?? []; + const lines = rawLines + .filter((l) => Boolean(l && (l.sku || l.name || l.amount != null || l.netprice != null || l.net != null))) + .map((l) => ({ + sku: l.sku || null, + name: l.name || null, + amount: l.amount != null ? Number(l.amount) : null, + pu: l.pu != null ? Number(l.pu) : 1, + unit: l.unit || null, + netprice: l.netprice != null ? Number(l.netprice) : (l.net != null ? Number(l.net) : null), + taxrate: l.taxrate != null ? Number(l.taxrate) : (l.tax != null ? Number(l.tax) : null), + })); + + return { + manifest: { + kind: 'delivery', + id: null, + date: state.date || null, + partyId: state.partyId ?? state.deliveryEntity ?? null, + externalNumber: state.externalNumber ?? state.external_number ?? null, + lines, + items: lines, + notes: state.notes ?? [], + attachments: (state.attachments ?? []).map((a) => ({ name: a.name, type: a.type })), + }, + }; + }, }; automat = new Automat(initialState, actions, { diff --git a/src/automats/draftSync.js b/src/automats/draftSync.js new file mode 100644 index 0000000..bc35823 --- /dev/null +++ b/src/automats/draftSync.js @@ -0,0 +1,190 @@ +/** + * @file draftSync.js + * @description Virtual Drafts synchronizer for VFS drafts (drafts/quote, drafts/order, drafts/delivery). + * Connects frontend draft automats (quoteDraftAutomat, orderDraftAutomat, deliveryEditorAutomat) + * with backend VFS requests and broadcasts. + * + * Incoming Signals: + * - SSE 'draft:request': Backend requests a draft (read or list). + * - SSE 'draft': Backend broadcasts an external update to a draft. + * + * Outgoing Calls: + * - POST /api/vfs/drafts/response: Responds to backend requests with draft content or file list. + */ + +import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat'; +import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat'; +import { vfs } from '../api/vfs'; +import { getStoredClientId } from '../api/http'; +import { getSseClientId } from './sseAutomat'; + +/** + * Normalizes a draft filename/path to its canonical key ('quote', 'order', 'delivery'). + * Handles paths like 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'quote', etc. + * + * @param {string} [name] + * @returns {string} Normalized key + */ +export function normalizeDraftName(name) { + if (!name) return ''; + return String(name) + .trim() + .replace(/^\/?(api\/)?vfs\//, '') + .replace(/^drafts\//, '') + .replace(/\.json$/, '') + .toLowerCase(); +} + +/** + * Supported virtual draft names. + */ +export const SUPPORTED_DRAFTS = ['quote', 'order', 'delivery']; + +/** + * Timestamps of last modifications per draft. + * @type {Map} + */ +const draftTimestamps = new Map(); + +/** + * Resolves active draft content for a given draft name from its managing automat. + * + * @param {string} rawName + * @returns {object|null} + */ +export function getActiveDraftContent(rawName) { + const name = normalizeDraftName(rawName); + if (name === 'quote') { + return quoteDraftAutomat.actions.getManifest(); + } + if (name === 'order') { + return orderDraftAutomat.actions.getManifest(); + } + if (name === 'delivery') { + return deliveryEditorAutomat.actions.getManifest(); + } + return null; +} + +/** + * Generates the list of virtual draft files for the VFS listing. + * + * @returns {Array<{ name: string, size: number, updated_at: string }>} + */ +export function getActiveDraftsList() { + const now = new Date().toISOString(); + return SUPPORTED_DRAFTS.map((name) => { + const content = getActiveDraftContent(name); + let size = 0; + if (content != null) { + try { + size = JSON.stringify(content).length; + } catch {} + } + const updatedAt = draftTimestamps.get(name) || (size > 0 ? now : '2026-10-14T00:00:00.000Z'); + return { + name, + size, + updated_at: updatedAt, + }; + }); +} + +/** + * Applies incoming draft content update to the managing automat. + * + * @param {string} rawName + * @param {object|string} rawContent + */ +export function applyDraftUpdate(rawName, rawContent) { + const name = normalizeDraftName(rawName); + if (!name) return; + + let content = rawContent; + if (typeof content === 'string') { + try { + content = JSON.parse(content); + } catch (err) { + console.error(`[draft] failed to parse incoming draft string for ${name}:`, err); + return; + } + } + + draftTimestamps.set(name, new Date().toISOString()); + + try { + if (name === 'quote') { + quoteDraftAutomat.actions.setManifest(content); + } else if (name === 'order') { + orderDraftAutomat.actions.setManifest(content); + } else if (name === 'delivery') { + const manifest = content?.manifest || content || {}; + const rawLines = manifest.lines ?? manifest.lineitems ?? manifest.items ?? []; + const lines = Array.isArray(rawLines) + ? rawLines.map((l) => (l && l.key ? l : createEmptyLineitem(l))) + : []; + const partyId = manifest.partyId ?? manifest.deliveryEntity ?? manifest.supplierId ?? null; + const externalNumber = manifest.externalNumber ?? manifest.external_number ?? null; + + deliveryEditorAutomat.actions.setDelivery({ + ...manifest, + partyId, + deliveryEntity: partyId, + externalNumber, + external_number: externalNumber, + lines, + }); + } + } catch (err) { + console.error(`[draft] error applying draft update to ${name} automat:`, err); + } +} + +/** + * Handles incoming 'draft:request' SSE event from backend. + * + * @param {{ requestId: string, action: 'read'|'list', name?: string, target?: string }} payload + * @returns {Promise} + */ +export async function handleDraftRequest(payload) { + if (!payload || !payload.requestId) return; + const { requestId, action, name, target } = payload; + + try { + if (action === 'read') { + const draftName = normalizeDraftName(name || target); + const content = getActiveDraftContent(draftName); + if (content != null) { + await vfs.respondDraft({ requestId, content }); + } else { + await vfs.respondDraft({ requestId, error: 'Draft not open' }); + } + } else if (action === 'list') { + const files = getActiveDraftsList(); + await vfs.respondDraft({ requestId, files }); + } + } catch (err) { + console.error('[draft] error handling draft:request:', err); + } +} + +/** + * Handles incoming 'draft' SSE broadcast from backend. + * + * @param {{ sender?: string, action: string, name?: string, target?: string, content?: any }} payload + */ +export function handleDraftEvent(payload) { + if (!payload) return; + const { sender, action, name, target, content } = payload; + + const myClientId = getStoredClientId() || (typeof getSseClientId === 'function' ? getSseClientId() : null); + if (sender && myClientId && sender === myClientId) { + // Ignore self-echo + return; + } + + if (action === 'update') { + const draftName = normalizeDraftName(name || target); + applyDraftUpdate(draftName, content); + } +} diff --git a/src/automats/index.js b/src/automats/index.js index b590ea1..afe37b6 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -5,6 +5,8 @@ */ export * from './deliveryEditorAutomat'; +export * from './manifestDraftAutomat'; +export * from './draftSync'; export { default as authAutomat } from './authAutomat'; export * from './authAutomat'; @@ -85,6 +87,7 @@ export { default as entityAutomat } from './entityAutomat'; export { default as customergroupAutomat } from './customergroupAutomat'; export { default as numberAutomat } from './numberAutomat'; export { default as accountAutomat } from './accountAutomat'; +export { default as backupAutomat } from './backupAutomat'; export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat'; diff --git a/src/automats/manifestDraftAutomat.js b/src/automats/manifestDraftAutomat.js new file mode 100644 index 0000000..a674978 --- /dev/null +++ b/src/automats/manifestDraftAutomat.js @@ -0,0 +1,235 @@ +/** + * @file manifestDraftAutomat.js + * @description Out-of-tree state automats for in-progress quotes and orders (quoteDraftAutomat, orderDraftAutomat). + * Preserves draft state across view navigation and syncs bidirectionally with VFS virtual draft files. + */ + +import { Automat } from 'react-automat'; +import { selectCompanyTaxrate } from './companyAutomat'; +import artikelAutomat, { getArticle } from './artikelAutomat'; +import { + emptyLine, + lineFromApi, + dateInputValue, + parseNum, +} from '../components/manifest/manifestConfig'; + +/** + * Creates the initial empty draft state for a manifest kind. + * + * @param {'quote'|'order'} kind + * @returns {object} Initial state + */ +export function createInitialManifestDraftState(kind) { + return { + kind, + mode: 'create', + id: null, + date: '', + partyId: null, + number: '', + externalNumber: '', + lines: [emptyLine()], + attachments: [], + }; +} + +/** + * Creates an Automat instance managing draft state for a manifest kind. + * + * @param {{ kind: 'quote'|'order', name: string }} options + * @returns {Automat} + */ +export function createManifestDraftAutomat({ kind, name }) { + let automat; + + const actions = { + /** @param {string} date */ + setDate(date) { + automat.setState({ date: dateInputValue(date) }); + }, + + /** @param {number|string|null} partyId */ + setPartyId(partyId) { + automat.setState({ partyId: partyId != null ? partyId : null }); + }, + + /** @param {string} externalNumber */ + setExternalNumber(externalNumber) { + automat.setState({ externalNumber: externalNumber != null ? String(externalNumber) : '' }); + }, + + /** @param {string} number */ + setNumber(number) { + automat.setState({ number: number != null ? String(number) : '' }); + }, + + /** @param {Array} lines */ + setLines(lines) { + const list = Array.isArray(lines) && lines.length > 0 ? lines : [emptyLine()]; + automat.setState({ lines: list }); + }, + + /** + * Updates an existing line by key. + * @param {string} key + * @param {string|object} patchOrField + * @param {*} [value] + */ + updateLine(key, patchOrField, value) { + const current = automat.state.lines || []; + const next = current.map((line) => { + if (line.key !== key) return line; + if (typeof patchOrField === 'object' && patchOrField != null) { + return { ...line, ...patchOrField }; + } + return { ...line, [patchOrField]: value }; + }); + automat.setState({ lines: next }); + }, + + /** @param {object} [line] */ + addLine(line = null) { + const current = automat.state.lines || []; + const newLine = line ? { ...emptyLine(), ...line } : emptyLine(); + automat.setState({ lines: [...current, newLine] }); + }, + + /** @param {string} key */ + removeLine(key) { + const current = automat.state.lines || []; + const next = current.filter((l) => l.key !== key); + automat.setState({ lines: next.length > 0 ? next : [emptyLine()] }); + }, + + removeAllLines() { + automat.setState({ lines: [emptyLine()] }); + }, + + /** @param {Array} attachments */ + setAttachments(attachments) { + automat.setState({ attachments: Array.isArray(attachments) ? attachments : [] }); + }, + + /** Resets draft state back to clean initial state. */ + reset() { + automat.setState(createInitialManifestDraftState(kind)); + }, + + /** + * Applies full manifest update (from external agent, VFS, or SSE). + * @param {object} content + */ + setManifest(content) { + if (!content) return; + const manifest = content.manifest || content; + const updates = {}; + + if (manifest.date !== undefined) { + updates.date = dateInputValue(manifest.date); + } + const party = manifest.partyId ?? manifest.entityId ?? manifest.party_id ?? manifest.supplierId; + if (party !== undefined) { + updates.partyId = party != null ? party : null; + } + const ext = manifest.externalNumber ?? manifest.external_number; + if (ext !== undefined) { + updates.externalNumber = ext != null ? String(ext) : ''; + } + + const rawLines = manifest.items ?? manifest.lines ?? manifest.lineitems; + if (Array.isArray(rawLines)) { + const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19'; + const lines = rawLines.map((row) => { + const mapped = lineFromApi(row); + if (!mapped.amount) mapped.amount = '1'; + if (!mapped.tax) { + mapped.tax = normTax; + mapped.taxrate = normTax; + } + if (!mapped.rebate) mapped.rebate = '0'; + if (!mapped.name && mapped.sku) { + const art = getArticle(mapped.sku); + if (art?.name) mapped.name = art.name; + } + return mapped; + }); + updates.lines = lines.length > 0 ? lines : [emptyLine()]; + + for (const l of updates.lines) { + if (l.sku) { + void artikelAutomat.actions.loadArticle(l.sku).catch(() => {}); + } + } + } + + automat.setState(updates); + }, + + /** + * Serializes current draft state into VFS draft manifest format. + * @returns {object} Manifest payload + */ + getManifest() { + const state = automat.state; + const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19'; + + const formattedLines = (state.lines || []) + .filter((l) => Boolean(l && (l.sku || l.name || l.net || l.gross))) + .map((l) => ({ + sku: l.sku || null, + name: l.name || null, + amount: parseNum(l.amount) ?? (l.amount ? Number(l.amount) : 1), + net: parseNum(l.net) ?? parseNum(l.netprice) ?? null, + gross: parseNum(l.gross) ?? null, + taxrate: parseNum(l.tax) ?? parseNum(l.taxrate) ?? Number(normTax), + rebate: parseNum(l.rebate) ?? 0, + pu: parseNum(l.pu) ?? 1, + unit: l.unit || null, + })); + + return { + manifest: { + kind, + mode: 'create', + id: null, + date: state.date || null, + partyId: state.partyId ?? null, + externalNumber: state.externalNumber || null, + lines: formattedLines, + items: formattedLines, + }, + }; + }, + }; + + automat = new Automat(createInitialManifestDraftState(kind), actions, { + name, + persist: false, + }); + + return automat; +} + +/** Shared singleton automat for quote draft. */ +export const quoteDraftAutomat = createManifestDraftAutomat({ + kind: 'quote', + name: 'quote-draft', +}); + +/** Shared singleton automat for order draft. */ +export const orderDraftAutomat = createManifestDraftAutomat({ + kind: 'order', + name: 'order-draft', +}); + +/** + * Returns the draft automat for a given manifest kind. + * @param {'quote'|'order'} kind + * @returns {Automat|null} + */ +export function getManifestDraftAutomat(kind) { + if (kind === 'quote') return quoteDraftAutomat; + if (kind === 'order') return orderDraftAutomat; + return null; +} diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 4fb0827..2f048bc 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -33,6 +33,7 @@ import attributeAutomat from './attributeAutomat'; import propertyAutomat from './propertyAutomat'; import supplierAutomat from './supplierAutomat'; import orderAutomat from './orderAutomat'; +import { handleDraftRequest, handleDraftEvent } from './draftSync'; /** @@ -250,6 +251,24 @@ function connect() { }); }); + addTrackedListener(es, 'draft:request', (event) => { + try { + const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + void handleDraftRequest(payload); + } catch (err) { + console.error('[sse] draft:request handling failed', err); + } + }); + + addTrackedListener(es, 'draft', (event) => { + try { + const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + handleDraftEvent(payload); + } catch (err) { + console.error('[sse] draft event handling failed', err); + } + }); + addTrackedListener(es, 'open', () => { sseAutomat.setState({ status: 'connected' }); }); diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 529fd5e..858fcdb 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -30,6 +30,7 @@ import { parseNum, } from './manifestConfig'; import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber'; +import { getManifestDraftAutomat } from '../../automats/manifestDraftAutomat'; class ManifestForm extends PureComponent { constructor(props) { @@ -42,17 +43,20 @@ class ManifestForm extends PureComponent { ? supplierAutomat.state.status === 'ready' : entityAutomat.state.status === 'ready'; + this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null; + const initialDraft = this.draftAutomat?.state; + this.state = { status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'), error: null, parties: initialParties, partiesLoading: !partiesReady, - date: '', - partyId: null, - number: '', - externalNumber: '', - lines: [emptyLine()], - attachments: [], + date: initialDraft?.date || '', + partyId: initialDraft?.partyId ?? null, + number: initialDraft?.number || '', + externalNumber: initialDraft?.externalNumber || '', + lines: initialDraft?.lines?.length > 0 ? initialDraft.lines : [emptyLine()], + attachments: initialDraft?.attachments || [], saving: false, pdfUploading: false, pdfDialogOpen: false, @@ -131,19 +135,48 @@ class ManifestForm extends PureComponent { } else { void this.loadPartiesOnly(); } + + if (this.draftAutomat) { + this.unsubDraft = this.draftAutomat.subscribe(this, (state) => ({ + date: state.date || '', + partyId: state.partyId, + externalNumber: state.externalNumber || '', + lines: state.lines?.length > 0 ? state.lines : [emptyLine()], + attachments: state.attachments || [], + })); + } } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps, prevState) { if ( this.props.mode === 'edit' && prevProps.docId !== this.props.docId ) { void this.loadEdit(); } + + if (this.props.mode === 'create' && this.draftAutomat) { + if ( + prevState.date !== this.state.date || + prevState.partyId !== this.state.partyId || + prevState.externalNumber !== this.state.externalNumber || + prevState.lines !== this.state.lines || + prevState.attachments !== this.state.attachments + ) { + this.draftAutomat.setState({ + date: this.state.date, + partyId: this.state.partyId, + externalNumber: this.state.externalNumber, + lines: this.state.lines, + attachments: this.state.attachments, + }); + } + } } componentWillUnmount() { this._cancelled = true; + this.unsubDraft?.(); this.unsubParties?.(); this.unsubCompany?.(); if (this._parseAbortController) { @@ -687,6 +720,7 @@ class ManifestForm extends PureComponent { requireObject(data, cfg.postPathLabel); requireObject(data[cfg.docField], cfg.docField); requireDefined(data[cfg.docField].id, `${cfg.docField}.id`); + this.draftAutomat?.actions?.reset(); await cfg.listAutomat.actions.reload(); if (this._cancelled) return; enqueueSnackbar(cfg.createdMsg, { variant: 'success' }); diff --git a/src/components/settings/BackupSettingsPanel.jsx b/src/components/settings/BackupSettingsPanel.jsx new file mode 100644 index 0000000..0dde41c --- /dev/null +++ b/src/components/settings/BackupSettingsPanel.jsx @@ -0,0 +1,567 @@ +import { Component } from 'react'; +import Alert from '@mui/material/Alert'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Collapse from '@mui/material/Collapse'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Tab from '@mui/material/Tab'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; +import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined'; +import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined'; +import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import StorageIcon from '@mui/icons-material/Storage'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { enqueueSnackbar } from 'notistack'; +import backupAutomat from '../../automats/backupAutomat'; +import { notifyError } from '../../utils/notify'; +import { + BORDER, + CHROMA, + DARK_1, + DARK_2, + DARK_4, + LIGHT_3, + PRIMARY, + SURFACE_BASE, + SURFACE_RAISED, + WHITE, +} from '../../colors'; + +function formatBytes(bytes) { + if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} + +function formatDate(ts) { + if (!ts) return '—'; + try { + const d = new Date(ts); + if (isNaN(d.getTime())) return String(ts); + return d.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return String(ts); + } +} + +function formatDuration(ms) { + if (ms == null || isNaN(ms)) return '—'; + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} + +export default class BackupSettingsPanel extends Component { + constructor(props) { + super(props); + const { status, error, runningBackup, backupStatus, backups } = backupAutomat.state; + this.state = { + status, + error, + runningBackup, + backupStatus, + backups, + tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly' + expandedItemName: null, + }; + + this.handleRefresh = this.handleRefresh.bind(this); + this.handleRunBackup = this.handleRunBackup.bind(this); + this.handleTierChange = this.handleTierChange.bind(this); + this.handleToggleExpand = this.handleToggleExpand.bind(this); + } + + componentDidMount() { + this.unsub = backupAutomat.subscribe(this, (state) => ({ + status: state.status, + error: state.error, + runningBackup: state.runningBackup, + backupStatus: state.backupStatus, + backups: state.backups, + })); + + if (backupAutomat.state.status === 'idle') { + void this.loadBackups(); + } + } + + componentWillUnmount() { + this.unsub?.(); + } + + // #region loadBackups + // MARK: calls backupAutomat.actions.load from ./api/index.js + async loadBackups() { + try { + await backupAutomat.actions.load(); + } catch (err) { + notifyError(err); + } + } + // #endregion + + // #region handleRefresh + // MARK: calls backupAutomat.actions.reload from ./api/index.js + async handleRefresh() { + try { + await backupAutomat.actions.reload(); + enqueueSnackbar('Backup-Status aktualisiert', { variant: 'info' }); + } catch (err) { + notifyError(err); + } + } + // #endregion + + // #region handleRunBackup + // MARK: calls POST /backup/run via backupAutomat.actions.runBackup from ./api/index.js + async handleRunBackup() { + if (this.state.runningBackup || this.state.backupStatus?.isRunning) return; + + try { + const result = await backupAutomat.actions.runBackup(); + const sizeStr = result?.totalSizeBytes != null ? ` (${formatBytes(result.totalSizeBytes)})` : ''; + enqueueSnackbar(`Manuelles Backup erfolgreich erstellt${sizeStr}.`, { + variant: 'success', + }); + } catch (err) { + notifyError(err); + } + } + // #endregion + + handleTierChange(_event, newValue) { + this.setState({ tierTab: newValue, expandedItemName: null }); + } + + handleToggleExpand(name) { + this.setState((prev) => ({ + expandedItemName: prev.expandedItemName === name ? null : name, + })); + } + + render() { + const { status, error, runningBackup, backupStatus, backups, tierTab, expandedItemName } = this.state; + + if (status === 'loading' && !backupStatus) { + return ( + + + + Backup-Status wird geladen... + + + ); + } + + if (status === 'failed' && !backupStatus) { + return ( + + + {error || 'Fehler beim Laden des Backup-Status.'} + + + + ); + } + + const isExecuting = Boolean(runningBackup || backupStatus?.isRunning); + const retention = backupStatus?.retention ?? { hourlyMax: 24, dailyMax: 7, monthly: 'Monatserster' }; + const counts = backups?.counts ?? { hourly: 0, daily: 0, monthly: 0 }; + const hourlyList = backups?.hourly || []; + const dailyList = backups?.daily || []; + const monthlyList = backups?.monthly || []; + + const activeList = tierTab === 'hourly' + ? hourlyList + : tierTab === 'daily' + ? dailyList + : monthlyList; + + return ( + + {/* Header Title & Actions */} + + + + + Datensicherung & Backups + + + Automatische Sicherung von Datenbank, virtuellen Dateisystemen (VFS) und Dokumentenspeicher. + + + + + + + + + {/* Status Badges Bar */} + + : } + label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'} + color={backupStatus?.enabled ? 'success' : 'default'} + variant="outlined" + /> + {isExecuting && ( + } + label="Backup wird ausgeführt..." + color="primary" + /> + )} + } + label={`Provider: ${backupStatus?.dbProvider?.toUpperCase() || 'DATENBANK'}`} + variant="outlined" + /> + {backupStatus?.backupDir && ( + } + label={`Verzeichnis: ${backupStatus.backupDir}`} + variant="outlined" + sx={{ maxWidth: 360 }} + /> + )} + + + {/* MSSQL Warning if present */} + {backupStatus?.mssqlWarning && ( + }> + MSSQL-Hinweis: Datenbank-Backups erfordern Schreibberechtigungen des SQL-Server-Dienstes auf das Backup-Verzeichnis oder werden getrennt verwaltet. + + )} + + {/* Overview KPI Cards */} + + {/* Hourly KPI */} + + + + Stündliche Sicherungen + + + {counts.hourly}{' '} + + / {retention.hourlyMax} max + + + + + Zuletzt: {formatDate(backupStatus?.latestHourly?.timestamp || hourlyList[0]?.timestamp)} + + + Größe: {formatBytes(backupStatus?.latestHourly?.totalSizeBytes || hourlyList[0]?.totalSizeBytes)} + + + + + {/* Daily KPI */} + + + + Tägliche Sicherungen + + + {counts.daily}{' '} + + / {retention.dailyMax} max + + + + + Zuletzt: {formatDate(backupStatus?.latestDaily?.timestamp || dailyList[0]?.timestamp)} + + + Größe: {formatBytes(backupStatus?.latestDaily?.totalSizeBytes || dailyList[0]?.totalSizeBytes)} + + + + + {/* Monthly KPI */} + + + + Monatliche Sicherungen + + + {counts.monthly} + + + + Zuletzt: {formatDate(backupStatus?.latestMonthly?.timestamp || monthlyList[0]?.timestamp)} + + + Größe: {formatBytes(backupStatus?.latestMonthly?.totalSizeBytes || monthlyList[0]?.totalSizeBytes)} + + + + + + {/* Retention Policy Note */} + + Aufbewahrungsrichtlinie: Maximal {retention.hourlyMax} stündliche Snapshots, {retention.dailyMax} tägliche Snapshots; monatliche Archivierung ({retention.monthly || 'am 1. Tag des Monats'}). Ältere Snapshots werden automatisch bereinigt. + + + {/* Backups Table Section with Tabs */} + + + + + + + + + + {activeList.length === 0 ? ( + + + + Keine Sicherungen in dieser Kategorie vorhanden. + + + ) : ( + + + + + + Zeitpunkt + Archivname + Größe + Typ + + + + {activeList.map((item) => { + const isExpanded = expandedItemName === item.name; + const manifest = item.manifest; + const isManual = Boolean(manifest?.manual); + + return ( + this.handleToggleExpand(item.name)} + /> + ); + })} + +
+
+ )} +
+
+ ); + } +} + +function BackupTableRow({ item, isExpanded, isManual, manifest, onToggleExpand }) { + return ( + <> + *': { borderBottom: isExpanded ? 'none !important' : undefined }, + }} + > + + { e.stopPropagation(); onToggleExpand(); }}> + {isExpanded ? : } + + + + {formatDate(item.timestamp)} + + + {item.name} + + + {formatBytes(item.totalSizeBytes)} + + + + + + + + + + + Backup-Details & Manifest + + + + + Datenbank + + + {manifest?.files?.database + ? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})` + : '—'} + + + + + VFS (Dateien & Belege) + + + {manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'} + + + + + Dokumentenspeicher (Storage) + + + {manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'} + + + {manifest?.durationMs != null && ( + + + Dauer + + + {formatDuration(manifest.durationMs)} + + + )} + {manifest?.dbProvider && ( + + + Datenbank-Provider + + + {manifest.dbProvider.toUpperCase()} + + + )} + + + Speicherpfad + + + {item.path} + + + + + + + + + ); +} diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx index 88539a6..dc0bc92 100644 --- a/src/components/settings/SettingsDialog.jsx +++ b/src/components/settings/SettingsDialog.jsx @@ -20,7 +20,9 @@ import SettingsKeysPanel from './SettingsKeysPanel'; import ImportExportSettingsPanel from './ImportExportSettingsPanel'; import PosTerminalSettingsPanel from './PosTerminalSettingsPanel'; import McpAuthorizationsPanel from './McpAuthorizationsPanel'; +import BackupSettingsPanel from './BackupSettingsPanel'; import SecurityIcon from '@mui/icons-material/Security'; +import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined'; import { SURFACE_BASE } from '../../colors'; const TAB_KEYS = { @@ -29,6 +31,7 @@ const TAB_KEYS = { numbers: 'numbers', printers: 'printers', importExport: 'importExport', + backup: 'backup', posterminal: 'posterminal', security: 'security', }; @@ -39,6 +42,7 @@ const TABS = [ { id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: , Panel: NumberSettingsPanel }, { id: TAB_KEYS.printers, label: 'Drucker', icon: , Panel: PrinterSettingsPanel }, { id: TAB_KEYS.importExport, label: 'Import/Export', icon: , Panel: ImportExportSettingsPanel }, + { id: TAB_KEYS.backup, label: 'Backups', icon: , Panel: BackupSettingsPanel }, { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: , Panel: PosTerminalSettingsPanel }, { id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: , Panel: McpAuthorizationsPanel }, ]; diff --git a/src/components/settings/index.js b/src/components/settings/index.js index 7b8e948..5656049 100644 --- a/src/components/settings/index.js +++ b/src/components/settings/index.js @@ -5,3 +5,5 @@ export { default as NumberSettingsPanel } from './NumberSettingsPanel'; export { default as PrinterSettingsPanel } from './PrinterSettingsPanel'; export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel'; export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel'; +export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel'; +export { default as BackupSettingsPanel } from './BackupSettingsPanel';