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,248 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { CountUp, ProgressChart } from '../ui/charts';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ExportMenu } from './ExportMenu';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { projectsApi, qs, reportsApi, type ProjectDetail } from '../../lib/api';
|
||||
import { useLoader } from '../../lib/hooks';
|
||||
import { formatDate, formatEuro, formatHours, formatNumber, mondayOf, todayIso } from '../../lib/format';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { TYPE_TONE } from '../projects/Projects';
|
||||
import { PROJECT_TYPE_LABELS } from '../../types';
|
||||
|
||||
export const ProjectReport: React.FC<{ id: number | null }> = ({ id }) => {
|
||||
const { navigate } = useRouter();
|
||||
const projects = useLoader(() => projectsApi.list(), []);
|
||||
const report = useLoader(() => (id ? reportsApi.project(id) : Promise.resolve(null)), [id]);
|
||||
|
||||
const options = useMemo(() => [...(projects.data ?? [])].sort((a, b) => a.display_name.localeCompare(b.display_name, 'de')), [projects.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/projekt/${e.target.value}` : 'reports/projekt')}>
|
||||
<option value="">Projekt wählen…</option>
|
||||
{options.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name} · {p.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!id ? (
|
||||
<EmptyState icon="folder" title="Projekt auswählen" text="Verlauf, Budget, Tasks, Team und Budgettöpfe eines Projekts." />
|
||||
) : report.error ? (
|
||||
<ErrorBox message={report.error} onRetry={report.reload} />
|
||||
) : !report.data ? (
|
||||
<Spinner label="Lade Projektreport…" />
|
||||
) : (
|
||||
<ProjectReportBody key={id} detail={report.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectReportBody: React.FC<{ detail: ProjectDetail; compact?: boolean }> = ({ detail, compact }) => {
|
||||
const { settings } = useAuth();
|
||||
const { navigate } = useRouter();
|
||||
const [chart, setChart] = useState<'progress' | 'weekly'>('progress');
|
||||
const [tab, setTab] = useState<'tasks' | 'team' | 'pots'>('tasks');
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
const p = detail.project;
|
||||
const t = detail.totals;
|
||||
const budgetHours = p.budget_hours;
|
||||
const remaining = budgetHours != null ? Math.max(0, budgetHours - p.budget_used_minutes / 60) : null;
|
||||
const remainingPct = budgetHours ? Math.round(((remaining ?? 0) / budgetHours) * 100) : null;
|
||||
const thisWeek = mondayOf(todayIso());
|
||||
const virtualRate = p.type === 'fixed' && p.used_minutes ? (p.budget_value ?? 0) / (p.used_minutes / 60) : null;
|
||||
const month = todayIso().slice(0, 7);
|
||||
|
||||
const exportOptions = [
|
||||
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Alle Mitarbeiter, aktueller Monat', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', project_id: p.id })}`, filename: `${p.code}.pdf` },
|
||||
{ label: 'Excel – Stundennachweis (Monat)', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', project_id: p.id, amounts: 1 })}`, filename: `${p.code}.xlsx` },
|
||||
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${p.code}.csv` },
|
||||
{ label: 'PDF – Tasks gesamt', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'tasks', format: 'pdf', include_fixed: 1 })}`, filename: `${p.code}-tasks.pdf` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!compact && (
|
||||
<div className="animate-rise">
|
||||
<button onClick={() => navigate(`reports/kunde/${p.customer_id}`)} className="text-sm font-bold text-ink-500 hover:text-brand-700">
|
||||
{p.customer.name}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<h2 className="text-2xl leading-tight text-ink-900 sm:text-3xl">
|
||||
<span className="tabular text-ink-400">[{p.code}]</span> {p.display_name}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => navigate(`projekte/${p.id}`)} className="flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 hover:border-brand-300 hover:text-brand-700">
|
||||
<Icon name="edit" className="h-3.5 w-3.5" /> Projekt öffnen
|
||||
</button>
|
||||
<ExportMenu options={exportOptions} label="Aktionen" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-ink-500">
|
||||
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="calendar" className="h-4 w-4" />
|
||||
Start: {formatDate(p.start_date)}
|
||||
{p.end_date && ` · Ende: ${formatDate(p.end_date)}`}
|
||||
</span>
|
||||
</div>
|
||||
{p.notes && (
|
||||
<div className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||
<p className={showNotes ? 'whitespace-pre-line' : 'line-clamp-2'}>{p.notes}</p>
|
||||
<button onClick={() => setShowNotes(!showNotes)} className="text-xs font-bold text-sky-700 underline">
|
||||
{showNotes ? 'Weniger' : 'Mehr anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 1 } as React.CSSProperties}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={chart}
|
||||
onChange={setChart}
|
||||
options={[
|
||||
{ value: 'progress', label: 'Projektverlauf' },
|
||||
{ value: 'weekly', label: 'Stunden pro Woche' },
|
||||
]}
|
||||
/>
|
||||
<span className="text-xs text-ink-400">Stunden · Start {formatDate(p.start_date)}</span>
|
||||
</div>
|
||||
<ProgressChart
|
||||
mode={chart}
|
||||
budget={budgetHours}
|
||||
format={(v) => `${formatNumber(v, 0)} h`}
|
||||
points={detail.weekly.map((w) => ({
|
||||
label: w.label,
|
||||
value: w.cumulative_minutes == null ? null : w.cumulative_minutes / 60,
|
||||
bar: w.billable_minutes / 60,
|
||||
barB: (w.minutes - w.billable_minutes) / 60,
|
||||
current: w.week_start === thisWeek,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<Kpi index={2} title="Gesamtstunden">
|
||||
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<Row label="Billable" value={formatHours(t.billable_minutes)} />
|
||||
<Row label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
||||
</Kpi>
|
||||
<Kpi index={3} title={`Budget verbleibend${remainingPct != null ? ` (${remainingPct} %)` : ''}`}>
|
||||
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining, 2)}</p>
|
||||
<Row label="Gesamtbudget" value={budgetHours == null ? 'unbegrenzt' : `${formatNumber(budgetHours, 2)} h`} />
|
||||
<BudgetBar className="mt-2" percent={budgetHours ? (p.budget_used_minutes / 60 / budgetHours) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||
</Kpi>
|
||||
<Kpi index={4} title={p.type === 'fixed' ? 'Virtueller Stundensatz' : 'Budget in Tagen'}>
|
||||
{p.type === 'fixed' ? (
|
||||
<>
|
||||
<p className="tabular text-3xl font-extrabold text-ink-900">{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}</p>
|
||||
<Row label="Festpreis ÷ Stunden" value="nur intern" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining / settings.hours_per_day, 1)}</p>
|
||||
<Row label="Tage frei von" value={budgetHours == null ? '–' : formatNumber(budgetHours / settings.hours_per_day, 1)} />
|
||||
</>
|
||||
)}
|
||||
</Kpi>
|
||||
<Kpi index={5} title="Umsatz erbracht">
|
||||
<CountUp value={p.earned ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<Row label="Budget gesamt" value={formatEuro(p.budget_value, 2)} />
|
||||
</Kpi>
|
||||
<Kpi index={6} title="Umsatz offen">
|
||||
<CountUp value={p.open ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<Row label="Töpfe aktiv" value={`${p.pots.filter((x) => x.status === 'active').length} von ${p.pots.length}`} />
|
||||
</Kpi>
|
||||
</div>
|
||||
|
||||
<Card className="animate-rise overflow-hidden" style={{ '--i': 7 } as React.CSSProperties}>
|
||||
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
|
||||
{(
|
||||
[
|
||||
['tasks', 'Tasks'],
|
||||
['team', 'Team'],
|
||||
['pots', 'Budgettöpfe'],
|
||||
] 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 === 'tasks' && <ReportTable rows={detail.tasks} kind="tasks" label="Task" />}
|
||||
{tab === 'team' && <ReportTable rows={detail.team} kind="team" label="Name" />}
|
||||
{tab === 'pots' && <PotsBreakdown detail={detail} />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Gesamtuebersicht aller Toepfe und darunter jeder Topf einzeln. */
|
||||
const PotsBreakdown: React.FC<{ detail: ProjectDetail }> = ({ detail }) => {
|
||||
const { settings } = useAuth();
|
||||
const p = detail.project;
|
||||
const totalBudget = p.pots.reduce((s, x) => s + (x.budget_hours ?? 0), 0);
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="rounded-2xl bg-ink-50 p-4">
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt · {p.pots.length} Töpfe</p>
|
||||
<div className="tabular mt-1 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<Row label="Gebucht" value={`${formatHours(p.used_minutes)} h`} />
|
||||
<Row label="Budget" value={totalBudget ? `${formatNumber(totalBudget, 2)} h` : 'unbegrenzt'} />
|
||||
<Row label="Erbracht" value={formatEuro(p.earned, 2)} />
|
||||
<Row label="Offen" value={formatEuro(p.open, 2)} />
|
||||
</div>
|
||||
<BudgetBar className="mt-3" percent={totalBudget ? (p.budget_used_minutes / 60 / totalBudget) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||
</div>
|
||||
{detail.pots.map((pot, i) => (
|
||||
<div key={pot.id} className="animate-rise rounded-2xl border border-ink-200" style={{ '--i': i } as React.CSSProperties}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 p-4">
|
||||
<div>
|
||||
<p className="font-extrabold text-ink-900">{pot.label}</p>
|
||||
<p className="text-xs text-ink-400">
|
||||
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)} · {pot.status === 'active' ? 'aktiv' : pot.status === 'expired' ? 'abgelaufen' : pot.status === 'exhausted' ? 'aufgebraucht' : 'geplant'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="tabular flex flex-wrap gap-4 text-xs">
|
||||
<Row label="Gebucht" value={`${formatHours(pot.used_minutes)} h`} />
|
||||
<Row label="Frei" value={pot.remaining_hours == null ? '∞' : `${formatNumber(pot.remaining_hours, 2)} h`} />
|
||||
<Row label="Erbracht" value={formatEuro(pot.earned, 2)} />
|
||||
<Row label="Offen" value={formatEuro(pot.open, 2)} />
|
||||
</div>
|
||||
</div>
|
||||
<BudgetBar className="mx-4 mb-3" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
|
||||
<div className="grid border-t border-ink-100 lg:grid-cols-2">
|
||||
<ReportTable rows={pot.tasks} kind="tasks" label="Task" totals={false} />
|
||||
<ReportTable rows={pot.team} kind="team" label="Team" totals={false} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Kpi: React.FC<{ title: string; index: number; children: React.ReactNode }> = ({ title, index, children }) => (
|
||||
<Card className="animate-rise p-4" style={{ '--i': index } as React.CSSProperties}>
|
||||
<p className="mb-1 text-sm font-semibold text-ink-500">{title}</p>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const Row: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<p className="tabular mt-1 flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-ink-500">{label}</span>
|
||||
<b className="text-ink-900">{value}</b>
|
||||
</p>
|
||||
);
|
||||
|
||||
export { Row as ReportRow, Kpi as ReportKpi };
|
||||
Reference in New Issue
Block a user