This commit is contained in:
seb
2026-07-19 16:37:45 +02:00
parent 17a36b2283
commit 9c20672e26
13 changed files with 481 additions and 26 deletions

View File

@@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#3b5bdb" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Dashfront</title> <title>Dashfront</title>
<style> <style>

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,28 @@
{
"id": "/",
"name": "Dashfront",
"short_name": "Dashfront",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#dceeea",
"theme_color": "#3b5bdb",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -5,7 +5,6 @@ import { useAuth } from './auth/useAuth';
import LoginPage from './components/LoginPage'; import LoginPage from './components/LoginPage';
import AppShell from './components/AppShell'; import AppShell from './components/AppShell';
import SectionLayout from './components/SectionLayout'; import SectionLayout from './components/SectionLayout';
import HomePage from './pages/index';
import ArtikelUebersichtPage from './pages/artikel/index'; import ArtikelUebersichtPage from './pages/artikel/index';
import ArtikelKategorienPage from './pages/artikel/kategorien'; import ArtikelKategorienPage from './pages/artikel/kategorien';
import ArtikelLagerPage from './pages/artikel/lager'; import ArtikelLagerPage from './pages/artikel/lager';
@@ -44,7 +43,7 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<AppShell />}> <Route path="/" element={<AppShell />}>
<Route index element={<HomePage />} /> <Route index element={<Navigate to="/artikel" replace />} />
<Route path="lieferung" element={<LieferungPage />} /> <Route path="lieferung" element={<LieferungPage />} />
<Route path="infos" element={<InfosPage />} /> <Route path="infos" element={<InfosPage />} />
<Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}> <Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}>
@@ -61,7 +60,7 @@ export default function App() {
<Route path="gruppen" element={<KundeGruppenPage />} /> <Route path="gruppen" element={<KundeGruppenPage />} />
<Route path="kontakte" element={<KundeKontaktePage />} /> <Route path="kontakte" element={<KundeKontaktePage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/artikel" replace />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -28,6 +28,16 @@ async function http(path, { method = 'GET', body } = {}) {
return res.json(); return res.json();
} }
export const article = {
search({ target, filter, offset, maxCount } = {}) {
const params = new URLSearchParams({ target });
if (filter) params.set('filter', filter);
if (offset) params.set('offset', String(offset));
if (maxCount != null) params.set('maxCount', String(maxCount));
return http(`/api/article/search?${params}`);
},
};
export const auth = { export const auth = {
async me() { async me() {
try { try {
@@ -66,4 +76,4 @@ export const auth = {
}, },
}; };
export default { auth }; export default { auth, article };

View File

@@ -19,6 +19,31 @@ function loadGoogleScript() {
return scriptPromise; return scriptPromise;
} }
export class GoogleSignInCancelledError extends Error {
constructor() {
super('Sign-in was cancelled');
this.name = 'GoogleSignInCancelledError';
}
}
export const FEDCM_BLOCKED_MESSAGE =
'Google sign-in is blocked for this site. Enable third-party sign-in using the icon next to the address bar, or in your browser site settings.';
export class GoogleSignInBlockedError extends Error {
constructor(message = FEDCM_BLOCKED_MESSAGE) {
super(message);
this.name = 'GoogleSignInBlockedError';
}
}
let activeAbort = null;
export function abortGoogleSignIn() {
if (!activeAbort) return;
window.google?.accounts?.id?.cancel();
activeAbort();
}
function clickRenderedGoogleButton(clientId, onCredential) { function clickRenderedGoogleButton(clientId, onCredential) {
const container = document.createElement('div'); const container = document.createElement('div');
container.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none;'; container.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none;';
@@ -51,8 +76,10 @@ function clickRenderedGoogleButton(clientId, onCredential) {
/** /**
* Prompt for a Google ID token (One Tap, falling back to the sign-in popup). * Prompt for a Google ID token (One Tap, falling back to the sign-in popup).
* @param {string} clientId
* @param {{ onFedcmBlocked?: () => void }} [options]
*/ */
export async function requestGoogleIdToken(clientId) { export async function requestGoogleIdToken(clientId, { onFedcmBlocked } = {}) {
if (!clientId) { if (!clientId) {
throw new Error('Google client ID is not configured'); throw new Error('Google client ID is not configured');
} }
@@ -65,9 +92,12 @@ export async function requestGoogleIdToken(clientId) {
const finish = (fn, value) => { const finish = (fn, value) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
activeAbort = null;
fn(value); fn(value);
}; };
activeAbort = () => finish(reject, new GoogleSignInCancelledError());
window.google.accounts.id.initialize({ window.google.accounts.id.initialize({
client_id: clientId, client_id: clientId,
callback: (response) => finish(resolve, response.credential), callback: (response) => finish(resolve, response.credential),
@@ -78,10 +108,21 @@ export async function requestGoogleIdToken(clientId) {
window.google.accounts.id.prompt((notification) => { window.google.accounts.id.prompt((notification) => {
if (settled) return; if (settled) return;
if (notification.isDismissedMoment()) {
if (notification.getDismissedReason() === 'credential_returned') return;
finish(reject, new GoogleSignInCancelledError());
return;
}
if (notification.isSkippedMoment()) {
const reason = notification.getSkippedReason?.();
if (reason === 'issuing_failed') {
onFedcmBlocked?.();
}
}
const needsFallback = const needsFallback =
notification.isNotDisplayed() || notification.isNotDisplayed() || notification.isSkippedMoment();
notification.isSkippedMoment() ||
notification.isDismissedMoment();
if (!needsFallback) return; if (!needsFallback) return;

View File

@@ -28,7 +28,7 @@ export default function AppShell() {
key={path} key={path}
component={NavLink} component={NavLink}
to={path} to={path}
end={path === '/'} onMouseDown={(e) => e.preventDefault()}
sx={(theme) => ({ sx={(theme) => ({
borderRadius: 0, borderRadius: 0,
px: 1.5, px: 1.5,

View File

@@ -8,7 +8,13 @@ import Typography from '@mui/material/Typography';
import GoogleIcon from '@mui/icons-material/Google'; import GoogleIcon from '@mui/icons-material/Google';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { requestGoogleIdToken } from '../auth/googleSignIn'; import {
abortGoogleSignIn,
FEDCM_BLOCKED_MESSAGE,
GoogleSignInBlockedError,
GoogleSignInCancelledError,
requestGoogleIdToken,
} from '../auth/googleSignIn';
export default function LoginPage() { export default function LoginPage() {
const { loginWithGoogle, googleClientId, status } = useAuth(); const { loginWithGoogle, googleClientId, status } = useAuth();
@@ -16,11 +22,24 @@ export default function LoginPage() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const handleLogin = async () => { const handleLogin = async () => {
setBusy(true); abortGoogleSignIn();
try { try {
const idToken = await requestGoogleIdToken(googleClientId); const idToken = await requestGoogleIdToken(googleClientId, {
onFedcmBlocked: () => {
enqueueSnackbar(FEDCM_BLOCKED_MESSAGE, {
variant: 'warning',
autoHideDuration: 12000,
});
},
});
setBusy(true);
await loginWithGoogle(idToken); await loginWithGoogle(idToken);
} catch (err) { } catch (err) {
if (err instanceof GoogleSignInCancelledError) return;
if (err instanceof GoogleSignInBlockedError) {
enqueueSnackbar(err.message, { variant: 'warning', autoHideDuration: 12000 });
return;
}
enqueueSnackbar(`Login failed: ${err.message}`, { variant: 'error' }); enqueueSnackbar(`Login failed: ${err.message}`, { variant: 'error' });
} finally { } finally {
setBusy(false); setBusy(false);

View File

@@ -1,4 +1,3 @@
import HomeIcon from '@mui/icons-material/Home';
import Inventory2Icon from '@mui/icons-material/Inventory2'; import Inventory2Icon from '@mui/icons-material/Inventory2';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
@@ -13,7 +12,6 @@ import GroupsIcon from '@mui/icons-material/Groups';
import ContactPageIcon from '@mui/icons-material/ContactPage'; import ContactPageIcon from '@mui/icons-material/ContactPage';
export const NAV_ITEMS = [ export const NAV_ITEMS = [
{ path: '/', label: 'Home', icon: HomeIcon },
{ path: '/artikel', label: 'Artikel', icon: Inventory2Icon }, { path: '/artikel', label: 'Artikel', icon: Inventory2Icon },
{ path: '/lieferung', label: 'Lieferung', icon: LocalShippingIcon }, { path: '/lieferung', label: 'Lieferung', icon: LocalShippingIcon },
{ path: '/auftrag', label: 'Auftrag', icon: AssignmentIcon }, { path: '/auftrag', label: 'Auftrag', icon: AssignmentIcon },

View File

@@ -1,7 +1,363 @@
import { Component } from 'react';
import { useSearchParams } from 'react-router-dom';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Pagination from '@mui/material/Pagination';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import { article } from '../../api';
import { formatPrice } from '../../utils/price';
const PAGE_SIZE = 10;
function parseFilter(filterStr) {
if (!filterStr) return [];
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
}
function buildFilter(categories) {
return categories.length ? categories.join(',') : '';
}
function formatStock(value) {
if (value == null) return '—';
return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 2 }).format(value);
}
function getCategoryDepth(id, facets) {
let depth = 0;
let parent = facets[id]?.parent;
while (parent && parent !== 0 && facets[String(parent)]) {
depth += 1;
parent = facets[String(parent)]?.parent;
}
return depth;
}
function formatCategoryList(catStr, facets) {
if (!catStr) return '';
return catStr
.split(',')
.map((id) => facets[id]?.name ?? id)
.join(', ');
}
function ResultsPagination({ offset, totalCount, pageCount, currentPage, onPageChange, sx }) {
if (totalCount <= PAGE_SIZE) return null;
export default function ArtikelUebersichtPage() {
return ( return (
<Typography color="text.secondary">Übersicht — Inhalt folgt.</Typography> <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
...sx,
}}
>
<Typography variant="body2" color="text.secondary">
{offset + 1}–{Math.min(offset + PAGE_SIZE, totalCount)} von {totalCount}
</Typography>
<Pagination
count={pageCount}
page={currentPage}
onChange={onPageChange}
color="primary"
size="small"
/>
</Box>
); );
} }
function withSearchParams(Wrapped) {
function SearchParamsWrapper(props) {
const [searchParams, setSearchParams] = useSearchParams();
return (
<Wrapped
{...props}
searchParams={searchParams}
setSearchParams={setSearchParams}
/>
);
}
return SearchParamsWrapper;
}
class ArtikelUebersichtPage extends Component {
state = {
input: this.props.searchParams.get('target') ?? '',
error: null,
categoryFacets: null,
totalCount: 0,
recordset: [],
};
componentDidMount() {
this.fetchResults();
}
componentDidUpdate(prevProps) {
const { searchParams } = this.props;
const prev = prevProps.searchParams;
const target = searchParams.get('target') ?? '';
const prevTarget = prev.get('target') ?? '';
if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target });
}
if (
target !== prevTarget
|| searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('offset') !== prev.get('offset')
) {
this.fetchResults();
}
}
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
const params = new URLSearchParams();
if (nextTarget) params.set('target', nextTarget);
if (nextFilter) params.set('filter', nextFilter);
if (nextOffset > 0) params.set('offset', String(nextOffset));
this.props.setSearchParams(params, { replace: true });
};
handleInputChange = (event) => {
const value = event.target.value;
this.setState({ input: value });
this.updateParams(value, this.props.searchParams.get('filter') ?? '', 0);
};
fetchResults = async () => {
const target = this.props.searchParams.get('target') ?? '';
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
const trimmed = target.trim();
if (trimmed.length < 3) {
this.setState({
categoryFacets: null,
totalCount: 0,
recordset: [],
error: null,
});
return;
}
try {
const data = await article.search({
target: trimmed,
filter: filterParam || undefined,
offset,
maxCount: PAGE_SIZE,
});
this.setState({
categoryFacets: data.categories ?? {},
totalCount: data.length ?? 0,
recordset: data.recordset ?? [],
error: null,
});
} catch (err) {
this.setState({
categoryFacets: null,
totalCount: 0,
recordset: [],
error: err.message || 'Suche fehlgeschlagen',
});
}
};
render() {
const { input, error, categoryFacets, totalCount, recordset } = this.state;
const target = this.props.searchParams.get('target') ?? '';
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
const categories = parseFilter(filterParam);
const searchActive = target.trim().length >= 3;
const facetEntries = categoryFacets
? Object.entries(categoryFacets)
.map(([id, { count, name, parent }]) => ({
id,
count,
name,
depth: getCategoryDepth(id, categoryFacets),
}))
.sort((a, b) => Number(a.id) - Number(b.id))
: [];
const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
const emptyRowCount = Math.max(0, PAGE_SIZE - recordset.length);
const handlePageChange = (_e, page) => {
this.updateParams(input, filterParam, (page - 1) * PAGE_SIZE);
};
return (
<>
<Box sx={{ mb: 3, maxWidth: 720 }}>
<TextField
fullWidth
autoFocus
type="search"
autoComplete="off"
inputProps={{
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: false,
}}
placeholder="Artikel suchen (EAN, Artikelnr., Name, Barcode)…"
value={input}
onChange={this.handleInputChange}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
},
}}
/>
</Box>
{input.trim().length < 3 && (
<Typography color="text.secondary">
Mindestens 3 Zeichen eingeben — mehrere Wörter werden mit UND verknüpft.
</Typography>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{searchActive && !error && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)}
{(facetEntries.length > 0 || recordset.length > 0) && (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{facetEntries.length > 0 && (
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'hidden' }}>
<Typography
variant="subtitle2"
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }}
>
Kategorien
</Typography>
<List dense disablePadding>
{facetEntries.map(({ id, count, name, depth }) => (
<ListItemButton
key={id}
selected={categories.includes(id)}
onClick={() => {
const next = categories.includes(id)
? categories.filter((c) => c !== id)
: [...categories, id];
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ pl: 2 + depth * 1.5 }}
>
<ListItemText
primary={`${name ?? id} (${count})`}
slotProps={{ primary: { variant: 'body2' } }}
/>
</ListItemButton>
))}
</List>
</Paper>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{recordset.length > 0 && (
<>
<ResultsPagination
offset={offset}
totalCount={totalCount}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={handlePageChange}
sx={{ mb: 2 }}
/>
<List component={Paper} variant="outlined" disablePadding>
{recordset.map((row) => (
<ListItemButton
key={row.id}
divider
sx={{ alignItems: 'flex-start', py: 1.5 }}
>
<ListItemText
primary={row.name}
secondary={
<Typography component="span" variant="body2" color="text.secondary">
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500 } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</Typography>
</ListItemButton>
))}
{Array.from({ length: emptyRowCount }, (_, i) => (
<ListItemButton
key={`empty-${i}`}
divider
disabled
aria-hidden
sx={{
alignItems: 'flex-start',
py: 1.5,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<ListItemText primary="." secondary="." />
<Typography variant="body2">.</Typography>
</ListItemButton>
))}
</List>
<ResultsPagination
offset={offset}
totalCount={totalCount}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={handlePageChange}
sx={{ mt: 2 }}
/>
</>
)}
</Box>
</Box>
)}
</>
);
}
}
export default withSearchParams(ArtikelUebersichtPage);

View File

@@ -1,10 +0,0 @@
import Typography from '@mui/material/Typography';
import PageLayout from '../components/PageLayout';
export default function HomePage() {
return (
<PageLayout title="Home">
<Typography color="text.secondary">Home — Inhalt folgt.</Typography>
</PageLayout>
);
}

11
src/utils/price.js Normal file
View File

@@ -0,0 +1,11 @@
export function getBrutto(netto, kSteuerklasse) {
const rate = { 1: 19, 2: 7, 3: 0 }[kSteuerklasse] ?? 19;
return netto * (1 + rate / 100);
}
export function formatPrice(amount) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
}