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>
257 lines
13 KiB
TypeScript
257 lines
13 KiB
TypeScript
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 };
|