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,260 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ExportMenu } from './ExportMenu';
|
||||
import { CustomerReport } from './CustomerReport';
|
||||
import { ProjectReport } from './ProjectReport';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { customersApi, qs, reportsApi, usersApi } from '../../lib/api';
|
||||
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||
import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { atLeast, type Customer, type DirectoryUser } from '../../types';
|
||||
|
||||
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||||
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
|
||||
|
||||
function periodRange(period: Period, anchor: string, custom: { from: string; to: string }) {
|
||||
if (period === 'week') {
|
||||
const from = mondayOf(anchor);
|
||||
return { from, to: addDays(from, 6), label: `KW ${isoWeek(from)} · ${formatDate(from)} – ${formatDate(addDays(from, 6))}` };
|
||||
}
|
||||
if (period === 'month') return { from: monthStart(anchor), to: monthEnd(anchor), label: `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` };
|
||||
if (period === 'quarter') {
|
||||
const q = Math.floor((Number(anchor.slice(5, 7)) - 1) / 3);
|
||||
const from = `${anchor.slice(0, 4)}-${String(q * 3 + 1).padStart(2, '0')}-01`;
|
||||
return { from, to: monthEnd(addMonths(from, 2)), label: `Q${q + 1} ${anchor.slice(0, 4)}` };
|
||||
}
|
||||
if (period === 'year') return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31`, label: anchor.slice(0, 4) };
|
||||
return { ...custom, label: `${formatDate(custom.from)} – ${formatDate(custom.to)}` };
|
||||
}
|
||||
|
||||
export const Reports: React.FC = () => {
|
||||
const { route, navigate } = useRouter();
|
||||
const [kind, id] = route.params;
|
||||
const view = kind === 'kunde' ? 'customer' : kind === 'projekt' ? 'project' : 'time';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<div className="no-scrollbar mb-4 flex items-center gap-1 overflow-x-auto overflow-y-hidden border-b border-ink-200">
|
||||
{(
|
||||
[
|
||||
['time', 'Zeitreport', 'reports'],
|
||||
['customer', 'Kundenreport', 'reports/kunde'],
|
||||
['project', 'Projektreport', 'reports/projekt'],
|
||||
] as const
|
||||
).map(([key, label, path]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => navigate(path)}
|
||||
className={`relative whitespace-nowrap px-4 py-2.5 text-sm font-bold transition ${view === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}
|
||||
>
|
||||
{label}
|
||||
{view === key && <span className="animate-pop absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-brand-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === 'time' && <TimeReport />}
|
||||
{view === 'customer' && <CustomerReport id={id ? Number(id) : null} />}
|
||||
{view === 'project' && <ProjectReport id={id ? Number(id) : null} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeReport: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { navigate } = useRouter();
|
||||
const [period, setPeriod] = usePersistentState<Period>('pulse_report_period', 'month');
|
||||
const [anchor, setAnchor] = useState(todayIso());
|
||||
const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() });
|
||||
const [tab, setTab] = usePersistentState<Tab>('pulse_report_tab', 'users');
|
||||
const [customerId, setCustomerId] = useState<number | ''>('');
|
||||
const [team, setTeam] = useState<number | ''>('');
|
||||
const [includeFixed, setIncludeFixed] = useState(false);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [leads, setLeads] = useState<DirectoryUser[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list().then(setCustomers).catch(() => undefined);
|
||||
usersApi
|
||||
.directory()
|
||||
.then((list) => setLeads(list.filter((u) => (u.role === 'lead' || u.role === 'management') && u.is_active)))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const range = periodRange(period, anchor, custom);
|
||||
const report = useLoader(
|
||||
() => reportsApi.time({ from: range.from, to: range.to, customer_id: customerId || null, team: team || null, include_fixed: includeFixed }),
|
||||
[range.from, range.to, customerId, team, includeFixed]
|
||||
);
|
||||
|
||||
const shift = (step: number) => {
|
||||
if (period === 'week') setAnchor(addDays(anchor, 7 * step));
|
||||
if (period === 'month') setAnchor(addMonths(anchor, step));
|
||||
if (period === 'quarter') setAnchor(addMonths(anchor, 3 * step));
|
||||
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`);
|
||||
};
|
||||
|
||||
const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined };
|
||||
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
|
||||
const exportOptions = useMemo(
|
||||
() => [
|
||||
{ label: `PDF – ${groupName}`, hint: 'Mit Logo, Kopf und Fuß', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'pdf' })}`, filename: 'Zeitreport.pdf' },
|
||||
{ label: `Excel – ${groupName}`, hint: 'Mit Summenformeln', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'xlsx' })}`, filename: 'Zeitreport.xlsx' },
|
||||
{ label: `CSV – ${groupName}`, path: `/exports/report${qs({ ...exportParams, group: tab, format: 'csv' })}`, filename: 'Zeitreport.csv' },
|
||||
{ label: 'Excel – alle Buchungen', hint: 'Detailliste für die Rechnung', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'xlsx' })}`, filename: 'Zeitreport-Detail.xlsx' },
|
||||
{ label: 'PDF – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'pdf' })}`, filename: 'Zeitreport-Detail.pdf' },
|
||||
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tab, range.from, range.to, customerId, team, includeFixed]
|
||||
);
|
||||
|
||||
const data = report.data;
|
||||
const s = data?.summary;
|
||||
const share = s && s.minutes ? (s.billable_minutes / s.minutes) * 100 : 0;
|
||||
const capacity = data?.users.reduce((sum, u) => sum + (u.capacity_minutes ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{atLeast(user, 'management') && leads.length > 0 && (
|
||||
<select className={`${inputClass} !w-auto max-w-[220px]`} value={team} onChange={(e) => setTeam(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">Alle Teams</option>
|
||||
{leads.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
Team {l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">Alle Kunden</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
|
||||
<option value="week">Woche</option>
|
||||
<option value="month">Monat</option>
|
||||
<option value="quarter">Quartal</option>
|
||||
<option value="year">Jahr</option>
|
||||
<option value="custom">Zeitraum</option>
|
||||
</select>
|
||||
{period === 'custom' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} onChange={(e) => setCustom({ ...custom, from: e.target.value })} />
|
||||
<span className="text-ink-400">–</span>
|
||||
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} onChange={(e) => setCustom({ ...custom, to: e.target.value })} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
|
||||
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Zurück">
|
||||
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
|
||||
</button>
|
||||
<span className="tabular flex items-center gap-2 border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-700">
|
||||
<Icon name="calendar" className="h-4 w-4 text-ink-400" />
|
||||
{range.label}
|
||||
</span>
|
||||
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Weiter">
|
||||
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
|
||||
{!data ? (
|
||||
<Spinner label="Lade Report…" />
|
||||
) : (
|
||||
<>
|
||||
<Card className={`animate-rise grid gap-6 p-5 transition-opacity sm:p-6 lg:grid-cols-[1fr_1.2fr_1.3fr_1fr] ${report.loading ? 'opacity-60' : ''}`}>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink-500">Gesamtstunden</p>
|
||||
<CountUp value={s!.minutes / 60} format={(v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />
|
||||
<p className="mt-1 text-xs text-ink-400">{s!.entries} Buchungen</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Donut percent={share} size={96} stroke={13} />
|
||||
<div className="flex-1 space-y-1.5 text-sm">
|
||||
<p className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2 font-semibold text-ink-600">
|
||||
<span className="h-3 w-3 rounded-sm" style={{ background: BILLABLE }} /> Billable
|
||||
</span>
|
||||
<b className="tabular text-ink-900">{formatHours(s!.billable_minutes)}</b>
|
||||
</p>
|
||||
<p className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2 font-semibold text-ink-600">
|
||||
<span className="h-3 w-3 rounded-sm" style={{ background: NON_BILLABLE }} /> Non-billable
|
||||
</span>
|
||||
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink-500">Billable Betrag</p>
|
||||
<CountUp value={s!.amount} format={(v) => formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-semibold text-ink-700">
|
||||
<input type="checkbox" checked={includeFixed} onChange={(e) => setIncludeFixed(e.target.checked)} className="h-4 w-4 accent-[var(--color-brand-500)]" />
|
||||
Festpreisprojekte einbeziehen
|
||||
</label>
|
||||
<p className="mt-1 text-[11px] leading-snug text-ink-400">
|
||||
Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
|
||||
<CountUp value={capacity ? (s!.minutes / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
|
||||
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
|
||||
{!includeFixed && s!.fixed_amount > 0 && <p className="mt-2 text-[11px] text-ink-400">+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten</p>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="animate-rise overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink-100 px-4 pt-2">
|
||||
<div className="no-scrollbar flex gap-1 overflow-x-auto overflow-y-hidden">
|
||||
{(
|
||||
[
|
||||
['clients', 'Kunden'],
|
||||
['projects', 'Projekte'],
|
||||
['tasks', 'Tasks'],
|
||||
['users', 'Mitarbeiter'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button key={key} onClick={() => setTab(key)} className={`relative whitespace-nowrap px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
|
||||
{label}
|
||||
<span className="ml-1 text-[11px] text-ink-400">{data[key].length}</span>
|
||||
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<ExportMenu options={exportOptions} />
|
||||
<IconButton icon="printer" label="Drucken" onClick={() => window.print()} className="hidden border border-ink-200 sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
<ReportTable
|
||||
key={tab}
|
||||
rows={data[tab]}
|
||||
kind={tab}
|
||||
label={{ clients: 'Kunde', projects: 'Projekt', tasks: 'Task', users: 'Name' }[tab]}
|
||||
onRowClick={
|
||||
tab === 'clients'
|
||||
? (row) => navigate(`reports/kunde/${row.customer_id}`)
|
||||
: tab === 'projects'
|
||||
? (row) => navigate(`reports/projekt/${row.project_id}`)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { periodRange };
|
||||
Reference in New Issue
Block a user