Files
perinco-Pulse/src/components/reports/ProjectReport.tsx
T
schakal00783andClaude Opus 5 8c93df11d9 feat(projekte): Projekttyp "Abruf" fuer Abrechnung nach Aufwand
Abruf braucht keinen Budgettopf: es gibt kein zugesagtes Volumen und kein
Ablaufdatum, gebucht wird, was anfaellt. Der Satz kann deshalb nicht am
Topf haengen und steht am Projekt (pulse_projects.rate, Stunde oder Tag).
Der Umsatz sind die abrechenbaren Stunden mal diesem Satz; offen ist
nichts.

- Migration ergaenzt den ENUM-Wert und die Spalte rate
- Anlegen verlangt einen Satz und nimmt keine Toepfe an; ein Topf
  nachtraeglich anzulegen wird abgewiesen
- Wechsel auf Abruf nur, solange auf den Toepfen nichts gebucht ist -
  sonst wuerden Buchungen ihr Budget verlieren; leere Toepfe fallen weg
- Buchungsdialog, Projektkarte, Projektdetail und Projektreport zeigen
  Satz und Umsatz statt Budgetbalken

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:13:35 +02:00

257 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' : p.type === 'abruf' ? (p.billing_unit === 'day' ? 'Tagessatz' : '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.type === 'abruf' ? (
<>
<p className="tabular text-3xl font-extrabold text-ink-900">{formatEuro(p.rate, 2)}</p>
<Row label="Abrechnung" value="nach Aufwand, ohne Budget" />
</>
) : (
<>
<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 };