u
This commit is contained in:
31
src/api/backup.js
Normal file
31
src/api/backup.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -31,4 +31,4 @@ export { shipping } from './shipping.js';
|
|||||||
export { payment } from './payment.js';
|
export { payment } from './payment.js';
|
||||||
export { account } from './account.js';
|
export { account } from './account.js';
|
||||||
export { mcp } from './mcp.js';
|
export { mcp } from './mcp.js';
|
||||||
|
export { backup } from './backup.js';
|
||||||
|
|||||||
@@ -143,4 +143,42 @@ export const vfs = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vfs/drafts/response
|
||||||
|
* Send response to virtual draft request (action: read or list).
|
||||||
|
* @param {{ requestId: string, content?: object|string, files?: Array<object>, 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
|
||||||
};
|
};
|
||||||
|
|||||||
209
src/automats/backupAutomat.js
Normal file
209
src/automats/backupAutomat.js
Normal file
@@ -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<object>} 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;
|
||||||
@@ -638,6 +638,37 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
...initialState,
|
...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, {
|
automat = new Automat(initialState, actions, {
|
||||||
|
|||||||
190
src/automats/draftSync.js
Normal file
190
src/automats/draftSync.js
Normal file
@@ -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<string, string>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './deliveryEditorAutomat';
|
export * from './deliveryEditorAutomat';
|
||||||
|
export * from './manifestDraftAutomat';
|
||||||
|
export * from './draftSync';
|
||||||
|
|
||||||
export { default as authAutomat } from './authAutomat';
|
export { default as authAutomat } from './authAutomat';
|
||||||
export * from './authAutomat';
|
export * from './authAutomat';
|
||||||
@@ -85,6 +87,7 @@ export { default as entityAutomat } from './entityAutomat';
|
|||||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
export { default as customergroupAutomat } from './customergroupAutomat';
|
||||||
export { default as numberAutomat } from './numberAutomat';
|
export { default as numberAutomat } from './numberAutomat';
|
||||||
export { default as accountAutomat } from './accountAutomat';
|
export { default as accountAutomat } from './accountAutomat';
|
||||||
|
export { default as backupAutomat } from './backupAutomat';
|
||||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
235
src/automats/manifestDraftAutomat.js
Normal file
235
src/automats/manifestDraftAutomat.js
Normal file
@@ -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<object>} 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<object>} 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;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import attributeAutomat from './attributeAutomat';
|
|||||||
import propertyAutomat from './propertyAutomat';
|
import propertyAutomat from './propertyAutomat';
|
||||||
import supplierAutomat from './supplierAutomat';
|
import supplierAutomat from './supplierAutomat';
|
||||||
import orderAutomat from './orderAutomat';
|
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', () => {
|
addTrackedListener(es, 'open', () => {
|
||||||
sseAutomat.setState({ status: 'connected' });
|
sseAutomat.setState({ status: 'connected' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
parseNum,
|
parseNum,
|
||||||
} from './manifestConfig';
|
} from './manifestConfig';
|
||||||
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
|
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
|
||||||
|
import { getManifestDraftAutomat } from '../../automats/manifestDraftAutomat';
|
||||||
|
|
||||||
class ManifestForm extends PureComponent {
|
class ManifestForm extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -42,17 +43,20 @@ class ManifestForm extends PureComponent {
|
|||||||
? supplierAutomat.state.status === 'ready'
|
? supplierAutomat.state.status === 'ready'
|
||||||
: entityAutomat.state.status === 'ready';
|
: entityAutomat.state.status === 'ready';
|
||||||
|
|
||||||
|
this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null;
|
||||||
|
const initialDraft = this.draftAutomat?.state;
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
|
status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
|
||||||
error: null,
|
error: null,
|
||||||
parties: initialParties,
|
parties: initialParties,
|
||||||
partiesLoading: !partiesReady,
|
partiesLoading: !partiesReady,
|
||||||
date: '',
|
date: initialDraft?.date || '',
|
||||||
partyId: null,
|
partyId: initialDraft?.partyId ?? null,
|
||||||
number: '',
|
number: initialDraft?.number || '',
|
||||||
externalNumber: '',
|
externalNumber: initialDraft?.externalNumber || '',
|
||||||
lines: [emptyLine()],
|
lines: initialDraft?.lines?.length > 0 ? initialDraft.lines : [emptyLine()],
|
||||||
attachments: [],
|
attachments: initialDraft?.attachments || [],
|
||||||
saving: false,
|
saving: false,
|
||||||
pdfUploading: false,
|
pdfUploading: false,
|
||||||
pdfDialogOpen: false,
|
pdfDialogOpen: false,
|
||||||
@@ -131,19 +135,48 @@ class ManifestForm extends PureComponent {
|
|||||||
} else {
|
} else {
|
||||||
void this.loadPartiesOnly();
|
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 (
|
if (
|
||||||
this.props.mode === 'edit' &&
|
this.props.mode === 'edit' &&
|
||||||
prevProps.docId !== this.props.docId
|
prevProps.docId !== this.props.docId
|
||||||
) {
|
) {
|
||||||
void this.loadEdit();
|
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() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
this._cancelled = true;
|
||||||
|
this.unsubDraft?.();
|
||||||
this.unsubParties?.();
|
this.unsubParties?.();
|
||||||
this.unsubCompany?.();
|
this.unsubCompany?.();
|
||||||
if (this._parseAbortController) {
|
if (this._parseAbortController) {
|
||||||
@@ -687,6 +720,7 @@ class ManifestForm extends PureComponent {
|
|||||||
requireObject(data, cfg.postPathLabel);
|
requireObject(data, cfg.postPathLabel);
|
||||||
requireObject(data[cfg.docField], cfg.docField);
|
requireObject(data[cfg.docField], cfg.docField);
|
||||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||||
|
this.draftAutomat?.actions?.reset();
|
||||||
await cfg.listAutomat.actions.reload();
|
await cfg.listAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
||||||
|
|||||||
567
src/components/settings/BackupSettingsPanel.jsx
Normal file
567
src/components/settings/BackupSettingsPanel.jsx
Normal file
@@ -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 (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 8, gap: 2 }}>
|
||||||
|
<CircularProgress size={36} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Backup-Status wird geladen...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed' && !backupStatus) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2} sx={{ py: 2 }}>
|
||||||
|
<Alert severity="error">
|
||||||
|
{error || 'Fehler beim Laden des Backup-Status.'}
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={this.handleRefresh}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Header Title & Actions */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<BackupOutlinedIcon sx={{ color: PRIMARY }} />
|
||||||
|
Datensicherung & Backups
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
|
Automatische Sicherung von Datenbank, virtuellen Dateisystemen (VFS) und Dokumentenspeicher.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={this.handleRefresh}
|
||||||
|
disabled={isExecuting}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayArrowOutlinedIcon />}
|
||||||
|
onClick={this.handleRunBackup}
|
||||||
|
disabled={isExecuting}
|
||||||
|
sx={{ bgcolor: PRIMARY, '&:hover': { bgcolor: CHROMA.PETROL.dark } }}
|
||||||
|
>
|
||||||
|
{isExecuting ? 'Sicherung läuft...' : 'Jetzt sichern'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status Badges Bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: SURFACE_BASE,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={backupStatus?.enabled ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
|
||||||
|
label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
|
||||||
|
color={backupStatus?.enabled ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
{isExecuting && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<CircularProgress size={12} color="inherit" />}
|
||||||
|
label="Backup wird ausgeführt..."
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<StorageIcon />}
|
||||||
|
label={`Provider: ${backupStatus?.dbProvider?.toUpperCase() || 'DATENBANK'}`}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
{backupStatus?.backupDir && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<FolderOutlinedIcon />}
|
||||||
|
label={`Verzeichnis: ${backupStatus.backupDir}`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ maxWidth: 360 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* MSSQL Warning if present */}
|
||||||
|
{backupStatus?.mssqlWarning && (
|
||||||
|
<Alert severity="warning" icon={<WarningAmberIcon />}>
|
||||||
|
MSSQL-Hinweis: Datenbank-Backups erfordern Schreibberechtigungen des SQL-Server-Dienstes auf das Backup-Verzeichnis oder werden getrennt verwaltet.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview KPI Cards */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Hourly KPI */}
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} textTransform="uppercase">
|
||||||
|
Stündliche Sicherungen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
|
||||||
|
{counts.hourly}{' '}
|
||||||
|
<Typography component="span" variant="body2" color="text.secondary">
|
||||||
|
/ {retention.hourlyMax} max
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Zuletzt: {formatDate(backupStatus?.latestHourly?.timestamp || hourlyList[0]?.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Größe: {formatBytes(backupStatus?.latestHourly?.totalSizeBytes || hourlyList[0]?.totalSizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Daily KPI */}
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} textTransform="uppercase">
|
||||||
|
Tägliche Sicherungen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
|
||||||
|
{counts.daily}{' '}
|
||||||
|
<Typography component="span" variant="body2" color="text.secondary">
|
||||||
|
/ {retention.dailyMax} max
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Zuletzt: {formatDate(backupStatus?.latestDaily?.timestamp || dailyList[0]?.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Größe: {formatBytes(backupStatus?.latestDaily?.totalSizeBytes || dailyList[0]?.totalSizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Monthly KPI */}
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} textTransform="uppercase">
|
||||||
|
Monatliche Sicherungen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
|
||||||
|
{counts.monthly}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Zuletzt: {formatDate(backupStatus?.latestMonthly?.timestamp || monthlyList[0]?.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Größe: {formatBytes(backupStatus?.latestMonthly?.totalSizeBytes || monthlyList[0]?.totalSizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Retention Policy Note */}
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
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.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Backups Table Section with Tabs */}
|
||||||
|
<Box sx={{ border: `1px solid ${BORDER}`, borderRadius: 2, bgcolor: WHITE, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ borderBottom: `1px solid ${BORDER}`, bgcolor: SURFACE_RAISED, px: 2 }}>
|
||||||
|
<Tabs
|
||||||
|
value={tierTab}
|
||||||
|
onChange={this.handleTierChange}
|
||||||
|
textColor="primary"
|
||||||
|
indicatorColor="primary"
|
||||||
|
sx={{ minHeight: 44 }}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="hourly"
|
||||||
|
label={`Stündlich (${counts.hourly})`}
|
||||||
|
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="daily"
|
||||||
|
label={`Täglich (${counts.daily})`}
|
||||||
|
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="monthly"
|
||||||
|
label={`Monatlich (${counts.monthly})`}
|
||||||
|
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{activeList.length === 0 ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 1 }}>
|
||||||
|
<CloudQueueOutlinedIcon sx={{ fontSize: 40, color: DARK_1 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Keine Sicherungen in dieser Kategorie vorhanden.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer sx={{ maxHeight: 360 }}>
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: 40 }} />
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Zeitpunkt</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Archivname</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Größe</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Typ</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{activeList.map((item) => {
|
||||||
|
const isExpanded = expandedItemName === item.name;
|
||||||
|
const manifest = item.manifest;
|
||||||
|
const isManual = Boolean(manifest?.manual);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackupTableRow
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isManual={isManual}
|
||||||
|
manifest={manifest}
|
||||||
|
onToggleExpand={() => this.handleToggleExpand(item.name)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupTableRow({ item, isExpanded, isManual, manifest, onToggleExpand }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
hover
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: isExpanded ? LIGHT_3 : 'inherit',
|
||||||
|
'& > *': { borderBottom: isExpanded ? 'none !important' : undefined },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ py: 1 }}>
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onToggleExpand(); }}>
|
||||||
|
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1, fontWeight: 500 }}>
|
||||||
|
{formatDate(item.timestamp)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1, color: DARK_2, fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{item.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1, fontWeight: 600 }}>
|
||||||
|
{formatBytes(item.totalSizeBytes)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={isManual ? 'Manuell' : 'Automatisch'}
|
||||||
|
color={isManual ? 'primary' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 22, fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow sx={{ bgcolor: LIGHT_3 }}>
|
||||||
|
<TableCell colSpan={5} sx={{ py: 0, px: 3 }}>
|
||||||
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ py: 2 }}>
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.secondary" textTransform="uppercase">
|
||||||
|
Backup-Details & Manifest
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
|
||||||
|
gap: 1.5,
|
||||||
|
mt: 1,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: WHITE,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Datenbank
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest?.files?.database
|
||||||
|
? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})`
|
||||||
|
: '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
VFS (Dateien & Belege)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Dokumentenspeicher (Storage)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{manifest?.durationMs != null && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Dauer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{formatDuration(manifest.durationMs)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{manifest?.dbProvider && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Datenbank-Provider
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest.dbProvider.toUpperCase()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box sx={{ gridColumn: { sm: 'span 3' } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Speicherpfad
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: DARK_2, wordBreak: 'break-all' }}>
|
||||||
|
{item.path}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ import SettingsKeysPanel from './SettingsKeysPanel';
|
|||||||
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
||||||
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
||||||
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
||||||
|
import BackupSettingsPanel from './BackupSettingsPanel';
|
||||||
import SecurityIcon from '@mui/icons-material/Security';
|
import SecurityIcon from '@mui/icons-material/Security';
|
||||||
|
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
|
||||||
import { SURFACE_BASE } from '../../colors';
|
import { SURFACE_BASE } from '../../colors';
|
||||||
|
|
||||||
const TAB_KEYS = {
|
const TAB_KEYS = {
|
||||||
@@ -29,6 +31,7 @@ const TAB_KEYS = {
|
|||||||
numbers: 'numbers',
|
numbers: 'numbers',
|
||||||
printers: 'printers',
|
printers: 'printers',
|
||||||
importExport: 'importExport',
|
importExport: 'importExport',
|
||||||
|
backup: 'backup',
|
||||||
posterminal: 'posterminal',
|
posterminal: 'posterminal',
|
||||||
security: 'security',
|
security: 'security',
|
||||||
};
|
};
|
||||||
@@ -39,6 +42,7 @@ const TABS = [
|
|||||||
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
||||||
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
||||||
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
||||||
|
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
|
||||||
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
||||||
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
|
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export { default as NumberSettingsPanel } from './NumberSettingsPanel';
|
|||||||
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
|
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
|
||||||
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
|
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
|
||||||
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
|
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
|
||||||
|
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
|
||||||
|
export { default as BackupSettingsPanel } from './BackupSettingsPanel';
|
||||||
|
|||||||
Reference in New Issue
Block a user