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
+185
View File
@@ -0,0 +1,185 @@
import React, { useMemo, useState } from 'react';
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { BILLABLE, CountUp, Legend, NON_BILLABLE, StackedBars } from '../ui/charts';
import { ReportTable } from './ReportTable';
import { ExportMenu } from './ExportMenu';
import { ReportKpi, ReportRow } from './ProjectReport';
import { useAuth } from '../../lib/auth';
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS } from '../../types';
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
const { navigate } = useRouter();
const customers = useLoader(() => customersApi.list(), []);
const report = useLoader(() => (id ? reportsApi.customer(id) : Promise.resolve(null)), [id]);
return (
<div className="space-y-4">
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/kunde/${e.target.value}` : 'reports/kunde')}>
<option value="">Kunde wählen…</option>
{(customers.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{!id ? (
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
) : report.error ? (
<ErrorBox message={report.error} onRetry={report.reload} />
) : !report.data ? (
<Spinner label="Lade Kundenreport…" />
) : (
<Body key={id} data={report.data} />
)}
</div>
);
};
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
const { settings } = useAuth();
const { navigate } = useRouter();
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
const [months, setMonths] = useState(12);
const c = data.customer;
const t = data.totals;
const color = colorFor(c.id, c.color);
const month = todayIso().slice(0, 7);
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
const exportOptions = [
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Vorlage für den Kunden, alle Berater', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', customer_id: c.id })}`, filename: `${c.short_name}-${month}.pdf` },
{ label: 'Excel – Stundennachweis mit Beträgen', hint: 'Für die Rechnungsstellung', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', customer_id: c.id, amounts: 1 })}`, filename: `${c.short_name}-${month}.xlsx` },
{ label: 'PDF – Projekte (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
{ label: 'CSV – alle Buchungen (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
];
return (
<div className="space-y-4">
<div className="animate-rise flex flex-wrap items-center gap-4">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl text-lg font-extrabold text-white shadow-card" style={{ background: color }}>
{c.short_name.slice(0, 2)}
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
<p className="text-sm text-ink-500">
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
</p>
</div>
<ExportMenu options={exportOptions} />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ReportKpi index={1} title="Gesamtstunden">
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
</ReportKpi>
<ReportKpi index={2} title="Umsatz erbracht">
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
</ReportKpi>
<ReportKpi index={3} title="Umsatz offen">
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="in laufenden Töpfen" value={`${data.projects.reduce((s, p) => s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} />
</ReportKpi>
<ReportKpi index={4} title="Budget gesamt">
<CountUp value={t.budget_value} format={(v) => formatEuro(v, 0)} className="text-3xl font-extrabold text-ink-900" />
<BudgetBar className="mt-3" percent={t.budget_value ? (t.earned / t.budget_value) * 100 : null} warnAt={settings.budget_warn_percent} />
</ReportKpi>
</div>
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 5 } as React.CSSProperties}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-base text-ink-900">Stunden je Monat</h3>
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
</div>
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
<option value={6}>6 Monate</option>
<option value={12}>12 Monate</option>
<option value={24}>24 Monate</option>
</select>
</div>
{series.length ? (
<StackedBars
scale={60}
points={series.map((m) => ({ key: m.month, label: `${MONTHS[Number(m.month.slice(5)) - 1].slice(0, 3)} ${m.month.slice(2, 4)}`, a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
format={(v) => `${formatHours(v, 1)} h`}
axisFormat={(v) => formatNumber(v / 60, 0)}
height={220}
/>
) : (
<p className="py-10 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
)}
</Card>
<Card className="animate-rise overflow-hidden" style={{ '--i': 6 } as React.CSSProperties}>
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
{(
[
['projects', 'Projekte'],
['team', 'Team'],
['tasks', 'Tasks'],
] as const
).map(([key, label]) => (
<button key={key} onClick={() => setTab(key)} className={`relative px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
{label}
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
</button>
))}
</div>
{tab === 'team' && <ReportTable rows={data.team} kind="team" label="Name" />}
{tab === 'tasks' && <ReportTable rows={data.tasks} kind="tasks" label="Task" />}
{tab === 'projects' && (
<div className="divide-y divide-ink-100">
{data.projects.length === 0 && <p className="p-6 text-center text-sm text-ink-400">Keine Projekte.</p>}
{data.projects.map(({ project: p, totals, pots }, i) => (
<button key={p.id} onClick={() => navigate(`reports/projekt/${p.id}`)} className="animate-rise block w-full p-4 text-left hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="tabular text-[11px] font-bold text-ink-400">{p.code}</p>
<p className="flex items-center gap-2 font-extrabold text-ink-900">
{p.display_name}
<Icon name="chevronRight" className="h-4 w-4 text-ink-300" />
</p>
</div>
<div className="flex items-center gap-2">
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
{!p.is_active && <Badge>Archiv</Badge>}
</div>
</div>
<div className="tabular mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
<ReportRow label="Stunden" value={formatHours(totals.minutes)} />
<ReportRow label="Budget" value={p.budget_hours == null ? '∞' : `${formatNumber(p.budget_hours, 0)} h`} />
<ReportRow label="Erbracht" value={formatEuro(p.earned, 0)} />
<ReportRow label="Offen" value={formatEuro(p.open, 0)} />
</div>
<BudgetBar className="mt-2" percent={p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null} warnAt={settings.budget_warn_percent} />
{pots.length > 1 && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{pots.map((pot) => (
<div key={pot.id} className="rounded-xl border border-ink-200 p-2.5">
<p className="flex justify-between gap-2 text-xs font-bold text-ink-700">
<span className="truncate">{pot.label}</span>
<span className="tabular text-ink-500">{formatHours(pot.used_minutes, 1)} h</span>
</p>
<BudgetBar className="mt-1.5" height="h-1.5" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
</div>
))}
</div>
)}
</button>
))}
</div>
)}
</Card>
</div>
);
};