From 9dac1518542b52015ca0ce1d023af2777f89c872 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 31 Jul 2026 07:00:04 +0200 Subject: [PATCH] u --- README.md | 5 +- package.json | 8 +- scripts/dev.mjs | 2 +- src/App.jsx | 93 +++++---- src/api/index.js | 51 ++++- src/auth/AuthProvider.jsx | 66 +++++- src/components/CompanySettingsDialog.jsx | 195 ++++++++++++++++++ src/components/CompanySetupDialog.jsx | 244 +++++++++++++++++++++++ src/components/UserMenu.jsx | 16 ++ src/utils/companyForm.js | 18 ++ vite.config.js | 78 ++++++-- 11 files changed, 715 insertions(+), 61 deletions(-) create mode 100644 src/components/CompanySettingsDialog.jsx create mode 100644 src/components/CompanySetupDialog.jsx create mode 100644 src/utils/companyForm.js 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 ( + + Unternehmen bearbeiten + + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + + + ) : ( + + setName(e.target.value)} + autoComplete="organization" + autoFocus + /> + + + Sprache + { + if (value) setIso639(value); + }} + > + Deutsch + English + + + + + setTaxrate(e.target.value)} + error={taxrate !== '' && parsedTaxrate == null} + slotProps={{ + input: { + endAdornment: %, + }, + htmlInput: { min: 0, max: 100, step: 0.01, required: true }, + }} + /> + setReducedtaxrate(e.target.value)} + error={reducedtaxrate !== '' && parsedReducedtaxrate == null} + slotProps={{ + input: { + endAdornment: %, + }, + htmlInput: { min: 0, max: 100, step: 0.01, required: true }, + }} + /> + + + )} + + + + + + + + ); +} 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 ( + + + {loading + ? 'Unternehmen' + : hasCompanies + ? 'Unternehmen auswählen' + : 'Unternehmen anlegen'} + + + {!loading ? ( + + {hasCompanies + ? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.' + : 'Legen Sie Ihr Unternehmen an, um fortzufahren.'} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + + + ) : ( + + {hasCompanies ? ( + + + {companies.map((item) => ( + handleSelect(item.id)} + > + + + + ))} + + + ) : null} + + {hasCompanies ? oder : null} + + + + {hasCompanies ? ( + Neues Unternehmen anlegen + ) : null} + + setName(e.target.value)} + autoComplete="organization" + autoFocus={!hasCompanies} + /> + + + Sprache + { + if (value) setIso639(value); + }} + > + Deutsch + English + + + + + setTaxrate(e.target.value)} + error={taxrate !== '' && parsedTaxrate == null} + slotProps={{ + input: { + endAdornment: %, + }, + htmlInput: { min: 0, max: 100, step: 0.01, required: true }, + }} + /> + setReducedtaxrate(e.target.value)} + error={reducedtaxrate !== '' && parsedReducedtaxrate == null} + slotProps={{ + input: { + endAdornment: %, + }, + htmlInput: { min: 0, max: 100, step: 0.01, required: true }, + }} + /> + + + + + + + + + )} + + + ); +} 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' }} > {user?.email} + { + setAnchorEl(null); + setSettingsOpen(true); + }} + > + + + + Unternehmen + { setAnchorEl(null); @@ -48,6 +63,7 @@ export default function UserMenu() { Sign out + 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({