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>
186 lines
10 KiB
TypeScript
186 lines
10 KiB
TypeScript
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>
|
||
);
|
||
};
|