304 lines
10 KiB
JavaScript
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>
|
|
);
|
|
}
|
|
} |