u
This commit is contained in:
@@ -60,7 +60,7 @@ import authAutomat from './authAutomat';
|
||||
|
||||
/** Selectable model presets; the first entry is the default for new conversations. */
|
||||
export const PRESET_MODELS = [
|
||||
{ value: 'stealth/space-bunny-alpha', label: 'Space Bunny Alpha' },
|
||||
{ value: 'openai/gpt-6-luna', label: 'GPT-6 Luna (Standard)' },
|
||||
];
|
||||
const DEFAULT_MODEL = PRESET_MODELS[0].value;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Button from '@mui/material/Button';
|
||||
import InputBase from '@mui/material/InputBase';
|
||||
import TextInput from '../TextInput';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
@@ -18,8 +19,15 @@ import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
||||
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
|
||||
import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
|
||||
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
|
||||
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
|
||||
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
|
||||
|
||||
import { setting as settingApi } from '../../api';
|
||||
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
||||
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
||||
import ChatFlowList from './ChatFlowList';
|
||||
@@ -62,6 +70,10 @@ export default class ChatDrawer extends PureComponent {
|
||||
attachmentError: null,
|
||||
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
|
||||
isResizing: false,
|
||||
showSettingsBar: false,
|
||||
modelslugflag: 'floor',
|
||||
modelreasoning: 'medium',
|
||||
modelslug: 'openai/gpt-6-luna',
|
||||
};
|
||||
this.inputRef = React.createRef();
|
||||
this.fileInputRef = React.createRef();
|
||||
@@ -79,6 +91,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
if (this.state.isOpen) {
|
||||
this.focusInput(150);
|
||||
}
|
||||
void this.loadModelSettings();
|
||||
this.unsub = chatAutomat.subscribe(this, (state) => {
|
||||
const wasOpen = this.state.isOpen;
|
||||
const willOpen = state.isOpen;
|
||||
@@ -338,6 +351,71 @@ export default class ChatDrawer extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
loadModelSettings = async () => {
|
||||
try {
|
||||
const data = await settingApi.list();
|
||||
const settings = data?.settings || [];
|
||||
const flagSetting = settings.find((s) => s.key === 'modelslugflag');
|
||||
const reasoningSetting = settings.find((s) => s.key === 'modelreasoning');
|
||||
const slugSetting = settings.find((s) => s.key === 'modelslug');
|
||||
this.setState({
|
||||
modelslugflag: flagSetting?.value ?? 'floor',
|
||||
modelreasoning: reasoningSetting?.value ?? 'medium',
|
||||
modelslug: slugSetting?.value ?? 'openai/gpt-6-luna',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ChatDrawer] Failed to load model settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
handleToggleSettingsBar = () => {
|
||||
this.setState((prev) => ({ showSettingsBar: !prev.showSettingsBar }));
|
||||
};
|
||||
|
||||
handleSetFlag = async (nextFlag) => {
|
||||
if (this.state.modelslugflag === nextFlag) return;
|
||||
const current = this.state.modelslugflag;
|
||||
this.setState({ modelslugflag: nextFlag });
|
||||
try {
|
||||
await settingApi.upsert('modelslugflag', { value: nextFlag });
|
||||
} catch (err) {
|
||||
console.error('[ChatDrawer] Failed to save modelslugflag:', err);
|
||||
this.setState({ modelslugflag: current });
|
||||
}
|
||||
};
|
||||
|
||||
handleSetReasoning = async (nextReasoning) => {
|
||||
if (this.state.modelreasoning === nextReasoning) return;
|
||||
const current = this.state.modelreasoning;
|
||||
this.setState({ modelreasoning: nextReasoning });
|
||||
try {
|
||||
await settingApi.upsert('modelreasoning', { value: nextReasoning });
|
||||
} catch (err) {
|
||||
console.error('[ChatDrawer] Failed to save modelreasoning:', err);
|
||||
this.setState({ modelreasoning: current });
|
||||
}
|
||||
};
|
||||
|
||||
handleModelslugChange = (e) => {
|
||||
this.setState({ modelslug: e.target.value });
|
||||
};
|
||||
|
||||
handleModelslugBlur = async () => {
|
||||
const { modelslug } = this.state;
|
||||
const trimmed = (modelslug || '').trim();
|
||||
try {
|
||||
await settingApi.upsert('modelslug', { value: trimmed });
|
||||
} catch (err) {
|
||||
console.error('[ChatDrawer] Failed to save modelslug:', err);
|
||||
}
|
||||
};
|
||||
|
||||
handleModelslugKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
getPendingAskUser = () => selectPendingAskUser(this.state);
|
||||
|
||||
renderHeader() {
|
||||
@@ -345,9 +423,12 @@ export default class ChatDrawer extends PureComponent {
|
||||
conversations,
|
||||
selectedConversationId,
|
||||
loadingConversations,
|
||||
showSettingsBar,
|
||||
modelslugflag,
|
||||
modelreasoning,
|
||||
modelslug,
|
||||
} = this.state;
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -360,15 +441,261 @@ export default class ChatDrawer extends PureComponent {
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 22, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
KI-Assistent
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: 34 }}>
|
||||
{showSettingsBar ? (
|
||||
/* Settings Bar Mode: Title goes away and reveals toggles */
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleToggleSettingsBar}
|
||||
aria-label="Einstellungen schließen"
|
||||
sx={{
|
||||
p: 0.5,
|
||||
color: 'primary.main',
|
||||
bgcolor: 'action.selected',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<TuneRoundedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen">
|
||||
{/* Group 1: Speed (floor, walk/standard "", run/nitro) */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.3,
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
|
||||
p: 0.3,
|
||||
borderRadius: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleSetFlag('floor')}
|
||||
aria-label="Floor"
|
||||
sx={{
|
||||
p: 0.3,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: modelslugflag === 'floor' ? 'primary.main' : 'transparent',
|
||||
color: modelslugflag === 'floor' ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: modelslugflag === 'floor' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ElderlyRoundedIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleSetFlag('')}
|
||||
aria-label="Normal"
|
||||
sx={{
|
||||
p: 0.3,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: modelslugflag === '' ? 'primary.main' : 'transparent',
|
||||
color: modelslugflag === '' ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: modelslugflag === '' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DirectionsWalkRoundedIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleSetFlag('nitro')}
|
||||
aria-label="Nitro"
|
||||
sx={{
|
||||
p: 0.3,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: modelslugflag === 'nitro' ? 'primary.main' : 'transparent',
|
||||
color: modelslugflag === 'nitro' ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: modelslugflag === 'nitro' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DirectionsRunRoundedIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
|
||||
|
||||
{/* Group 2: Reasoning (none, low, medium, max) */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.3,
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
|
||||
p: 0.3,
|
||||
borderRadius: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* none toggle (crossed out brain) */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleSetReasoning('none')}
|
||||
aria-label="Reasoning None"
|
||||
sx={{
|
||||
p: 0.3,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: modelreasoning === 'none' ? 'primary.main' : 'transparent',
|
||||
color: modelreasoning === 'none' ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: modelreasoning === 'none' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PsychologyRoundedIcon sx={{ fontSize: 16 }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
width: '125%',
|
||||
height: '1.5px',
|
||||
bgcolor: modelreasoning === 'none' ? 'primary.contrastText' : 'error.main',
|
||||
transform: 'rotate(-45deg)',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</IconButton>
|
||||
|
||||
{/* low toggle (smallest brain) */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleSetReasoning('low')}
|
||||
aria-label="Reasoning Low"
|
||||
sx={{
|
||||
p: 0.3,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: modelreasoning === 'low' ? 'primary.main' : 'transparent',
|
||||
color: modelreasoning === 'low' ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: modelreasoning === 'low' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PsychologyRoundedIcon sx={{ fontSize: 13 }} />
|
||||
</IconButton>
|
||||
|
||||
{/* medium toggle (medium brain) */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleSetReasoning('medium')}
|
||||
aria-label="Reasoning Medium"
|
||||
sx={{
|
||||
p: 0.3,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: modelreasoning === 'medium' ? 'primary.main' : 'transparent',
|
||||
color: modelreasoning === 'medium' ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: modelreasoning === 'medium' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PsychologyRoundedIcon sx={{ fontSize: 17 }} />
|
||||
</IconButton>
|
||||
|
||||
{/* max toggle (big brain) */}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => this.handleSetReasoning('high')}
|
||||
aria-label="Reasoning Max"
|
||||
sx={{
|
||||
p: 0.3,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: modelreasoning === 'high' ? 'primary.main' : 'transparent',
|
||||
color: modelreasoning === 'high' ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: modelreasoning === 'high' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PsychologyRoundedIcon sx={{ fontSize: 22 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
|
||||
|
||||
{/* modelslug input */}
|
||||
<InputBase
|
||||
value={modelslug || ''}
|
||||
onChange={this.handleModelslugChange}
|
||||
onBlur={this.handleModelslugBlur}
|
||||
onKeyDown={this.handleModelslugKeyDown}
|
||||
placeholder="Modellslug"
|
||||
inputProps={{
|
||||
spellCheck: false,
|
||||
autoCorrect: 'off',
|
||||
autoCapitalize: 'off',
|
||||
'aria-label': 'Modellslug',
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 80,
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: 'monospace',
|
||||
px: 0.8,
|
||||
py: 0.2,
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid transparent',
|
||||
'&:focus-within': {
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : '#fff'),
|
||||
},
|
||||
'& input': {
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
/* Normal Mode: settings icon on the left end, title visible */
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleToggleSettingsBar}
|
||||
aria-label="Modell-Einstellungen"
|
||||
sx={{
|
||||
p: 0.5,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'primary.main', bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<TuneRoundedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 20, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
KI-Assistent
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ ml: 1, flexShrink: 0 }}>
|
||||
<CloseRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -28,7 +29,7 @@ const SETTABLE_KEYS = [
|
||||
'keys.tavily',
|
||||
'keys.openrouter',
|
||||
'keys.serper',
|
||||
'default-model',
|
||||
'modelslugimage',
|
||||
'systemprompt-prefix',
|
||||
'systemprompt-suffix',
|
||||
];
|
||||
@@ -38,11 +39,17 @@ const KEY_LABELS = {
|
||||
'keys.tavily': 'Tavily API-Key',
|
||||
'keys.openrouter': 'OpenRouter API-Key',
|
||||
'keys.serper': 'Serper API-Key',
|
||||
'default-model': 'Modellslug',
|
||||
'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',
|
||||
@@ -80,12 +87,17 @@ export default class SettingsKeysPanel extends Component {
|
||||
try {
|
||||
const data = await settingApi.list();
|
||||
requireObject(data, 'GET /api/company/setting');
|
||||
const settings = requireArray(data.settings, 'settings');
|
||||
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 ?? '',
|
||||
value: s.value ?? (DEFAULT_SETTINGS[s.key] ?? ''),
|
||||
busy: false,
|
||||
saveError: null,
|
||||
},
|
||||
@@ -117,14 +129,8 @@ export default class SettingsKeysPanel extends Component {
|
||||
const row = this.state.settings.find((s) => s.key === key);
|
||||
if (!row) return;
|
||||
|
||||
const body = {};
|
||||
if (draft.value !== (row.value ?? '')) {
|
||||
body.value = draft.value;
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
return;
|
||||
}
|
||||
const valToSave = draft.value !== '' ? draft.value : (DEFAULT_SETTINGS[key] ?? '');
|
||||
const body = { value: valToSave };
|
||||
|
||||
this.setState((prev) => ({
|
||||
drafts: {
|
||||
@@ -146,7 +152,7 @@ export default class SettingsKeysPanel extends Component {
|
||||
drafts: {
|
||||
...this.state.drafts,
|
||||
[key]: {
|
||||
value: updated.value ?? '',
|
||||
value: updated.value ?? valToSave,
|
||||
busy: false,
|
||||
saveError: null,
|
||||
},
|
||||
@@ -198,6 +204,7 @@ export default class SettingsKeysPanel extends Component {
|
||||
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
|
||||
@@ -211,23 +218,49 @@ export default class SettingsKeysPanel extends Component {
|
||||
<Typography variant="body2">{keyLabel(row.key)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'top' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline={isMultiline}
|
||||
minRows={isMultiline ? 2 : undefined}
|
||||
maxRows={isMultiline ? 6 : undefined}
|
||||
value={draft.value}
|
||||
disabled={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"
|
||||
/>
|
||||
{enumOpts ? (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
value={draft.value !== undefined && draft.value !== null ? draft.value : (DEFAULT_SETTINGS[row.key] ?? '')}
|
||||
disabled={draft.busy}
|
||||
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
|
||||
slotProps={{ htmlInput: { spellCheck: false } }}
|
||||
inputProps={{ 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={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' } }}
|
||||
inputProps={{ spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ verticalAlign: 'top', pt: 1, width: 60 }}>
|
||||
<Stack
|
||||
|
||||
@@ -4,7 +4,8 @@ import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { formatPrice, getArticleGross } from '../../utils/price';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { articleSku } from '../article/articleRoute';
|
||||
import {
|
||||
getThumbHashDataUrl,
|
||||
@@ -289,6 +290,7 @@ export class ArticleTile extends Tile {
|
||||
if (!article) return null;
|
||||
const sku = articleSku(article);
|
||||
const brand = article.brand;
|
||||
const grossPrice = getArticleGross(article, companyAutomat.state.current?.taxrate);
|
||||
return (
|
||||
<>
|
||||
{brand?.name ? (
|
||||
@@ -310,9 +312,9 @@ export class ArticleTile extends Tile {
|
||||
{sku}
|
||||
</Typography>
|
||||
) : null}
|
||||
{article.net != null && (
|
||||
{grossPrice != null && (
|
||||
<Typography variant="caption" fontWeight={600}>
|
||||
{formatPrice(article.net)}
|
||||
{formatPrice(grossPrice)}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
@@ -353,6 +355,7 @@ export class ArticleTile extends Tile {
|
||||
const hasPhoto = this.hasPhoto();
|
||||
const { article, label, sx, disabled } = this.props;
|
||||
const sku = articleSku(article);
|
||||
const grossPrice = getArticleGross(article, companyAutomat.state.current?.taxrate);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
@@ -451,7 +454,7 @@ export class ArticleTile extends Tile {
|
||||
{sku}
|
||||
</Typography>
|
||||
) : null}
|
||||
{article.net != null && (
|
||||
{grossPrice != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
@@ -461,7 +464,7 @@ export class ArticleTile extends Tile {
|
||||
textShadow: TEXT_GLOW_DARK,
|
||||
}}
|
||||
>
|
||||
{formatPrice(article.net)}
|
||||
{formatPrice(grossPrice)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -20,7 +20,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import articleSearchAutomat from '../automats/articleSearchAutomat';
|
||||
import BackendError from '../components/BackendError';
|
||||
import { formatPrice } from '../utils/price';
|
||||
import { formatPrice, getArticleGross } from '../utils/price';
|
||||
import {
|
||||
buildSearchUrl,
|
||||
queryFromParams,
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
setLastCategoryId,
|
||||
navigationAutomat,
|
||||
categoryAutomat,
|
||||
companyAutomat,
|
||||
} from '../automats';
|
||||
import { BrandLabel } from '../components/brand';
|
||||
import { SCRIM_LABEL } from '../colors';
|
||||
@@ -586,7 +587,7 @@ class ArtikelSearchPage extends Component {
|
||||
const isParent = Boolean(row.isParent || row.is_parent);
|
||||
const childCount = Number(row.childCount) || 0;
|
||||
const showChildBadge = isParent || childCount > 0;
|
||||
const price = row.price ?? row.net ?? row.vknetto;
|
||||
const price = getArticleGross(row, companyAutomat.state.current?.taxrate);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={row.id}
|
||||
|
||||
@@ -52,3 +52,38 @@ export function netFromGross(gross, taxRate) {
|
||||
if (factor <= 0) return null;
|
||||
return Math.round((gross / factor) * 10000) / 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves or calculates the gross price (Bruttopreis) for an article.
|
||||
* Supports:
|
||||
* - Direct gross price (`article.gross`)
|
||||
* - Net price with tax rate (`article.net`, `article.taxRate`)
|
||||
* - Nested prices array (`article.prices`)
|
||||
*
|
||||
* @param {object|null} article
|
||||
* @param {number} [defaultTaxRate=0] Fallback tax rate if not present on article/price
|
||||
* @returns {number|null} Gross price rounded to 2 decimal places, or null
|
||||
*/
|
||||
export function getArticleGross(article, defaultTaxRate = 0) {
|
||||
if (!article) return null;
|
||||
if (article.gross != null && Number.isFinite(Number(article.gross))) {
|
||||
return Number(article.gross);
|
||||
}
|
||||
let net = article.net ?? article.price ?? article.vknetto;
|
||||
let taxRate = article.taxRate;
|
||||
if (net == null && Array.isArray(article.prices) && article.prices.length > 0) {
|
||||
const base = article.prices.find((p) => p.minmultiplier === 1) ?? article.prices[0];
|
||||
if (base?.gross != null && Number.isFinite(Number(base.gross))) {
|
||||
return Number(base.gross);
|
||||
}
|
||||
net = base?.net;
|
||||
if (taxRate == null) taxRate = base?.taxRate;
|
||||
}
|
||||
if (net == null || !Number.isFinite(Number(net))) return null;
|
||||
const rate = taxRate != null && Number.isFinite(Number(taxRate))
|
||||
? Number(taxRate)
|
||||
: Number(defaultTaxRate ?? 0);
|
||||
const gross = Number(net) * (1 + rate / 100);
|
||||
return Math.round((gross + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user