This commit is contained in:
seb
2026-10-07 11:40:55 +02:00
parent a2c18841c1
commit 22115cb583
6 changed files with 446 additions and 47 deletions

View File

@@ -60,7 +60,7 @@ import authAutomat from './authAutomat';
/** Selectable model presets; the first entry is the default for new conversations. */ /** Selectable model presets; the first entry is the default for new conversations. */
export const PRESET_MODELS = [ 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; const DEFAULT_MODEL = PRESET_MODELS[0].value;

View File

@@ -4,6 +4,7 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import InputBase from '@mui/material/InputBase';
import TextInput from '../TextInput'; import TextInput from '../TextInput';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select'; import Select from '@mui/material/Select';
@@ -18,8 +19,15 @@ import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Badge from '@mui/material/Badge'; import Badge from '@mui/material/Badge';
import Divider from '@mui/material/Divider';
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded'; 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 chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat'; import userSettingsAutomat from '../../automats/userSettingsAutomat';
import ChatFlowList from './ChatFlowList'; import ChatFlowList from './ChatFlowList';
@@ -62,6 +70,10 @@ export default class ChatDrawer extends PureComponent {
attachmentError: null, attachmentError: null,
drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH, drawerWidth: settings?.chatDrawerWidth || DEFAULT_DRAWER_WIDTH,
isResizing: false, isResizing: false,
showSettingsBar: false,
modelslugflag: 'floor',
modelreasoning: 'medium',
modelslug: 'openai/gpt-6-luna',
}; };
this.inputRef = React.createRef(); this.inputRef = React.createRef();
this.fileInputRef = React.createRef(); this.fileInputRef = React.createRef();
@@ -79,6 +91,7 @@ export default class ChatDrawer extends PureComponent {
if (this.state.isOpen) { if (this.state.isOpen) {
this.focusInput(150); this.focusInput(150);
} }
void this.loadModelSettings();
this.unsub = chatAutomat.subscribe(this, (state) => { this.unsub = chatAutomat.subscribe(this, (state) => {
const wasOpen = this.state.isOpen; const wasOpen = this.state.isOpen;
const willOpen = 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); getPendingAskUser = () => selectPendingAskUser(this.state);
renderHeader() { renderHeader() {
@@ -345,9 +423,12 @@ export default class ChatDrawer extends PureComponent {
conversations, conversations,
selectedConversationId, selectedConversationId,
loadingConversations, loadingConversations,
showSettingsBar,
modelslugflag,
modelreasoning,
modelslug,
} = this.state; } = this.state;
return ( return (
<Box <Box
sx={{ sx={{
@@ -360,15 +441,261 @@ export default class ChatDrawer extends PureComponent {
gap: 1.5, gap: 1.5,
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <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>
{/* 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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AutoAwesomeRoundedIcon sx={{ fontSize: 22, color: 'primary.main' }} /> <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}> <Typography variant="subtitle1" fontWeight={700}>
KI-Assistent KI-Assistent
</Typography> </Typography>
</Box> </Box>
)}
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen"> <IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ ml: 1, flexShrink: 0 }}>
<CloseRoundedIcon fontSize="small" /> <CloseRoundedIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>

View File

@@ -3,6 +3,7 @@ import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table'; import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody'; import TableBody from '@mui/material/TableBody';
@@ -28,7 +29,7 @@ const SETTABLE_KEYS = [
'keys.tavily', 'keys.tavily',
'keys.openrouter', 'keys.openrouter',
'keys.serper', 'keys.serper',
'default-model', 'modelslugimage',
'systemprompt-prefix', 'systemprompt-prefix',
'systemprompt-suffix', 'systemprompt-suffix',
]; ];
@@ -38,11 +39,17 @@ const KEY_LABELS = {
'keys.tavily': 'Tavily API-Key', 'keys.tavily': 'Tavily API-Key',
'keys.openrouter': 'OpenRouter API-Key', 'keys.openrouter': 'OpenRouter API-Key',
'keys.serper': 'Serper API-Key', 'keys.serper': 'Serper API-Key',
'default-model': 'Modellslug', 'modelslugimage': 'Modellslug Bild',
'systemprompt-prefix': 'Prä Prompt', 'systemprompt-prefix': 'Prä Prompt',
'systemprompt-suffix': 'Post Prompt', 'systemprompt-suffix': 'Post Prompt',
}; };
export const DEFAULT_SETTINGS = {
modelslugimage: 'meta/muse-image',
};
const ENUM_OPTIONS = {};
const MULTILINE_KEYS = new Set([ const MULTILINE_KEYS = new Set([
'systemprompt-prefix', 'systemprompt-prefix',
'systemprompt-suffix', 'systemprompt-suffix',
@@ -80,12 +87,17 @@ export default class SettingsKeysPanel extends Component {
try { try {
const data = await settingApi.list(); const data = await settingApi.list();
requireObject(data, 'GET /api/company/setting'); 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( const drafts = Object.fromEntries(
settings.map((s) => [ settings.map((s) => [
s.key, s.key,
{ {
value: s.value ?? '', value: s.value ?? (DEFAULT_SETTINGS[s.key] ?? ''),
busy: false, busy: false,
saveError: null, saveError: null,
}, },
@@ -117,14 +129,8 @@ export default class SettingsKeysPanel extends Component {
const row = this.state.settings.find((s) => s.key === key); const row = this.state.settings.find((s) => s.key === key);
if (!row) return; if (!row) return;
const body = {}; const valToSave = draft.value !== '' ? draft.value : (DEFAULT_SETTINGS[key] ?? '');
if (draft.value !== (row.value ?? '')) { const body = { value: valToSave };
body.value = draft.value;
}
if (Object.keys(body).length === 0) {
return;
}
this.setState((prev) => ({ this.setState((prev) => ({
drafts: { drafts: {
@@ -146,7 +152,7 @@ export default class SettingsKeysPanel extends Component {
drafts: { drafts: {
...this.state.drafts, ...this.state.drafts,
[key]: { [key]: {
value: updated.value ?? '', value: updated.value ?? valToSave,
busy: false, busy: false,
saveError: null, saveError: null,
}, },
@@ -198,6 +204,7 @@ export default class SettingsKeysPanel extends Component {
const draft = row ? drafts[row.key] : null; const draft = row ? drafts[row.key] : null;
if (!row || !draft) return null; if (!row || !draft) return null;
const isMultiline = MULTILINE_KEYS.has(row.key); const isMultiline = MULTILINE_KEYS.has(row.key);
const enumOpts = ENUM_OPTIONS[row.key];
return ( return (
<TableRow key={row.key} hover> <TableRow key={row.key} hover>
<TableCell <TableCell
@@ -211,12 +218,35 @@ export default class SettingsKeysPanel extends Component {
<Typography variant="body2">{keyLabel(row.key)}</Typography> <Typography variant="body2">{keyLabel(row.key)}</Typography>
</TableCell> </TableCell>
<TableCell sx={{ verticalAlign: 'top' }}> <TableCell sx={{ verticalAlign: 'top' }}>
{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 <TextField
size="small" size="small"
fullWidth fullWidth
multiline={isMultiline} multiline={isMultiline}
minRows={isMultiline ? 2 : undefined} minRows={isMultiline ? 2 : undefined}
maxRows={isMultiline ? 6 : undefined} maxRows={isMultiline ? 6 : undefined}
placeholder={DEFAULT_SETTINGS[row.key] ?? undefined}
value={draft.value} value={draft.value}
disabled={draft.busy} disabled={draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)} onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
@@ -227,7 +257,10 @@ export default class SettingsKeysPanel extends Component {
} }
}} }}
autoComplete="off" autoComplete="off"
slotProps={{ htmlInput: { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' } }}
inputProps={{ spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' }}
/> />
)}
</TableCell> </TableCell>
<TableCell align="right" sx={{ verticalAlign: 'top', pt: 1, width: 60 }}> <TableCell align="right" sx={{ verticalAlign: 'top', pt: 1, width: 60 }}>
<Stack <Stack

View File

@@ -4,7 +4,8 @@ import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase'; import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles'; 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 { articleSku } from '../article/articleRoute';
import { import {
getThumbHashDataUrl, getThumbHashDataUrl,
@@ -289,6 +290,7 @@ export class ArticleTile extends Tile {
if (!article) return null; if (!article) return null;
const sku = articleSku(article); const sku = articleSku(article);
const brand = article.brand; const brand = article.brand;
const grossPrice = getArticleGross(article, companyAutomat.state.current?.taxrate);
return ( return (
<> <>
{brand?.name ? ( {brand?.name ? (
@@ -310,9 +312,9 @@ export class ArticleTile extends Tile {
{sku} {sku}
</Typography> </Typography>
) : null} ) : null}
{article.net != null && ( {grossPrice != null && (
<Typography variant="caption" fontWeight={600}> <Typography variant="caption" fontWeight={600}>
{formatPrice(article.net)} {formatPrice(grossPrice)}
</Typography> </Typography>
)} )}
</> </>
@@ -353,6 +355,7 @@ export class ArticleTile extends Tile {
const hasPhoto = this.hasPhoto(); const hasPhoto = this.hasPhoto();
const { article, label, sx, disabled } = this.props; const { article, label, sx, disabled } = this.props;
const sku = articleSku(article); const sku = articleSku(article);
const grossPrice = getArticleGross(article, companyAutomat.state.current?.taxrate);
return ( return (
<ButtonBase <ButtonBase
@@ -451,7 +454,7 @@ export class ArticleTile extends Tile {
{sku} {sku}
</Typography> </Typography>
) : null} ) : null}
{article.net != null && ( {grossPrice != null && (
<Typography <Typography
variant="caption" variant="caption"
fontWeight={600} fontWeight={600}
@@ -461,7 +464,7 @@ export class ArticleTile extends Tile {
textShadow: TEXT_GLOW_DARK, textShadow: TEXT_GLOW_DARK,
}} }}
> >
{formatPrice(article.net)} {formatPrice(grossPrice)}
</Typography> </Typography>
)} )}
</Box> </Box>

View File

@@ -20,7 +20,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import articleSearchAutomat from '../automats/articleSearchAutomat'; import articleSearchAutomat from '../automats/articleSearchAutomat';
import BackendError from '../components/BackendError'; import BackendError from '../components/BackendError';
import { formatPrice } from '../utils/price'; import { formatPrice, getArticleGross } from '../utils/price';
import { import {
buildSearchUrl, buildSearchUrl,
queryFromParams, queryFromParams,
@@ -46,6 +46,7 @@ import {
setLastCategoryId, setLastCategoryId,
navigationAutomat, navigationAutomat,
categoryAutomat, categoryAutomat,
companyAutomat,
} from '../automats'; } from '../automats';
import { BrandLabel } from '../components/brand'; import { BrandLabel } from '../components/brand';
import { SCRIM_LABEL } from '../colors'; import { SCRIM_LABEL } from '../colors';
@@ -586,7 +587,7 @@ class ArtikelSearchPage extends Component {
const isParent = Boolean(row.isParent || row.is_parent); const isParent = Boolean(row.isParent || row.is_parent);
const childCount = Number(row.childCount) || 0; const childCount = Number(row.childCount) || 0;
const showChildBadge = isParent || childCount > 0; const showChildBadge = isParent || childCount > 0;
const price = row.price ?? row.net ?? row.vknetto; const price = getArticleGross(row, companyAutomat.state.current?.taxrate);
return ( return (
<ListItemButton <ListItemButton
key={row.id} key={row.id}

View File

@@ -52,3 +52,38 @@ export function netFromGross(gross, taxRate) {
if (factor <= 0) return null; if (factor <= 0) return null;
return Math.round((gross / factor) * 10000) / 10000; 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;
}