This commit is contained in:
seb
2026-09-30 09:47:47 +02:00
parent d8f4727f32
commit 2f2906b8b5
11 changed files with 1479 additions and 162 deletions

View File

@@ -497,6 +497,22 @@ export class ApiClient {
'DELETE',
`/api/article/${enc(sku)}/children/${enc(childSku)}${deleteArticle ? '?deleteArticle=true' : ''}`
),
/** GET /api/article/{sku}/articlelist */
getArticlelist: (sku) =>
this.#json('GET', `/api/article/${enc(sku)}/articlelist`),
/** PUT /api/article/{sku}/articlelist */
setArticlelist: (sku, items) =>
this.#json('PUT', `/api/article/${enc(sku)}/articlelist`, {
body: Array.isArray(items) ? { items } : items,
}),
/** GET /api/article/{sku}/optionslist */
getOptionslist: (sku) =>
this.#json('GET', `/api/article/${enc(sku)}/optionslist`),
/** PUT /api/article/{sku}/optionslist */
setOptionslist: (sku, options) =>
this.#json('PUT', `/api/article/${enc(sku)}/optionslist`, {
body: Array.isArray(options) ? { options } : options,
}),
};
this.posterminal = {

View File

@@ -519,6 +519,42 @@ export const article = {
{ method: 'DELETE' }
);
},
/**
* GET /api/article/:sku/articlelist → { items: [...] }
*/
getArticlelist(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`);
},
/**
* PUT /api/article/:sku/articlelist
* Body: { items: [{ articleId, sku, count, sort }] }
*/
setArticlelist(sku, items) {
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`, {
method: 'PUT',
body: Array.isArray(items) ? { items } : items,
});
},
/**
* GET /api/article/:sku/optionslist → { options: [...] }
*/
getOptionslist(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`);
},
/**
* PUT /api/article/:sku/optionslist
* Body: { options: [{ articleId, sku, count, sort, mandated }] }
*/
setOptionslist(sku, options) {
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`, {
method: 'PUT',
body: Array.isArray(options) ? { options } : options,
});
},
};
/**
@@ -611,7 +647,7 @@ export const category = {
/**
* GET /api/category/:id/articles → { articles: [...] }
* Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, …
* Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, childCount, …
*/
getArticles(categoryId) {
return http(`/api/category/${categoryId}/articles`);

BIN
src/assets/quixpos-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,6 +1,8 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import GoogleIcon from '@mui/icons-material/Google';
import { enqueueSnackbar } from 'notistack';
import { notifyErrorMessage } from '../utils/notify';
@@ -15,6 +17,8 @@ import {
requestGoogleIdToken,
} from '../auth/googleSignIn';
import quixposLogo from '../assets/quixpos-logo.png';
class LoginPage extends PureComponent {
static contextType = StorageManagerContext;
@@ -93,17 +97,62 @@ class LoginPage extends PureComponent {
justifyContent: 'center',
minHeight: '100vh',
bgcolor: 'background.default',
p: 2,
}}
>
<Button
variant="contained"
size="large"
startIcon={<GoogleIcon />}
disabled={busy || loading || !ready}
onClick={this.handleLogin}
<Paper
elevation={3}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2.5,
px: 5,
py: 5,
maxWidth: 400,
width: '100%',
borderRadius: 3,
textAlign: 'center',
}}
>
{busy ? 'Signing in…' : 'Sign in with Google'}
</Button>
<Box
component="img"
src={quixposLogo}
alt="QuixPOS Logo"
sx={{
width: 96,
height: 96,
borderRadius: 3,
display: 'block',
}}
/>
<Box>
<Typography
variant="h5"
component="h1"
sx={{ fontWeight: 700, color: '#1a3533', letterSpacing: '-0.01em' }}
>
QuixPOS
</Typography>
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', fontWeight: 500, mt: 0.5 }}
>
Management Interface Login
</Typography>
</Box>
<Button
variant="contained"
size="large"
fullWidth
startIcon={<GoogleIcon />}
disabled={busy || loading || !ready}
onClick={this.handleLogin}
sx={{ mt: 1, py: 1.25, textTransform: 'none', fontWeight: 600 }}
>
{busy ? 'Anmeldung läuft…' : 'Mit Google anmelden'}
</Button>
</Paper>
</Box>
);
}

View File

