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:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { Donut } from '../ui/charts';
import { EntryModal } from './EntryModal';
import { ExportDialog } from './ExportDialog';
import { WeekView } from './WeekView';
import { MonthView } from './MonthView';
import { YearView } from './YearView';
import { useAuth } from '../../lib/auth';
import { entriesApi, projectsApi, usersApi } from '../../lib/api';
import { useIsMobile, useLoader, usePersistentState, useCountUp } from '../../lib/hooks';
import {
MONTHS,
addDays,
addMonths,
formatDate,
formatDuration,
isoWeek,
mondayOf,
monthEnd,
monthStart,
todayIso,
weekdayIndex,
} from '../../lib/format';
import { visibleTeam } from '../team/hierarchy';
import { atLeast, type DirectoryUser, type Project, type TimeEntry } from '../../types';
export type BookingMode = 'week' | 'month' | 'year';
function rangeFor(mode: BookingMode, anchor: string) {
if (mode === 'week') {
const from = mondayOf(anchor);
return { from, to: addDays(from, 6) };
}
if (mode === 'month') return { from: monthStart(anchor), to: monthEnd(anchor) };
return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31` };
}
/** Arbeitstage Mo-Fr im Intervall, fuer das Soll. */
function workdays(from: string, to: string) {
let count = 0;
for (let d = from; d <= to; d = addDays(d, 1)) if (weekdayIndex(d) < 5) count++;
return count;
}
export const Booking: React.FC = () => {
const { user, settings } = useAuth();
const isMobile = useIsMobile();
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
const [anchor, setAnchor] = useState(todayIso());
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [viewUserId, setViewUserId] = useState<number>(user!.id);
const isLead = atLeast(user, 'lead');
const readOnly = viewUserId !== user!.id;
const range = rangeFor(mode, anchor);
const entries = useLoader(() => entriesApi.list(range.from, range.to, viewUserId), [range.from, range.to, viewUserId]);
const projects = useLoader(() => projectsApi.list('mine'), []);
const [team, setTeam] = useState<DirectoryUser[]>([]);
const [allProjects, setAllProjects] = useState<Project[] | null>(null);
useEffect(() => {
if (!isLead) return;
usersApi
.directory()
.then((list) => setTeam(visibleTeam(list, user!)))
.catch(() => undefined);
}, [isLead, user]);
const list = entries.data ?? [];
const total = list.reduce((s, e) => s + e.minutes, 0);
const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const viewedUser = team.find((t) => t.id === viewUserId);
const weekly = (readOnly ? null : user!.weekly_hours) ?? settings.weekly_hours;
const today = todayIso();
// Soll bis heute (bzw. ganzer Zeitraum, wenn er vorbei ist).
const targetTo = range.to < today ? range.to : today < range.from ? range.from : today;
const target = today < range.from ? 0 : (weekly / 5) * workdays(range.from, targetTo) * 60;
const shift = (step: number) => {
if (mode === 'week') setAnchor(addDays(anchor, 7 * step));
else if (mode === 'month') setAnchor(addMonths(anchor, step));
else setAnchor(`${Number(anchor.slice(0, 4)) + step}-${anchor.slice(5)}`);
};
const label =
mode === 'week'
? `KW ${isoWeek(range.from)} · ${formatDate(range.from).slice(0, 6)} – ${formatDate(range.to)}`
: mode === 'month'
? `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}`
: anchor.slice(0, 4);
const openNew = (date?: string) => {
if (readOnly) return;
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
};
const openExport = () => {
if (isLead && !allProjects) projectsApi.list().then(setAllProjects).catch(() => undefined);
setExportOpen(true);
};
// Tastenkuerzel: N = neue Buchung, Pfeile = blaettern.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (modal || exportOpen) return;
const target = e.target as HTMLElement;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
if (e.key === 'n' || e.key === '+') {
e.preventDefault();
openNew();
}
if (e.key === 'ArrowLeft') shift(-1);
if (e.key === 'ArrowRight') shift(1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const warnings = useMemo(
() => (projects.data ?? []).flatMap((p) => p.pots.filter((pot) => pot.warning && pot.status !== 'expired' && pot.valid_to >= today).map((pot) => ({ project: p, pot }))),
[projects.data, today]
);
return (
<div className="mx-auto max-w-[1500px]">
{/* Werkzeugleiste */}
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Zurück">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<button onClick={() => setAnchor(todayIso())} className="border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 hover:bg-ink-50">
Heute
</button>
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Weiter">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
<div className="order-last flex w-full items-center gap-2 sm:order-none sm:ml-auto sm:w-auto">
<Segmented
value={mode}
onChange={setMode}
className="flex-1 sm:flex-none"
options={[
{ value: 'week', label: 'Woche' },
{ value: 'month', label: 'Monat' },
{ value: 'year', label: 'Jahr' },
]}
/>
{isLead && team.length > 1 && (
<select className={`${inputClass} w-auto max-w-[180px] py-2 text-xs font-bold`} value={viewUserId} onChange={(e) => setViewUserId(Number(e.target.value))} title="Zeiten ansehen von">
{team.map((t) => (
<option key={t.id} value={t.id}>
{t.id === user!.id ? 'Meine Zeiten' : t.name}
</option>
))}
</select>
)}
<Button variant="secondary" icon="download" onClick={openExport} className="py-2">
<span className="hidden sm:inline">Export</span>
</Button>
{!isMobile && !readOnly && (
<button
onClick={() => openNew()}
title="Zeit buchen (N)"
aria-label="Zeit buchen"
className="animate-glow flex h-11 w-11 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition hover:scale-105 hover:bg-brand-600 active:scale-95"
>
<Icon name="plus" className="h-6 w-6" strokeWidth={2.6} />
</button>
)}
</div>
</div>
{readOnly && viewedUser && (
<div className="animate-fade mb-4 flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-700">
<Avatar name={viewedUser.name} color={viewedUser.color} id={viewedUser.id} size={28} />
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
<button className="ml-auto text-xs font-bold underline" onClick={() => setViewUserId(user!.id)}>
Zurück zu meinen Zeiten
</button>
</div>
)}
{!readOnly && warnings.length > 0 && (
<div className="animate-fade mb-4 space-y-2">
{warnings.slice(0, 3).map(({ project, pot }) => (
<div key={pot.id} className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs font-semibold text-amber-700">
<Icon name="warning" className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<b>{project.display_name}</b> – {pot.warning}
{pot.remaining_hours != null && ` Noch ${pot.remaining_hours.toLocaleString('de-DE')} h frei.`}
</span>
</div>
))}
</div>
)}
<SummaryStrip total={total} billable={billable} target={target} weekly={weekly} mode={mode} count={list.length} />
{entries.error && <ErrorBox message={entries.error} onRetry={entries.reload} />}
{entries.loading && !entries.data ? (
<Spinner />
) : (
<div key={`${mode}-${range.from}`} className="animate-fade">
{mode === 'week' && (
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} />
)}
{mode === 'month' && (
<MonthView
month={range.from}
entries={list}
dailyTarget={(weekly / 5) * 60}
onOpenDay={(date) => {
setAnchor(date);
setMode('week');
}}
onAdd={openNew}
readOnly={readOnly}
/>
)}
{mode === 'year' && (
<YearView
year={anchor.slice(0, 4)}
entries={list}
weeklyTarget={weekly}
onOpenMonth={(month) => {
setAnchor(`${month}-01`);
setMode('month');
}}
/>
)}
</div>
)}
{isMobile && !readOnly && (
<button
onClick={() => openNew()}
aria-label="Zeit buchen"
className="animate-glow fixed bottom-24 right-5 z-40 flex h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
>
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
</button>
)}
{modal && projects.data && (
<EntryModal projects={projects.data} date={modal.date} entry={modal.entry} onClose={() => setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} />
)}
{exportOpen && (
<ExportDialog
month={anchor.slice(0, 7)}
projects={isLead ? allProjects ?? projects.data ?? [] : projects.data ?? []}
team={team}
viewUserId={viewUserId}
onClose={() => setExportOpen(false)}
/>
)}
</div>
);
};
const SummaryStrip: React.FC<{ total: number; billable: number; target: number; weekly: number; mode: BookingMode; count: number }> = ({
total,
billable,
target,
weekly,
mode,
count,
}) => {
const shown = useCountUp(total);
const percent = target > 0 ? Math.round((total / target) * 100) : 0;
const diff = total - target;
return (
<Card className="animate-rise mb-4 flex flex-wrap items-center gap-4 p-4 sm:gap-6 sm:p-5">
<Donut percent={Math.min(100, percent)} size={isFinite(percent) ? 76 : 76} stroke={9} track="var(--color-ink-100)" label="Soll" />
<div className="min-w-[120px]">
<p className="text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">
Gebucht {mode === 'week' ? 'diese Woche' : mode === 'month' ? 'im Monat' : 'im Jahr'}
</p>
<p className="tabular text-3xl font-extrabold text-ink-900">
{formatDuration(shown)}
<span className="ml-1 text-base text-ink-400">h</span>
</p>
</div>
<div className="grid w-full grid-cols-3 gap-2 sm:w-auto sm:max-w-xl sm:flex-1 sm:gap-3">
<Mini label="Soll bis heute" value={`${formatDuration(target)} h`} hint={`${weekly} h/Woche`} />
<Mini label="Differenz" value={`${diff >= 0 ? '+' : '−'}${formatDuration(Math.abs(diff))} h`} tone={diff >= 0 ? 'good' : 'bad'} />
<Mini label="Billable" value={total ? `${Math.round((billable / total) * 100)} %` : '–'} hint={`${count} Buchungen`} />
</div>
</Card>
);
};
const Mini: React.FC<{ label: string; value: string; hint?: string; tone?: 'good' | 'bad' }> = ({ label, value, hint, tone }) => (
<div className="min-w-0 rounded-xl bg-ink-50 px-3 py-2">
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-400">{label}</p>
<p className={`tabular truncate text-sm font-extrabold sm:text-base ${tone === 'good' ? 'text-brand-700' : tone === 'bad' ? 'text-red-600' : 'text-ink-900'}`}>{value}</p>
{hint && <p className="truncate text-[10px] text-ink-400">{hint}</p>}
</div>
);