This commit is contained in:
seb
2026-10-05 16:09:23 +02:00
parent bac2d9a202
commit 5c48d1be65
6 changed files with 293 additions and 29 deletions

View File

@@ -35,6 +35,14 @@ export {
selectActiveHelpId, selectActiveHelpId,
} from './helpAutomat'; } from './helpAutomat';
export {
default as pwaAutomat,
initPwa,
openPwaDialog,
closePwaDialog,
applyPwaUpdate,
} from './pwaAutomat';
export { default as supplierAutomat } from './supplierAutomat'; export { default as supplierAutomat } from './supplierAutomat';
export { default as warehouseAutomat } from './warehouseAutomat'; export { default as warehouseAutomat } from './warehouseAutomat';

125
src/automats/pwaAutomat.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* @file pwaAutomat.js
* @description Manages PWA update lifecycle, service worker registration,
* and user prompt dialog state.
*/
import { Automat } from 'react-automat';
/**
* @typedef {Object} PwaState
* @property {boolean} needRefresh Whether a new SW update is available and waiting
* @property {boolean} dialogOpen Whether the update dialog is currently visible
* @property {boolean} offlineReady Whether content is cached for offline use
* @property {boolean} updating Whether an update is currently being applied
*/
/** @type {PwaState} */
const initialState = {
needRefresh: false,
dialogOpen: false,
offlineReady: false,
updating: false,
};
let swUpdater = null;
/**
* State store for PWA lifecycle & update notifications.
* Registered on window.__AUTOMATS__ as 'pwa'.
*
* @type {Automat<PwaState>}
*/
const pwaAutomat = new Automat(
initialState,
{
setRegistration(updater) {
swUpdater = updater;
},
setNeedRefresh() {
pwaAutomat.setState({
needRefresh: true,
dialogOpen: true,
});
},
setOfflineReady() {
pwaAutomat.setState({
offlineReady: true,
});
},
openDialog() {
pwaAutomat.setState({ dialogOpen: true });
},
closeDialog() {
pwaAutomat.setState({ dialogOpen: false });
},
async applyUpdate() {
pwaAutomat.setState({ updating: true });
if (typeof swUpdater === 'function') {
try {
await swUpdater(true);
} catch (err) {
console.error('[PWA] Error activating update:', err);
window.location.reload();
}
} else {
window.location.reload();
}
},
// Allows testing the dialog in dev mode or in browser console:
// window.__AUTOMATS__.pwa.actions.simulateUpdateAvailable()
simulateUpdateAvailable() {
pwaAutomat.setState({
needRefresh: true,
dialogOpen: true,
});
},
},
{ name: 'pwa' },
);
/**
* Initializes PWA service worker registration in production.
*/
export function initPwa() {
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return;
import('virtual:pwa-register')
.then(({ registerSW }) => {
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
pwaAutomat.actions.setNeedRefresh();
},
onOfflineReady() {
pwaAutomat.actions.setOfflineReady();
},
onRegistered(registration) {
if (!registration) return;
// Check for new updates periodically (every 30 minutes)
setInterval(() => {
registration.update().catch(() => {});
}, 30 * 60 * 1000);
// Check for updates when tab or PWA returns to foreground
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
registration.update().catch(() => {});
}
});
},
});
pwaAutomat.actions.setRegistration(updateSW);
})
.catch((err) => {
console.warn('[PWA] Service worker registration failed or omitted:', err);
});
}
export const openPwaDialog = () => pwaAutomat.actions.openDialog();
export const closePwaDialog = () => pwaAutomat.actions.closeDialog();
export const applyPwaUpdate = () => pwaAutomat.actions.applyUpdate();
export default pwaAutomat;

View File

