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,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>
|
||||
);
|
||||
Reference in New Issue
Block a user