@@ -79,8 +79,19 @@ const SETTINGS_TAB = {
help: 'Artikeleinstellungen, Einheiten, Steuersatz und Löschen',
};
function visibleTabs() {
return [...ALWAYS_TABS, SETTINGS_TAB];
function visibleTabs(article) {
const isFather = Boolean(article?.isFather);
const tabs = ALWAYS_TABS.map((row) => {
if (row.id === ARTICLE_TAB.supplier && isFather) {
return {
...row,
disabled: true,
help: 'Lieferanten sind für Vaterartikel deaktiviert (werden auf Variantenebene gepflegt).',
};
}
return row;
});
return [...tabs, SETTINGS_TAB];
}
function readRouteTab() {
@@ -264,6 +275,7 @@ class ArticleTileTab extends PureComponent {
<Tab
key={row.id}
value={row.id}
disabled={Boolean(row.disabled)}
aria-label={row.label}
icon={
<>
@@ -300,6 +312,12 @@ class ArticleTileTab extends PureComponent {
color: 'text.primary',
borderBottom: 'none',
},
'&.Mui-disabled': {
opacity: 0.4,
bgcolor: '#e2e8f0',
cursor: 'not-allowed',
pointerEvents: 'auto',
},
}}
/>
);

View File

@@ -30,8 +30,12 @@ export function articleSku(article) {
return sku || null;
}
export function articleTabIds() {
return [...BASE_ARTICLE_TAB_IDS, ARTICLE_TAB.settings];
export function articleTabIds(article) {
let ids = [...BASE_ARTICLE_TAB_IDS];
if (article?.isFather) {
ids = ids.filter((id) => id !== ARTICLE_TAB.supplier);
}
return [...ids, ARTICLE_TAB.settings];
}
export function isArticleTabId(tab) {

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { EditablePanelTile } from 'dashgui/panels';
import { EditablePanelTile, PanelTile } from 'dashgui/panels';
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
import { StorageManagerContext } from '../../../api/storageManagerContext';
@@ -485,6 +485,53 @@ export default class ArticleViewPrice extends PureComponent {
render() {
const { article: articleData } = this.props;
if (articleData?.isFather) {
return (
<PanelTile
wide={1}
minHeight={SECOND_ROW_MIN_HEIGHT}
color="#e2e8f0"
contentAlignItems="flex-start"
>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: 'uppercase',
letterSpacing: 0.6,
fontWeight: 600,
display: 'block',
}}
>
Preis
</Typography>
<Box sx={{ my: 0.75 }}>
<Typography
component="div"
sx={{
fontSize: '1.25rem',
fontWeight: 700,
lineHeight: 1.2,
color: 'text.secondary',
}}
>
Kein Preis
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.25 }}
>
Preise werden auf Variantenebene gepflegt
</Typography>
</Box>
</Box>
</PanelTile>
);
}
const { company, tiersDialogOpen, unitEditorOpen } = this.state;
const priceValue = priceDraftValue(articleData, company);
const basePrice = primaryPrice(articleData);

View File

@@ -31,8 +31,9 @@ export default class ArticleViewStock extends PureComponent {
render() {
const { article: articleData } = this.props;
const { adjustOpen } = this.state;
const isFather = Boolean(articleData?.isFather);
const isTrackStock =
articleData?.track_stock !== false && articleData?.track_stock !== 0;
!isFather && articleData?.track_stock !== false && articleData?.track_stock !== 0;
const rawWarehouses = Array.isArray(articleData?.warehouses)
? articleData.warehouses
@@ -60,12 +61,16 @@ export default class ArticleViewStock extends PureComponent {
<PanelTile
wide={1}
minHeight={SECOND_ROW_MIN_HEIGHT}
color="#bbf7d0"
action={{
label: 'Bestand anpassen',
icon: EditOutlinedIcon,
onClick: this.handleOpenAdjust,
}}
color={isFather ? '#e2e8f0' : '#bbf7d0'}
action={
isFather
? null
: {
label: 'Bestand anpassen',
icon: EditOutlinedIcon,
onClick: this.handleOpenAdjust,
}
}
>
<Box
sx={{
@@ -90,7 +95,28 @@ export default class ArticleViewStock extends PureComponent {
Bestand
</Typography>
{isTrackStock ? (
{isFather ? (
<Box sx={{ my: 0.75 }}>
<Typography
component="div"
sx={{
fontSize: '1.25rem',
fontWeight: 700,
lineHeight: 1.2,
color: 'text.secondary',
}}
>
Kein Bestand
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.25 }}
>
Bestand wird auf Variantenebene geführt
</Typography>
</Box>
) : isTrackStock ? (
<Tooltip title="Klicken, um Bestand anzupassen" arrow placement="top-start">
<Box
onClick={this.handleOpenAdjust}
@@ -251,11 +277,13 @@ export default class ArticleViewStock extends PureComponent {
</Box>
</PanelTile>
<ArticleStockAdjustDialog
open={adjustOpen}
onClose={this.handleCloseAdjust}
article={articleData}
/>
{!isFather ? (
<ArticleStockAdjustDialog
open={adjustOpen}
onClose={this.handleCloseAdjust}
article={articleData}
/>
) : null}
</>
);
}

View File

@@ -197,6 +197,33 @@ export default class ArticleSupplierTab extends PureComponent {
busyId,
} = this.state;
if (this.props.article?.isFather) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 6,
px: 2,
borderRadius: 2,
border: '1px dashed rgba(0, 0, 0, 0.15)',
bgcolor: 'rgba(0, 0, 0, 0.01)',
textAlign: 'center',
}}
>
<LocalShippingOutlinedIcon sx={{ fontSize: 48, mb: 1.5, color: 'text.secondary', opacity: 0.4 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5, color: 'text.secondary' }}>
Lieferanten sind für Vaterartikel deaktiviert
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
Lieferanten, Einkaufspreise und Lieferanten-Artikelnummern werden für jede Variante separat gepflegt.
</Typography>
</Box>
);
}
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 6 }}>

View File

@@ -53,6 +53,33 @@ export class ArticleTile extends Tile {
);
}
renderExtra() {
const childCount = Number(this.props.article?.childCount) || 0;
if (childCount <= 0) return null;
return (
<Box
sx={{
position: 'absolute',
top: 6,
right: 6,
zIndex: 1,
px: 0.75,
py: 0.25,
borderRadius: 999,
bgcolor: 'rgba(0, 0, 0, 0.65)',
color: 'common.white',
fontSize: 11,
fontWeight: 600,
lineHeight: 1.2,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{childCount}
</Box>
);
}
render() {
if (!this.hasPhoto()) {
return super.render();
@@ -87,6 +114,7 @@ export class ArticleTile extends Tile {
}}
/>
</Box>
{this.renderExtra()}
<Box
sx={{
position: 'absolute',