@@ -0,0 +1,114 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import CircularProgress from '@mui/material/CircularProgress';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import AppDialog from './AppDialog';
import pwaAutomat from '../automats/pwaAutomat';
/**
* PWA Update prompt dialog.
* Appears when a new service worker update has been downloaded in the background.
* Allows the user to reload the page to apply the update immediately,
* or postpone it ("Später") without losing ongoing form input.
*/
export default class PwaUpdateDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
dialogOpen: pwaAutomat.state.dialogOpen,
updating: pwaAutomat.state.updating,
};
this.handleUpdate = this.handleUpdate.bind(this);
this.handleClose = this.handleClose.bind(this);
}
componentDidMount() {
this.unsub = pwaAutomat.subscribe(this, ['dialogOpen', 'updating']);
}
componentWillUnmount() {
this.unsub?.();
}
handleClose() {
if (this.state.updating) return;
pwaAutomat.actions.closeDialog();
}
handleUpdate() {
pwaAutomat.actions.applyUpdate();
}
render() {
const { dialogOpen, updating } = this.state;
return (
<AppDialog
open={dialogOpen}
onClose={this.handleClose}
maxWidth="xs"
fullWidth
aria-labelledby="pwa-update-dialog-title"
>
<DialogTitle
id="pwa-update-dialog-title"
sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pb: 1 }}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 38,
height: 38,
borderRadius: '50%',
bgcolor: 'primary.main',
color: 'primary.contrastText',
}}
>
<SystemUpdateAltIcon fontSize="small" />
</Box>
Aktualisierung verfügbar
</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: 'text.primary', mb: 1, fontWeight: 500 }}>
Eine neue Version von <strong>Dashfront</strong> ist bereit.
</DialogContentText>
<DialogContentText variant="body2" color="text.secondary">
Möchten Sie die Anwendung jetzt neu laden, um die Änderungen zu übernehmen?
Stellen Sie sicher, dass wichtige Eingaben gespeichert sind.
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5, pt: 1, gap: 1 }}>
<Button
onClick={this.handleClose}
color="inherit"
disabled={updating}
sx={{ textTransform: 'none' }}
>
Später
</Button>
<Button
onClick={this.handleUpdate}
variant="contained"
disabled={updating}
startIcon={
updating ? <CircularProgress size={16} color="inherit" /> : null
}
autoFocus
sx={{ textTransform: 'none', px: 2 }}
>
{updating ? 'Aktualisiere…' : 'Jetzt neu laden'}
</Button>
</DialogActions>
</AppDialog>
);
}
}

View File

