u
This commit is contained in:
@@ -19,7 +19,10 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at
|
Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at
|
||||||
`http://localhost:4208`.
|
`http://localhost:4208` and uses Vite HMR for fast reloads.
|
||||||
|
|
||||||
|
To test the full PWA precache (production build + preview, no HMR), use
|
||||||
|
`npm run dev:pwa` instead.
|
||||||
|
|
||||||
Dashback must be running and your Google account must be authorized on the
|
Dashback must be running and your Google account must be authorized on the
|
||||||
backend.
|
backend.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "vite",
|
||||||
"dev:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
"dev:https": "VITE_HTTPS=1 vite",
|
||||||
"dev:hmr": "vite",
|
"dev:pwa": "node scripts/dev.mjs",
|
||||||
"dev:hmr:https": "VITE_HTTPS=1 vite",
|
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
||||||
"cert": "./scripts/generate-cert.sh",
|
"cert": "./scripts/generate-cert.sh",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Dev with full PWA precache: production build --watch + preview.
|
* Dev with full PWA precache: production build --watch + preview.
|
||||||
* Vite HMR cannot precache in-memory modules; use npm run dev:hmr for that.
|
* Vite HMR cannot precache in-memory modules; use npm run dev for that.
|
||||||
*/
|
*/
|
||||||
import { build, preview } from 'vite';
|
import { build, preview } from 'vite';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|||||||
93
src/App.jsx
93
src/App.jsx
@@ -1,24 +1,43 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { useAuth } from './auth/useAuth';
|
import { useAuth } from './auth/useAuth';
|
||||||
import LoginPage from './components/LoginPage';
|
import LoginPage from './components/LoginPage';
|
||||||
|
import CompanySetupDialog from './components/CompanySetupDialog';
|
||||||
import AppShell from './components/AppShell';
|
import AppShell from './components/AppShell';
|
||||||
import SectionLayout from './components/SectionLayout';
|
import SectionLayout from './components/SectionLayout';
|
||||||
import ArtikelUebersichtPage from './pages/artikel/index';
|
|
||||||
import ArtikelKategorienPage from './pages/artikel/kategorien';
|
const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index'));
|
||||||
import ArtikelLagerPage from './pages/artikel/lager';
|
const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
|
||||||
import ArtikelSkuPage from './pages/artikel/sku';
|
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
|
||||||
import LieferungPage from './pages/lieferung/index';
|
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
|
||||||
import AuftragAktivePage from './pages/auftrag/index';
|
const LieferungPage = lazy(() => import('./pages/lieferung/index'));
|
||||||
import AuftragArchivPage from './pages/auftrag/archiv';
|
const AuftragAktivePage = lazy(() => import('./pages/auftrag/index'));
|
||||||
import KundeListePage from './pages/kunde/index';
|
const AuftragArchivPage = lazy(() => import('./pages/auftrag/archiv'));
|
||||||
import KundeGruppenPage from './pages/kunde/gruppen';
|
const KundeListePage = lazy(() => import('./pages/kunde/index'));
|
||||||
import KundeKontaktePage from './pages/kunde/kontakte';
|
const KundeGruppenPage = lazy(() => import('./pages/kunde/gruppen'));
|
||||||
import InfosPage from './pages/infos/index';
|
const KundeKontaktePage = lazy(() => import('./pages/kunde/kontakte'));
|
||||||
|
const InfosPage = lazy(() => import('./pages/infos/index'));
|
||||||
|
|
||||||
|
function PageFallback() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { status } = useAuth();
|
const { status, needsCompanySetup } = useAuth();
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
@@ -40,31 +59,37 @@ export default function App() {
|
|||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (needsCompanySetup) {
|
||||||
|
return <CompanySetupDialog />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Suspense fallback={<PageFallback />}>
|
||||||
<Route path="/" element={<AppShell />}>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/artikel" replace />} />
|
<Route path="/" element={<AppShell />}>
|
||||||
<Route path="lieferung" element={<LieferungPage />} />
|
<Route index element={<Navigate to="/artikel" replace />} />
|
||||||
<Route path="infos" element={<InfosPage />} />
|
<Route path="lieferung" element={<LieferungPage />} />
|
||||||
<Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}>
|
<Route path="infos" element={<InfosPage />} />
|
||||||
<Route index element={<ArtikelUebersichtPage />} />
|
<Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}>
|
||||||
<Route path="kategorien" element={<ArtikelKategorienPage />} />
|
<Route index element={<ArtikelUebersichtPage />} />
|
||||||
<Route path="lager" element={<ArtikelLagerPage />} />
|
<Route path="kategorien" element={<ArtikelKategorienPage />} />
|
||||||
<Route path=":sku" element={<ArtikelSkuPage />} />
|
<Route path="lager" element={<ArtikelLagerPage />} />
|
||||||
|
<Route path=":sku" element={<ArtikelSkuPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}>
|
||||||
|
<Route index element={<AuftragAktivePage />} />
|
||||||
|
<Route path="archiv" element={<AuftragArchivPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="kunde" element={<SectionLayout sectionPath="/kunde" />}>
|
||||||
|
<Route index element={<KundeListePage />} />
|
||||||
|
<Route path="gruppen" element={<KundeGruppenPage />} />
|
||||||
|
<Route path="kontakte" element={<KundeKontaktePage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/artikel" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}>
|
</Routes>
|
||||||
<Route index element={<AuftragAktivePage />} />
|
</Suspense>
|
||||||
<Route path="archiv" element={<AuftragArchivPage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="kunde" element={<SectionLayout sectionPath="/kunde" />}>
|
|
||||||
<Route index element={<KundeListePage />} />
|
|
||||||
<Route path="gruppen" element={<KundeGruppenPage />} />
|
|
||||||
<Route path="kontakte" element={<KundeKontaktePage />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="*" element={<Navigate to="/artikel" replace />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,55 @@ export const article = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const company = {
|
||||||
|
async list() {
|
||||||
|
try {
|
||||||
|
return await http('/api/company');
|
||||||
|
} catch {
|
||||||
|
return { companies: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Uses GET /api/company until a dedicated endpoint exists. */
|
||||||
|
async getById(companyId) {
|
||||||
|
const data = await http('/api/company');
|
||||||
|
const found = data.companies?.find((item) => item.id === companyId);
|
||||||
|
if (!found) throw new Error('Unternehmen nicht gefunden.');
|
||||||
|
return { company: found };
|
||||||
|
},
|
||||||
|
|
||||||
|
create({ name, iso639, taxrate, reducedtaxrate }) {
|
||||||
|
return http('/api/company', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, iso639, taxrate, reducedtaxrate },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend suggestion (not implemented yet):
|
||||||
|
*
|
||||||
|
* PUT /api/company/:companyId
|
||||||
|
* Body: { name, iso639, taxrate, reducedtaxrate }
|
||||||
|
* Response: { company: { id, name, iso639, taxrate, reducedtaxrate, ... } }
|
||||||
|
* Auth: session cookie; user must be allowed to edit that company.
|
||||||
|
*
|
||||||
|
* Optional: add iso639 to GET /api/company list items (today only languageId).
|
||||||
|
*/
|
||||||
|
update(companyId, { name, iso639, taxrate, reducedtaxrate }) {
|
||||||
|
return http(`/api/company/${companyId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { name, iso639, taxrate, reducedtaxrate },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
select(companyId) {
|
||||||
|
return http('/api/auth/selected-company', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { companyId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const auth = {
|
export const auth = {
|
||||||
async me() {
|
async me() {
|
||||||
try {
|
try {
|
||||||
@@ -145,4 +194,4 @@ export const auth = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { auth, article };
|
export default { auth, article, company };
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { auth } from '../api';
|
import { auth, company } from '../api';
|
||||||
import { AuthContext } from './context';
|
import { AuthContext } from './context';
|
||||||
|
|
||||||
|
function applyUser(session, setUser, setStatus) {
|
||||||
|
const nextUser = session?.user ?? null;
|
||||||
|
setUser(nextUser);
|
||||||
|
setStatus(nextUser ? 'authed' : 'anon');
|
||||||
|
return nextUser;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthProvider({ children }) {
|
export default function AuthProvider({ children }) {
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [googleClientId, setGoogleClientId] = useState(null);
|
const [googleClientId, setGoogleClientId] = useState(null);
|
||||||
const [status, setStatus] = useState('loading'); // loading | authed | anon
|
const [status, setStatus] = useState('loading'); // loading | authed | anon
|
||||||
|
const [companyReady, setCompanyReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -15,15 +23,16 @@ export default function AuthProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth.me();
|
const session = await auth.me();
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setUser(session.user);
|
const nextUser = applyUser(session, setUser, setStatus);
|
||||||
|
setCompanyReady(nextUser?.selectedCompanyId != null);
|
||||||
setGoogleClientId(session.googleClientId);
|
setGoogleClientId(session.googleClientId);
|
||||||
setStatus(session.user ? 'authed' : 'anon');
|
|
||||||
if (session.forbidden) {
|
if (session.forbidden) {
|
||||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setStatus('anon');
|
setStatus('anon');
|
||||||
|
setCompanyReady(false);
|
||||||
enqueueSnackbar(`Session check failed: ${err.message}`, {
|
enqueueSnackbar(`Session check failed: ${err.message}`, {
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
@@ -32,33 +41,74 @@ export default function AuthProvider({ children }) {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [enqueueSnackbar]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loginWithGoogle = useCallback(async (credential) => {
|
const loginWithGoogle = useCallback(async (credential) => {
|
||||||
const { session } = await auth.loginWithGoogle(credential);
|
const { session } = await auth.loginWithGoogle(credential);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setStatus('anon');
|
setStatus('anon');
|
||||||
|
setCompanyReady(false);
|
||||||
enqueueSnackbar(
|
enqueueSnackbar(
|
||||||
`Login ok but session cookie not accepted (/api/auth/me still unauthorized)`,
|
`Login ok but session cookie not accepted (/api/auth/me still unauthorized)`,
|
||||||
{ variant: 'error', autoHideDuration: 12000 },
|
{ variant: 'error', autoHideDuration: 12000 },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUser(session.user);
|
const nextUser = applyUser(session, setUser, setStatus);
|
||||||
setStatus('authed');
|
setCompanyReady(nextUser?.selectedCompanyId != null);
|
||||||
}, [enqueueSnackbar]);
|
}, [enqueueSnackbar]);
|
||||||
|
|
||||||
|
const selectCompany = useCallback(async (companyId) => {
|
||||||
|
const data = await company.select(companyId);
|
||||||
|
if (data.user) setUser(data.user);
|
||||||
|
setCompanyReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createCompany = useCallback(async ({ name, iso639, taxrate, reducedtaxrate }) => {
|
||||||
|
const data = await company.create({ name, iso639, taxrate, reducedtaxrate });
|
||||||
|
if (data.user) {
|
||||||
|
setUser(data.user);
|
||||||
|
setCompanyReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.company?.id) {
|
||||||
|
const selected = await company.select(data.company.id);
|
||||||
|
if (selected.user) setUser(selected.user);
|
||||||
|
setCompanyReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setStatus('anon');
|
setStatus('anon');
|
||||||
|
setCompanyReady(false);
|
||||||
window.history.replaceState(null, '', '/');
|
window.history.replaceState(null, '', '/');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ user, googleClientId, status, loginWithGoogle, logout }),
|
() => ({
|
||||||
[user, googleClientId, status, loginWithGoogle, logout],
|
user,
|
||||||
|
googleClientId,
|
||||||
|
status,
|
||||||
|
loginWithGoogle,
|
||||||
|
logout,
|
||||||
|
selectCompany,
|
||||||
|
createCompany,
|
||||||
|
needsCompanySetup: status === 'authed' && !companyReady,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
user,
|
||||||
|
googleClientId,
|
||||||
|
status,
|
||||||
|
companyReady,
|
||||||
|
loginWithGoogle,
|
||||||
|
logout,
|
||||||
|
selectCompany,
|
||||||
|
createCompany,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
195
src/components/CompanySettingsDialog.jsx
Normal file
195
src/components/CompanySettingsDialog.jsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import FormLabel from '@mui/material/FormLabel';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import { company } from '../api';
|
||||||
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
import {
|
||||||
|
ISO639_OPTIONS,
|
||||||
|
companyLanguageToIso639,
|
||||||
|
parseTaxRate,
|
||||||
|
} from '../utils/companyForm';
|
||||||
|
|
||||||
|
export default function CompanySettingsDialog({ open, onClose }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [iso639, setIso639] = useState('de');
|
||||||
|
const [taxrate, setTaxrate] = useState('');
|
||||||
|
const [reducedtaxrate, setReducedtaxrate] = useState('');
|
||||||
|
|
||||||
|
const companyId = user?.selectedCompanyId;
|
||||||
|
const parsedTaxrate = parseTaxRate(taxrate);
|
||||||
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
||||||
|
const canSave =
|
||||||
|
name.trim() !== '' &&
|
||||||
|
ISO639_OPTIONS.includes(iso639) &&
|
||||||
|
parsedTaxrate != null &&
|
||||||
|
parsedReducedtaxrate != null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !companyId) return;
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await company.getById(companyId);
|
||||||
|
if (!active) return;
|
||||||
|
const item = data.company;
|
||||||
|
setName(item.name ?? '');
|
||||||
|
setIso639(companyLanguageToIso639(item));
|
||||||
|
setTaxrate(item.taxrate != null ? String(item.taxrate) : '');
|
||||||
|
setReducedtaxrate(item.reducedtaxrate != null ? String(item.reducedtaxrate) : '');
|
||||||
|
} catch (err) {
|
||||||
|
if (!active) return;
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [open, companyId]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!companyId || !canSave) {
|
||||||
|
setError('Bitte alle Pflichtfelder ausfüllen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await company.update(companyId, {
|
||||||
|
name: name.trim(),
|
||||||
|
iso639,
|
||||||
|
taxrate: parsedTaxrate,
|
||||||
|
reducedtaxrate: parsedReducedtaxrate,
|
||||||
|
});
|
||||||
|
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Unternehmen bearbeiten</DialogTitle>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<DialogContent>
|
||||||
|
{error ? (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoComplete="organization"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
value={iso639}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
if (value) setIso639(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="de">Deutsch</ToggleButton>
|
||||||
|
<ToggleButton value="en">English</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Normalsatz"
|
||||||
|
value={taxrate}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setTaxrate(e.target.value)}
|
||||||
|
error={taxrate !== '' && parsedTaxrate == null}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
},
|
||||||
|
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Ermäßigter Satz"
|
||||||
|
value={reducedtaxrate}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setReducedtaxrate(e.target.value)}
|
||||||
|
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
},
|
||||||
|
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={onClose} disabled={busy}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={busy || loading || !canSave}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
src/components/CompanySetupDialog.jsx
Normal file
244
src/components/CompanySetupDialog.jsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import FormLabel from '@mui/material/FormLabel';
|
||||||
|
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 Stack from '@mui/material/Stack';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import BusinessIcon from '@mui/icons-material/Business';
|
||||||
|
import { company } from '../api';
|
||||||
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
import {
|
||||||
|
defaultIso639,
|
||||||
|
ISO639_OPTIONS,
|
||||||
|
parseTaxRate,
|
||||||
|
} from '../utils/companyForm';
|
||||||
|
|
||||||
|
export default function CompanySetupDialog() {
|
||||||
|
const { createCompany, selectCompany, logout } = useAuth();
|
||||||
|
const [companies, setCompanies] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [iso639, setIso639] = useState(defaultIso639);
|
||||||
|
const [taxrate, setTaxrate] = useState('19');
|
||||||
|
const [reducedtaxrate, setReducedtaxrate] = useState('7');
|
||||||
|
|
||||||
|
const hasCompanies = companies.length > 0;
|
||||||
|
|
||||||
|
const loadCompanies = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await company.list();
|
||||||
|
setCompanies(data.companies ?? []);
|
||||||
|
} catch {
|
||||||
|
setCompanies([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCompanies();
|
||||||
|
}, [loadCompanies]);
|
||||||
|
|
||||||
|
const parsedTaxrate = parseTaxRate(taxrate);
|
||||||
|
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
|
||||||
|
const canCreate =
|
||||||
|
name.trim() !== '' &&
|
||||||
|
ISO639_OPTIONS.includes(iso639) &&
|
||||||
|
parsedTaxrate != null &&
|
||||||
|
parsedReducedtaxrate != null;
|
||||||
|
|
||||||
|
const handleSelect = async (companyId) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await selectCompany(companyId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canCreate) {
|
||||||
|
setError('Bitte alle Pflichtfelder ausfüllen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createCompany({
|
||||||
|
name: name.trim(),
|
||||||
|
iso639,
|
||||||
|
taxrate: parsedTaxrate,
|
||||||
|
reducedtaxrate: parsedReducedtaxrate,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open disableEscapeKeyDown maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{loading
|
||||||
|
? 'Unternehmen'
|
||||||
|
: hasCompanies
|
||||||
|
? 'Unternehmen auswählen'
|
||||||
|
: 'Unternehmen anlegen'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{!loading ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{hasCompanies
|
||||||
|
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
|
||||||
|
: 'Legen Sie Ihr Unternehmen an, um fortzufahren.'}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{hasCompanies ? (
|
||||||
|
<Box>
|
||||||
|
<List disablePadding sx={{ bgcolor: 'background.paper', borderRadius: 1 }}>
|
||||||
|
{companies.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.id}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => handleSelect(item.id)}
|
||||||
|
>
|
||||||
|
<BusinessIcon sx={{ mr: 1.5, color: 'primary.main' }} />
|
||||||
|
<ListItemText primary={item.name} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasCompanies ? <Divider>oder</Divider> : null}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleCreate} noValidate>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{hasCompanies ? (
|
||||||
|
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoComplete="organization"
|
||||||
|
autoFocus={!hasCompanies}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl required>
|
||||||
|
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
value={iso639}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
if (value) setIso639(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="de">Deutsch</ToggleButton>
|
||||||
|
<ToggleButton value="en">English</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Normalsatz"
|
||||||
|
value={taxrate}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setTaxrate(e.target.value)}
|
||||||
|
error={taxrate !== '' && parsedTaxrate == null}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
},
|
||||||
|
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
label="Ermäßigter Satz"
|
||||||
|
value={reducedtaxrate}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setReducedtaxrate(e.target.value)}
|
||||||
|
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
},
|
||||||
|
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button type="submit" variant="contained" disabled={busy || !canCreate}>
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
color="inherit"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => logout()}
|
||||||
|
sx={{ alignSelf: 'center' }}
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ import MenuItem from '@mui/material/MenuItem';
|
|||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import LogoutIcon from '@mui/icons-material/Logout';
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
import BusinessIcon from '@mui/icons-material/Business';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
|
import CompanySettingsDialog from './CompanySettingsDialog';
|
||||||
|
|
||||||
// Menu open/close state is local; toggling it only re-renders this component.
|
// Menu open/close state is local; toggling it only re-renders this component.
|
||||||
export default function UserMenu() {
|
export default function UserMenu() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [anchorEl, setAnchorEl] = useState(null);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +39,18 @@ export default function UserMenu() {
|
|||||||
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
>
|
>
|
||||||
<MenuItem disabled>{user?.email}</MenuItem>
|
<MenuItem disabled>{user?.email}</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={!user?.selectedCompanyId}
|
||||||
|
onClick={() => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
setSettingsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<BusinessIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Unternehmen</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
@@ -48,6 +63,7 @@ export default function UserMenu() {
|
|||||||
<ListItemText>Sign out</ListItemText>
|
<ListItemText>Sign out</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
<CompanySettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/utils/companyForm.js
Normal file
18
src/utils/companyForm.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const ISO639_OPTIONS = ['de', 'en'];
|
||||||
|
|
||||||
|
export function defaultIso639() {
|
||||||
|
return navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTaxRate(value) {
|
||||||
|
if (value === '' || value == null) return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Until backend returns iso639 on company objects. */
|
||||||
|
export function companyLanguageToIso639(company) {
|
||||||
|
if (company?.iso639) return company.iso639;
|
||||||
|
if (company?.languageId === 2) return 'en';
|
||||||
|
return 'de';
|
||||||
|
}
|
||||||
@@ -24,6 +24,23 @@ function httpsOptions() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncate(text, max = 500) {
|
||||||
|
if (text == null || text === '') return text;
|
||||||
|
const s = typeof text === 'string' ? text : String(text);
|
||||||
|
if (s.length <= max) return s;
|
||||||
|
return `${s.slice(0, max)}… (${s.length} chars)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLoggedBody(statusCode, body) {
|
||||||
|
if (statusCode === 304) return '(304 no body)';
|
||||||
|
if (statusCode === 204 || body === '') return '(empty)';
|
||||||
|
return truncate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRequestBody(method) {
|
||||||
|
return method === 'POST' || method === 'PUT' || method === 'PATCH';
|
||||||
|
}
|
||||||
|
|
||||||
const sharedServer = {
|
const sharedServer = {
|
||||||
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
|
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
|
||||||
port: useHttps ? 4308 : 4209,
|
port: useHttps ? 4308 : 4209,
|
||||||
@@ -35,24 +52,43 @@ const sharedServer = {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
cookieDomainRewrite: '',
|
cookieDomainRewrite: '',
|
||||||
configure(proxy) {
|
configure(proxy) {
|
||||||
|
proxy.on('proxyReq', (_proxyReq, req) => {
|
||||||
|
if (!hasRequestBody(req.method)) return;
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
req.on('end', () => {
|
||||||
|
req._proxyRequestBody = chunks.length
|
||||||
|
? Buffer.concat(chunks).toString('utf8')
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
proxy.on('proxyRes', (proxyRes, req) => {
|
proxy.on('proxyRes', (proxyRes, req) => {
|
||||||
const raw = proxyRes.headers['set-cookie'];
|
const raw = proxyRes.headers['set-cookie'];
|
||||||
if (!raw) {
|
let cookies = raw ? (Array.isArray(raw) ? raw : [raw]) : null;
|
||||||
console.log('[vite-proxy]', req.method, req.url, {
|
|
||||||
status: proxyRes.statusCode,
|
|
||||||
setCookie: '(none)',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cookies = Array.isArray(raw) ? raw : [raw];
|
|
||||||
// Backend sets Secure; browsers/WebViews on plain HTTP refuse it.
|
// Backend sets Secure; browsers/WebViews on plain HTTP refuse it.
|
||||||
if (!useHttps) {
|
if (cookies && !useHttps) {
|
||||||
cookies = cookies.map((c) => c.replace(/;\s*Secure/gi, ''));
|
cookies = cookies.map((c) => c.replace(/;\s*Secure/gi, ''));
|
||||||
proxyRes.headers['set-cookie'] = cookies;
|
proxyRes.headers['set-cookie'] = cookies;
|
||||||
}
|
}
|
||||||
console.log('[vite-proxy]', req.method, req.url, {
|
|
||||||
status: proxyRes.statusCode,
|
const responseChunks = [];
|
||||||
setCookie: cookies,
|
proxyRes.on('data', (chunk) => responseChunks.push(chunk));
|
||||||
|
proxyRes.on('end', () => {
|
||||||
|
const responseBody = responseChunks.length
|
||||||
|
? Buffer.concat(responseChunks).toString('utf8')
|
||||||
|
: '';
|
||||||
|
const details = {
|
||||||
|
status: proxyRes.statusCode,
|
||||||
|
setCookie: cookies ?? '(none)',
|
||||||
|
response: formatLoggedBody(proxyRes.statusCode, responseBody),
|
||||||
|
};
|
||||||
|
if (hasRequestBody(req.method)) {
|
||||||
|
details.body = req._proxyRequestBody
|
||||||
|
? truncate(req._proxyRequestBody)
|
||||||
|
: '(empty)';
|
||||||
|
}
|
||||||
|
console.log('[vite-proxy]', req.method, req.url, details);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -61,6 +97,24 @@ const sharedServer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
codeSplitting: {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: 'react-vendor',
|
||||||
|
test: /node_modules\/(react|react-dom|react-router|react-router-dom|scheduler)\//,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mui-vendor',
|
||||||
|
test: /node_modules\/(@mui|@emotion)\//,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
|||||||
Reference in New Issue
Block a user