pwa
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>Dashfront</title>
|
||||
<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 AppShell from './components/AppShell';
|
||||
import SectionLayout from './components/SectionLayout';
|
||||
import HomePage from './pages/index';
|
||||
import ArtikelUebersichtPage from './pages/artikel/index';
|
||||
import ArtikelKategorienPage from './pages/artikel/kategorien';
|
||||
import ArtikelLagerPage from './pages/artikel/lager';
|
||||
@@ -44,7 +43,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppShell />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route index element={<Navigate to="/artikel" replace />} />
|
||||
<Route path="lieferung" element={<LieferungPage />} />
|
||||
<Route path="infos" element={<InfosPage />} />
|
||||
<Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}>
|
||||
@@ -61,7 +60,7 @@ export default function App() {
|
||||
<Route path="gruppen" element={<KundeGruppenPage />} />
|
||||
<Route path="kontakte" element={<KundeKontaktePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/artikel" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -28,6 +28,16 @@ async function http(path, { method = 'GET', body } = {}) {
|
||||
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 = {
|
||||
async me() {
|
||||
try {
|
||||
@@ -66,4 +76,4 @@ export const auth = {
|
||||
},
|
||||
};
|
||||
|
||||
export default { auth };
|
||||
export default { auth, article };
|
||||
|
||||
@@ -19,6 +19,31 @@ function loadGoogleScript() {
|
||||
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) {
|
||||
const container = document.createElement('div');
|
||||
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).
|
||||
* @param {string} clientId
|
||||
* @param {{ onFedcmBlocked?: () => void }} [options]
|
||||
*/
|
||||
export async function requestGoogleIdToken(clientId) {
|
||||
export async function requestGoogleIdToken(clientId, { onFedcmBlocked } = {}) {
|
||||
if (!clientId) {
|
||||
throw new Error('Google client ID is not configured');
|
||||
}
|
||||
@@ -65,9 +92,12 @@ export async function requestGoogleIdToken(clientId) {
|
||||
const finish = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
activeAbort = null;
|
||||
fn(value);
|
||||
};
|
||||
|
||||
activeAbort = () => finish(reject, new GoogleSignInCancelledError());
|
||||
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: (response) => finish(resolve, response.credential),
|
||||
@@ -78,10 +108,21 @@ export async function requestGoogleIdToken(clientId) {
|
||||
window.google.accounts.id.prompt((notification) => {
|
||||
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 =
|
||||
notification.isNotDisplayed() ||
|
||||
notification.isSkippedMoment() ||
|
||||
notification.isDismissedMoment();
|
||||
notification.isNotDisplayed() || notification.isSkippedMoment();
|
||||
|
||||
if (!needsFallback) return;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function AppShell() {
|
||||
key={path}
|
||||
component={NavLink}
|
||||
to={path}
|
||||
end={path === '/'}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
sx={(theme) => ({
|
||||
borderRadius: 0,
|
||||
px: 1.5,
|
||||
|
||||
@@ -8,7 +8,13 @@ import Typography from '@mui/material/Typography';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import { useSnackbar } from 'notistack';
|
||||
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() {
|
||||
const { loginWithGoogle, googleClientId, status } = useAuth();
|
||||
@@ -16,11 +22,24 @@ export default function LoginPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setBusy(true);
|
||||
abortGoogleSignIn();
|
||||
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);
|
||||
} 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' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||
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';
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ path: '/', label: 'Home', icon: HomeIcon },
|
||||
{ path: '/artikel', label: 'Artikel', icon: Inventory2Icon },
|
||||
{ path: '/lieferung', label: 'Lieferung', icon: LocalShippingIcon },
|
||||
{ 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 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 (
|
||||
<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