@@ -1,5 +1,6 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
@@ -10,10 +11,11 @@ import LogoutIcon from '@mui/icons-material/Logout';
import BusinessIcon from '@mui/icons-material/Business'; import BusinessIcon from '@mui/icons-material/Business';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'; import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
import { helpAutomat, toggleHelp } from '../automats'; import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats';
import CompanySettingsDialog from './CompanySettingsDialog'; import CompanySettingsDialog from './CompanySettingsDialog';
import SettingsDialog from './settings/SettingsDialog'; import SettingsDialog from './settings/SettingsDialog';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
@@ -27,6 +29,7 @@ class UserMenu extends PureComponent {
numbersOpen: false, numbersOpen: false,
user: authAutomat.state.user, user: authAutomat.state.user,
helpEnabled: helpAutomat.state?.enabled ?? false, helpEnabled: helpAutomat.state?.enabled ?? false,
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
}; };
this.handleOpenMenu = this.handleOpenMenu.bind(this); this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this); this.handleCloseMenu = this.handleCloseMenu.bind(this);
@@ -35,6 +38,7 @@ class UserMenu extends PureComponent {
this.handleOpenNumbers = this.handleOpenNumbers.bind(this); this.handleOpenNumbers = this.handleOpenNumbers.bind(this);
this.handleCloseNumbers = this.handleCloseNumbers.bind(this); this.handleCloseNumbers = this.handleCloseNumbers.bind(this);
this.handleToggleHelp = this.handleToggleHelp.bind(this); this.handleToggleHelp = this.handleToggleHelp.bind(this);
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
this.handleLogout = this.handleLogout.bind(this); this.handleLogout = this.handleLogout.bind(this);
} }
@@ -43,11 +47,20 @@ class UserMenu extends PureComponent {
this.unsubscribeHelp = helpAutomat.subscribe(this, (state) => ({ this.unsubscribeHelp = helpAutomat.subscribe(this, (state) => ({
helpEnabled: Boolean(state?.enabled), helpEnabled: Boolean(state?.enabled),
})); }));
this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({
pwaUpdateAvailable: Boolean(state?.needRefresh),
}));
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubscribeAuth?.(); this.unsubscribeAuth?.();
this.unsubscribeHelp?.(); this.unsubscribeHelp?.();
this.unsubscribePwa?.();
}
handleOpenPwaUpdate() {
this.setState({ anchorEl: null });
openPwaDialog();
} }
handleOpenMenu(event) { handleOpenMenu(event) {
@@ -91,7 +104,7 @@ class UserMenu extends PureComponent {
} }
render() { render() {
const { user, anchorEl, settingsOpen, numbersOpen, helpEnabled } = this.state; const { user, anchorEl, settingsOpen, numbersOpen, helpEnabled, pwaUpdateAvailable } = this.state;
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const pictureSrc = (() => { const pictureSrc = (() => {
@@ -112,9 +125,17 @@ class UserMenu extends PureComponent {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }} sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
> >
<Avatar src={pictureSrc} alt={user?.email} sx={{ width: 32, height: 32 }}> <Badge
{user?.email?.[0]?.toUpperCase()} color="primary"
</Avatar> variant="dot"
invisible={!pwaUpdateAvailable}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<Avatar src={pictureSrc} alt={user?.email} sx={{ width: 32, height: 32 }}>
{user?.email?.[0]?.toUpperCase()}
</Avatar>
</Badge>
</IconButton> </IconButton>
</Box> </Box>
<Menu <Menu
@@ -124,6 +145,18 @@ class UserMenu extends PureComponent {
anchorOrigin={{ vertical: 'top', horizontal: 'left' }} anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
> >
{pwaUpdateAvailable && (
<MenuItem onClick={this.handleOpenPwaUpdate} sx={{ bgcolor: 'action.hover' }}>
<ListItemIcon>
<SystemUpdateAltIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary="Update verfügbar"
secondary="Jetzt aktualisieren"
primaryTypographyProps={{ fontWeight: 600, color: 'primary.main' }}
/>
</MenuItem>
)}
<MenuItem disabled>{user?.email}</MenuItem> <MenuItem disabled>{user?.email}</MenuItem>
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenSettings}> <MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenSettings}>
<ListItemIcon> <ListItemIcon>

View File

@@ -11,6 +11,8 @@ import { installCssVars } from './colors';
// Side-effect imports: each module registers its automat and subscribes to // Side-effect imports: each module registers its automat and subscribes to
// authAutomat, so the stores must be evaluated before the session bootstraps. // authAutomat, so the stores must be evaluated before the session bootstraps.
import './automats'; import './automats';
import { initPwa } from './automats';
import PwaUpdateDialog from './components/PwaUpdateDialog';
import './index.css'; import './index.css';
// Publish the color tokens as CSS custom properties so index.css (and any // Publish the color tokens as CSS custom properties so index.css (and any
@@ -37,18 +39,8 @@ if (import.meta.env.DEV) {
void clearDevServiceWorker(); void clearDevServiceWorker();
} }
// PWA auto-update: reload the page as soon as a new build's service worker // Initialize PWA service worker registration and update listener in production
// takes control. The virtual module only exists in production builds (the initPwa();
// plugin is registered for `build` only), hence the dynamic import.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
import('virtual:pwa-register')
.then(({ registerSW }) => {
registerSW({ immediate: true });
})
.catch(() => {
// No SW in this build — app still works, just without offline support.
});
}
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<ThemeProvider theme={getActiveTheme()}> <ThemeProvider theme={getActiveTheme()}>
@@ -71,6 +63,7 @@ createRoot(document.getElementById('root')).render(
</Suspense> </Suspense>
) : null} ) : null}
</Box> </Box>
<PwaUpdateDialog />
</AuthBootstrap> </AuthBootstrap>
</SnackbarProvider> </SnackbarProvider>
</ThemeProvider>, </ThemeProvider>,

View File

@@ -289,12 +289,9 @@ export default defineConfig(({ command }) => ({
...(command === 'build' ...(command === 'build'
? [ ? [
VitePWA({ VitePWA({
// autoUpdate: the SW calls clientsClaim()/skipWaiting() itself, so a // prompt: new service worker waits in the background until the user
// new build takes over every open tab on its own. // confirms update via the PWA update dialog.
registerType: 'autoUpdate', registerType: 'prompt',
// Registration is done explicitly in src/main.jsx via
// `virtual:pwa-register` with `immediate: true`; that is what makes
// the *page* reload (a bare injected script only swaps the SW).
injectRegister: false, injectRegister: false,
manifest: { manifest: {
id: '/', id: '/',
@@ -325,12 +322,6 @@ export default defineConfig(({ command }) => ({
], ],
}, },
workbox: { workbox: {
// NOTE: vite-plugin-pwa only auto-sets these two when
// injectRegister === 'auto'. Because we register manually in
// src/main.jsx (injectRegister: false), they must be explicit —
// otherwise a new SW waits forever behind the old one and no tab
// is ever claimed.
skipWaiting: true,
clientsClaim: true, clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
globIgnores: ['**/*.wasm'], globIgnores: ['**/*.wasm'],