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>
This commit is contained in:
schakal00783
2026-09-18 00:13:35 +02:00
co-authored by Claude Opus 5
parent 2af516ec20
commit 8c93df11d9
12 changed files with 182 additions and 35 deletions
+22 -3
View File
@@ -55,6 +55,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
setNewTask('');
};
const isAbruf = p.type === 'abruf';
const maxTask = Math.max(1, ...p.tasks.map((t) => t.used_minutes));
const totalPotHours = p.pots.reduce((s, pot) => s + (pot.budget_hours ?? 0), 0);
@@ -65,7 +66,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<div className="flex flex-wrap items-center gap-2">
<Badge tone={TYPE_TONE[p.type]}>
{PROJECT_TYPE_LABELS[p.type]}
{p.type === 'billable' && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`}
{(p.type === 'billable' || p.type === 'abruf') && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`}
</Badge>
<span className="tabular text-xs font-bold text-ink-400">{p.code}</span>
{p.tags.map((t) => (
@@ -127,15 +128,33 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm text-ink-900">
<Icon name="pot" className="h-4 w-4 text-brand-600" />
Budgettöpfe
{isAbruf ? 'Abrechnung nach Aufwand' : 'Budgettöpfe'}
</h3>
{canEdit && (
{canEdit && !isAbruf && (
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}>
Topf
</Button>
)}
</div>
{isAbruf && (
/* Abruf hat kein Volumen und kein Ablaufdatum - nur einen Satz. */
<div className="rounded-xl border border-ink-200 p-3">
<div className="tabular grid grid-cols-2 gap-2 text-[11px] sm:grid-cols-4">
<Kv label="Gebucht" value={`${formatHours(p.used_minutes, 1)} h`} />
<Kv label="davon abrechenbar" value={`${formatHours(p.billable_minutes, 1)} h`} />
<Kv
label={p.billing_unit === 'day' ? 'Tagessatz' : 'Stundensatz'}
value={canEdit ? formatEuro(p.rate, 2) : '–'}
/>
<Kv label="Umsatz" value={canEdit ? formatEuro(p.earned, 2) : '–'} />
</div>
<p className="mt-2 text-xs text-ink-400">
Kein Budgettopf: gebucht wird innerhalb der Projektlaufzeit ohne Limit, der Umsatz ergibt sich aus den abrechenbaren Stunden.
</p>
</div>
)}
{p.pots.length > 1 && (
<div className="mb-3 rounded-xl bg-ink-50 p-3">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt über alle Töpfe</p>