diff --git a/README.md b/README.md
index ab004bb..31a0521 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,10 @@ npm run dev
```
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
backend.
diff --git a/package.json b/package.json
index fd128f3..df5c970 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,10 @@
"version": "0.0.1",
"type": "module",
"scripts": {
- "dev": "node scripts/dev.mjs",
- "dev:https": "VITE_HTTPS=1 node scripts/dev.mjs",
- "dev:hmr": "vite",
- "dev:hmr:https": "VITE_HTTPS=1 vite",
+ "dev": "vite",
+ "dev:https": "VITE_HTTPS=1 vite",
+ "dev:pwa": "node scripts/dev.mjs",
+ "dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
"cert": "./scripts/generate-cert.sh",
"build": "vite build",
"preview": "vite preview",
diff --git a/scripts/dev.mjs b/scripts/dev.mjs
index e512216..116f6c3 100644
--- a/scripts/dev.mjs
+++ b/scripts/dev.mjs
@@ -1,6 +1,6 @@
/**
* 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 path from 'node:path';
diff --git a/src/App.jsx b/src/App.jsx
index 93ed395..1582b4c 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,24 +1,43 @@
+import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { useAuth } from './auth/useAuth';
import LoginPage from './components/LoginPage';
+import CompanySetupDialog from './components/CompanySetupDialog';
import AppShell from './components/AppShell';
import SectionLayout from './components/SectionLayout';
-import ArtikelUebersichtPage from './pages/artikel/index';
-import ArtikelKategorienPage from './pages/artikel/kategorien';
-import ArtikelLagerPage from './pages/artikel/lager';
-import ArtikelSkuPage from './pages/artikel/sku';
-import LieferungPage from './pages/lieferung/index';
-import AuftragAktivePage from './pages/auftrag/index';
-import AuftragArchivPage from './pages/auftrag/archiv';
-import KundeListePage from './pages/kunde/index';
-import KundeGruppenPage from './pages/kunde/gruppen';
-import KundeKontaktePage from './pages/kunde/kontakte';
-import InfosPage from './pages/infos/index';
+
+const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index'));
+const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
+const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
+const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
+const LieferungPage = lazy(() => import('./pages/lieferung/index'));
+const AuftragAktivePage = lazy(() => import('./pages/auftrag/index'));
+const AuftragArchivPage = lazy(() => import('./pages/auftrag/archiv'));
+const KundeListePage = lazy(() => import('./pages/kunde/index'));
+const KundeGruppenPage = lazy(() => import('./pages/kunde/gruppen'));
+const KundeKontaktePage = lazy(() => import('./pages/kunde/kontakte'));
+const InfosPage = lazy(() => import('./pages/infos/index'));
+
+function PageFallback() {
+ return (
+
+
+
+ );
+}
export default function App() {
- const { status } = useAuth();
+ const { status, needsCompanySetup } = useAuth();
if (status === 'loading') {
return (
@@ -40,31 +59,37 @@ export default function App() {
return ;
}
+ if (needsCompanySetup) {
+ return ;
+ }
+
return (
-
- }>
- } />
- } />
- } />
- }>
- } />
- } />
- } />
- } />
+ }>
+
+ }>
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+
+ }>
+ } />
+ } />
+
+ }>
+ } />
+ } />
+ } />
+
+ } />
- }>
- } />
- } />
-
- }>
- } />
- } />
- } />
-
- } />
-
-
+
+
);
}
diff --git a/src/api/index.js b/src/api/index.js
index 1c75d6e..6f1e4dd 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -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 = {
async me() {
try {
@@ -145,4 +194,4 @@ export const auth = {
},
};
-export default { auth, article };
+export default { auth, article, company };
diff --git a/src/auth/AuthProvider.jsx b/src/auth/AuthProvider.jsx
index d64414d..95418bc 100644
--- a/src/auth/AuthProvider.jsx
+++ b/src/auth/AuthProvider.jsx
@@ -1,13 +1,21 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSnackbar } from 'notistack';
-import { auth } from '../api';
+import { auth, company } from '../api';
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 }) {
const { enqueueSnackbar } = useSnackbar();
const [user, setUser] = useState(null);
const [googleClientId, setGoogleClientId] = useState(null);
const [status, setStatus] = useState('loading'); // loading | authed | anon
+ const [companyReady, setCompanyReady] = useState(false);
useEffect(() => {
let active = true;
@@ -15,15 +23,16 @@ export default function AuthProvider({ children }) {
try {
const session = await auth.me();
if (!active) return;
- setUser(session.user);
+ const nextUser = applyUser(session, setUser, setStatus);
+ setCompanyReady(nextUser?.selectedCompanyId != null);
setGoogleClientId(session.googleClientId);
- setStatus(session.user ? 'authed' : 'anon');
if (session.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' });
}
} catch (err) {
if (!active) return;
setStatus('anon');
+ setCompanyReady(false);
enqueueSnackbar(`Session check failed: ${err.message}`, {
variant: 'error',
});
@@ -32,33 +41,74 @@ export default function AuthProvider({ children }) {
return () => {
active = false;
};
- }, [enqueueSnackbar]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
const loginWithGoogle = useCallback(async (credential) => {
const { session } = await auth.loginWithGoogle(credential);
if (!session?.user) {
setUser(null);
setStatus('anon');
+ setCompanyReady(false);
enqueueSnackbar(
`Login ok but session cookie not accepted (/api/auth/me still unauthorized)`,
{ variant: 'error', autoHideDuration: 12000 },
);
return;
}
- setUser(session.user);
- setStatus('authed');
+ const nextUser = applyUser(session, setUser, setStatus);
+ setCompanyReady(nextUser?.selectedCompanyId != null);
}, [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 () => {
await auth.logout();
setUser(null);
setStatus('anon');
+ setCompanyReady(false);
window.history.replaceState(null, '', '/');
}, []);
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 {children};
diff --git a/src/components/CompanySettingsDialog.jsx b/src/components/CompanySettingsDialog.jsx
new file mode 100644
index 0000000..7c10fc5
--- /dev/null
+++ b/src/components/CompanySettingsDialog.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx
new file mode 100644
index 0000000..d5c7acd
--- /dev/null
+++ b/src/components/CompanySetupDialog.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx
index 8ce14f6..d1b1344 100644
--- a/src/components/UserMenu.jsx
+++ b/src/components/UserMenu.jsx
@@ -7,12 +7,15 @@ import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import LogoutIcon from '@mui/icons-material/Logout';
+import BusinessIcon from '@mui/icons-material/Business';
import { useAuth } from '../auth/useAuth';
+import CompanySettingsDialog from './CompanySettingsDialog';
// Menu open/close state is local; toggling it only re-renders this component.
export default function UserMenu() {
const { user, logout } = useAuth();
const [anchorEl, setAnchorEl] = useState(null);
+ const [settingsOpen, setSettingsOpen] = useState(false);
const open = Boolean(anchorEl);
return (
@@ -36,6 +39,18 @@ export default function UserMenu() {
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
+
+ setSettingsOpen(false)} />
>
);
}
diff --git a/src/utils/companyForm.js b/src/utils/companyForm.js
new file mode 100644
index 0000000..2be730f
--- /dev/null
+++ b/src/utils/companyForm.js
@@ -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';
+}
diff --git a/vite.config.js b/vite.config.js
index 4f18516..fb65761 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -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 = {
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
port: useHttps ? 4308 : 4209,
@@ -35,24 +52,43 @@ const sharedServer = {
changeOrigin: true,
cookieDomainRewrite: '',
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) => {
const raw = proxyRes.headers['set-cookie'];
- if (!raw) {
- console.log('[vite-proxy]', req.method, req.url, {
- status: proxyRes.statusCode,
- setCookie: '(none)',
- });
- return;
- }
- let cookies = Array.isArray(raw) ? raw : [raw];
+ let cookies = raw ? (Array.isArray(raw) ? raw : [raw]) : null;
// Backend sets Secure; browsers/WebViews on plain HTTP refuse it.
- if (!useHttps) {
+ if (cookies && !useHttps) {
cookies = cookies.map((c) => c.replace(/;\s*Secure/gi, ''));
proxyRes.headers['set-cookie'] = cookies;
}
- console.log('[vite-proxy]', req.method, req.url, {
- status: proxyRes.statusCode,
- setCookie: cookies,
+
+ const responseChunks = [];
+ 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({
+ 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: [
react(),
VitePWA({