feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { authApi } from './api';
|
||||
import { useAuth } from './auth';
|
||||
import { THEME_PALETTES, type ThemePalette } from '../types';
|
||||
import { RainyWeather } from '../components/layout/RainyWeather';
|
||||
import { SnowWeather } from '../components/layout/SnowWeather';
|
||||
import { AuraSky } from '../components/layout/AuraSky';
|
||||
|
||||
/*
|
||||
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
|
||||
*
|
||||
* Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy | snow | aura
|
||||
* -> Klasse theme-* am <html>
|
||||
* Modus (mode) hell | dunkel -> Klasse dark am <html>
|
||||
*
|
||||
* Beide gehoeren zum **Benutzer** und nicht zum Browser: sie stehen in
|
||||
* pulse_users.theme und pulse_users.theme_mode. Wer sich an einem anderen Rechner
|
||||
* anmeldet, findet seine Wahl wieder, und melden sich zwei Leute an derselben
|
||||
* Maschine an, sieht jeder seine eigene.
|
||||
*
|
||||
* Der localStorage bleibt als Zwischenspeicher daneben liegen, aber nicht als
|
||||
* Quelle der Wahrheit: er wird vom Inline-Skript in index.html gelesen, um die
|
||||
* Klassen vor dem ersten Frame zu setzen. Ohne das blitzt beim Laden das
|
||||
* Standarddesign auf, weil das Bundle erst danach laeuft. Er hat genau eine
|
||||
* sichtbare Folge: melden sich zwei Benutzer an demselben Rechner an, sieht der
|
||||
* zweite fuer einen Augenblick das Design des ersten.
|
||||
*/
|
||||
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
/** null = der Systemeinstellung folgen */
|
||||
type ModePreference = ThemeMode | null;
|
||||
|
||||
const MODE_KEY = 'pulse_theme';
|
||||
const PALETTE_KEY = 'pulse_palette';
|
||||
|
||||
const DEFAULT_PALETTE: ThemePalette = 'perinco';
|
||||
|
||||
interface ThemeContextValue {
|
||||
/** Tatsaechlich aktive Darstellung. */
|
||||
theme: ThemeMode;
|
||||
/** Gespeicherte Wahl des Modus; null bedeutet "folgt dem System". */
|
||||
preference: ModePreference;
|
||||
/** Aktive Farbwelt. */
|
||||
palette: ThemePalette;
|
||||
/** Schaltet explizit auf den jeweils anderen Modus. */
|
||||
toggle: () => void;
|
||||
/** Zurueck zur Systemeinstellung. */
|
||||
useSystem: () => void;
|
||||
/** Farbwelt wechseln. */
|
||||
setPalette: (palette: ThemePalette) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function readCachedMode(): ModePreference {
|
||||
try {
|
||||
const stored = localStorage.getItem(MODE_KEY);
|
||||
return stored === 'light' || stored === 'dark' ? stored : null;
|
||||
} catch {
|
||||
// Privater Modus ohne localStorage - dann eben ohne Zwischenspeicher.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedPalette(): ThemePalette {
|
||||
try {
|
||||
const stored = localStorage.getItem(PALETTE_KEY);
|
||||
return THEME_PALETTES.includes(stored as ThemePalette)
|
||||
? (stored as ThemePalette)
|
||||
: DEFAULT_PALETTE;
|
||||
} catch {
|
||||
return DEFAULT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt in den Zwischenspeicher fuers naechste Laden; Fehler sind egal. */
|
||||
function cache(key: string, value: string | null) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Nicht speicherbar - dann blitzt beim naechsten Laden eben kurz das
|
||||
// Standarddesign auf. Die Wahl selbst liegt in der Datenbank.
|
||||
}
|
||||
}
|
||||
|
||||
function systemTheme(): ThemeMode {
|
||||
return typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Klassen, an denen die Farbwerte in index.css haengen. Das
|
||||
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame.
|
||||
*
|
||||
* Die Klassen aller anderen Farbwelten werden entfernt, nicht nur die eigene
|
||||
* gesetzt: sonst bliebe beim Wechsel die vorige stehen und zwei Themes
|
||||
* ueberlagerten sich.
|
||||
*/
|
||||
function applyTheme(mode: ThemeMode, palette: ThemePalette) {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', mode === 'dark');
|
||||
root.style.colorScheme = mode;
|
||||
|
||||
for (const candidate of THEME_PALETTES) {
|
||||
root.classList.toggle(`theme-${candidate}`, candidate === palette);
|
||||
}
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
const [preference, setPreference] = useState<ModePreference>(() => readCachedMode());
|
||||
const [palette, setPaletteState] = useState<ThemePalette>(() => readCachedPalette());
|
||||
const [resolvedSystem, setResolvedSystem] = useState<ThemeMode>(() => systemTheme());
|
||||
|
||||
const theme = preference ?? resolvedSystem;
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme, palette);
|
||||
}, [theme, palette]);
|
||||
|
||||
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!query) return;
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
setResolvedSystem(event.matches ? 'dark' : 'light');
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* Die Wahl des angemeldeten Benutzers uebernehmen - einmal je Anmeldung.
|
||||
*
|
||||
* Der Merker ist wichtig: `user` aendert sich auch, wenn jemand sein Foto
|
||||
* tauscht oder gerade das Design umstellt (updateUser). Ohne ihn wuerde
|
||||
* dieser Effekt bei jeder solchen Aenderung erneut laufen und koennte eine
|
||||
* gerade getroffene Wahl mit dem alten Stand ueberschreiben.
|
||||
*/
|
||||
const syncedForUser = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
// Abmelden: die Anzeige bleibt, wie sie ist. Der naechste Benutzer
|
||||
// bekommt beim Anmelden seine eigene Wahl.
|
||||
syncedForUser.current = null;
|
||||
return;
|
||||
}
|
||||
if (syncedForUser.current === user.id) return;
|
||||
syncedForUser.current = user.id;
|
||||
|
||||
const nextPalette = THEME_PALETTES.includes(user.theme as ThemePalette)
|
||||
? (user.theme as ThemePalette)
|
||||
: DEFAULT_PALETTE;
|
||||
const nextMode: ModePreference =
|
||||
user.theme_mode === 'light' || user.theme_mode === 'dark' ? user.theme_mode : null;
|
||||
|
||||
setPaletteState(nextPalette);
|
||||
setPreference(nextMode);
|
||||
cache(PALETTE_KEY, nextPalette === DEFAULT_PALETTE ? null : nextPalette);
|
||||
cache(MODE_KEY, nextMode);
|
||||
}, [user]);
|
||||
|
||||
/*
|
||||
* Speichern am Benutzer. Die Anzeige wechselt sofort und wartet nicht auf das
|
||||
* Backend - ein Design, das nach dem Klick eine halbe Sekunde ueberlegt,
|
||||
* fuehlt sich kaputt an. Schlaegt das Speichern fehl, gilt die Wahl fuer
|
||||
* diese Sitzung und ist nach dem naechsten Anmelden weg; das ist harmlos und
|
||||
* deshalb keine Fehlermeldung wert.
|
||||
*/
|
||||
const persist = useCallback(
|
||||
(changes: { theme?: ThemePalette | null; theme_mode?: ModePreference }) => {
|
||||
if (!user) return;
|
||||
authApi
|
||||
.updateMe(changes)
|
||||
.then(({ user: saved }) => updateUser(saved))
|
||||
.catch((err) => {
|
||||
console.warn('[Design] Konnte nicht gespeichert werden:', err);
|
||||
});
|
||||
},
|
||||
[user, updateUser]
|
||||
);
|
||||
|
||||
const storeMode = useCallback(
|
||||
(next: ModePreference) => {
|
||||
setPreference(next);
|
||||
cache(MODE_KEY, next);
|
||||
persist({ theme_mode: next });
|
||||
},
|
||||
[persist]
|
||||
);
|
||||
|
||||
const setPalette = useCallback(
|
||||
(next: ThemePalette) => {
|
||||
setPaletteState(next);
|
||||
cache(PALETTE_KEY, next === DEFAULT_PALETTE ? null : next);
|
||||
// Der Standard wird als null gespeichert, damit "keine Wahl getroffen"
|
||||
// und "ausdruecklich perinco" in der Datenbank dasselbe bedeuten.
|
||||
persist({ theme: next === DEFAULT_PALETTE ? null : next });
|
||||
},
|
||||
[persist]
|
||||
);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
preference,
|
||||
palette,
|
||||
toggle: () => storeMode(theme === 'dark' ? 'light' : 'dark'),
|
||||
useSystem: () => storeMode(null),
|
||||
setPalette,
|
||||
}),
|
||||
[theme, preference, palette, storeMode, setPalette]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
{/* Wetter und Himmel gehoeren zum Theme, nicht zu einer Ansicht. */}
|
||||
{palette === 'rainy' && <RainyWeather />}
|
||||
{palette === 'snow' && <SnowWeather />}
|
||||
{palette === 'aura' && <AuraSky />}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user