Files
dashfront/src/components/settings/SettingsKeysPanel.jsx
2026-11-03 06:46:36 +01:00

304 lines
10 KiB
JavaScript

import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
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 TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import { setting as settingApi } from '../../api';
import companyAutomat from '../../automats/companyAutomat';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
/**
* Settings that may be set via PUT /api/company/setting/{key}.
* Only these keys are accepted by the backend.
*/
const SETTABLE_KEYS = [
'ui.theme',
'keys.tavily',
'keys.openrouter',
'keys.serper',
'modelslugimage',
'systemprompt-prefix',
'systemprompt-suffix',
];
const KEY_LABELS = {
'ui.theme': 'UI-Theme',
'keys.tavily': 'Tavily API-Key',
'keys.openrouter': 'OpenRouter API-Key',
'keys.serper': 'Serper API-Key',
'modelslugimage': 'Modellslug Bild',
'systemprompt-prefix': 'Prä Prompt',
'systemprompt-suffix': 'Post Prompt',
};
export const DEFAULT_SETTINGS = {
modelslugimage: 'meta/muse-image',
};
const ENUM_OPTIONS = {};
const MULTILINE_KEYS = new Set([
'systemprompt-prefix',
'systemprompt-suffix',
]);
const EXCLUDED_KEYS = new Set([
'labelprinter.addr',
'labelprinter.type',
]);
function keyLabel(key) {
return KEY_LABELS[key] ?? key;
}
export default class SettingsKeysPanel extends Component {
constructor(props) {
super(props);
this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: 'loading',
error: null,
settings: [],
// key → { value, busy, saveError }
drafts: {},
};
this.handleRetry = this.handleRetry.bind(this);
this.handleSave = this.handleSave.bind(this);
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
void this.load();
}
async load() {
this.setState({ status: 'loading', error: null });
try {
const data = await settingApi.list();
requireObject(data, 'GET /api/company/setting');
const fetched = requireArray(data.settings, 'settings');
const byKey = new Map(fetched.map((s) => [s.key, s]));
const settings = SETTABLE_KEYS.map((key) => {
if (byKey.has(key)) return byKey.get(key);
return { key, value: DEFAULT_SETTINGS[key] ?? null };
});
const drafts = Object.fromEntries(
settings.map((s) => [
s.key,
{
value: s.value ?? (DEFAULT_SETTINGS[s.key] ?? ''),
busy: false,
saveError: null,
},
]),
);
this.setState({ status: 'ready', settings, drafts });
} catch (err) {
notifyError(err);
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.load();
}
handleChange(key, field, value) {
this.setState((prev) => ({
drafts: {
...prev.drafts,
[key]: { ...prev.drafts[key], [field]: value, saveError: null },
},
}));
}
async handleSave(key) {
if (!this.state.isAdmin) return;
const draft = this.state.drafts[key];
if (!draft || draft.busy) return;
const row = this.state.settings.find((s) => s.key === key);
if (!row) return;
const valToSave = draft.value !== '' ? draft.value : (DEFAULT_SETTINGS[key] ?? '');
const body = { value: valToSave };
this.setState((prev) => ({
drafts: {
...prev.drafts,
[key]: { ...prev.drafts[key], busy: true, saveError: null },
},
}));
try {
const data = await settingApi.upsert(key, body);
requireObject(data, 'PUT /api/company/setting/:key');
requireObject(data.setting, 'setting');
const updated = requireDefined(data.setting, 'setting');
const settings = this.state.settings.map((s) =>
s.key === key ? { ...s, ...body } : s,
);
this.setState({
settings,
drafts: {
...this.state.drafts,
[key]: {
value: updated.value ?? valToSave,
busy: false,
saveError: null,
},
},
});
enqueueSnackbar('Einstellung gespeichert.', { variant: 'success' });
} catch (err) {
if (this._unmounted) return;
this.setState((prev) => ({
drafts: {
...prev.drafts,
[key]: { ...prev.drafts[key], busy: false, saveError: backendErrorFrom(err) },
},
}));
}
}
componentWillUnmount() {
this._unmounted = true;
this.unsub?.();
}
render() {
const { isAdmin, status, error, settings, drafts } = this.state;
if (status === 'loading') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
if (settings.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
Keine Einstellungen.
</Typography>
);
}
return (
<TableContainer>
<Table size="small">
<TableBody>
{SETTABLE_KEYS.filter((k) => !EXCLUDED_KEYS.has(k)).map((key) => {
const row = settings.find((s) => s.key === key);
const draft = row ? drafts[row.key] : null;
if (!row || !draft) return null;
const isMultiline = MULTILINE_KEYS.has(row.key);
const enumOpts = ENUM_OPTIONS[row.key];
return (
<TableRow key={row.key} hover>
<TableCell
sx={{
width: 180,
verticalAlign: 'top',
pt: 1.5,
whiteSpace: 'nowrap',
}}
>
<Typography variant="body2">{keyLabel(row.key)}</Typography>
</TableCell>
<TableCell sx={{ verticalAlign: 'top' }}>
{enumOpts ? (
<TextField
select
size="small"
fullWidth
value={draft.value !== undefined && draft.value !== null ? draft.value : (DEFAULT_SETTINGS[row.key] ?? '')}
disabled={!isAdmin || draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
slotProps={{ htmlInput: { spellCheck: false } }}
>
{enumOpts.map((opt) => {
const val = typeof opt === 'object' ? opt.value : opt;
const lbl = typeof opt === 'object' ? opt.label : opt;
return (
<MenuItem key={val} value={val}>
{lbl}
</MenuItem>
);
})}
</TextField>
) : (
<TextField
size="small"
fullWidth
multiline={isMultiline}
minRows={isMultiline ? 2 : undefined}
maxRows={isMultiline ? 6 : undefined}
placeholder={DEFAULT_SETTINGS[row.key] ?? undefined}
value={draft.value}
disabled={!isAdmin || draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
onKeyDown={(e) => {
if (!isMultiline && e.key === 'Enter') {
e.preventDefault();
void this.handleSave(row.key);
}
}}
autoComplete="off"
slotProps={{ htmlInput: { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' } }}
/>
)}
</TableCell>
<TableCell align="right" sx={{ verticalAlign: 'top', pt: 1, width: 60 }}>
<Stack
direction="row"
spacing={0}
sx={{ justifyContent: 'flex-end', alignItems: 'center' }}
>
{draft.saveError ? (
<Box sx={{ maxWidth: 220, mr: 1 }}>
<Alert severity="error" sx={{ py: 0 }}>
{typeof draft.saveError === 'string'
? draft.saveError
: draft.saveError.message}
</Alert>
</Box>
) : null}
<IconButton
size="small"
aria-label="Speichern"
color="primary"
disabled={!isAdmin || draft.busy}
onClick={() => void this.handleSave(row.key)}
>
<SaveOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
}