pwa
This commit is contained in:
@@ -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
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
BIN
public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
28
public/manifest.webmanifest
Normal file
28
public/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
11
src/utils/price.js
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user