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
+26 -5
View File
@@ -131,6 +131,8 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
const [name, setName] = useState(project?.name ?? '');
const [type, setType] = useState<ProjectType>(project?.type ?? 'billable');
const [unit, setUnit] = useState<'hour' | 'day'>(project?.billing_unit ?? 'hour');
// Nur fuer "Abruf": Satz am Projekt statt am Budgettopf.
const [rate, setRate] = useState(project?.rate == null ? '' : String(project.rate).replace('.', ','));
const [start, setStart] = useState(project?.start_date ?? todayIso());
const [end, setEnd] = useState(project?.end_date ?? '');
const isAdmin = user!.role === 'admin';
@@ -172,6 +174,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
name,
type,
billing_unit: unit,
rate: type === 'abruf' ? num(rate) : null,
start_date: start,
end_date: end || null,
owner_id: ownerId || null,
@@ -184,7 +187,8 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
: await projectsApi.create({
...base,
tasks: tasks.filter((t) => t.name.trim()),
pots: pots.map((p) => potPayload(p, settings.hours_per_day)),
// Abruf kommt ohne Topf.
pots: type === 'abruf' ? [] : pots.map((p) => potPayload(p, settings.hours_per_day)),
});
addToast(editing ? 'Projekt gespeichert.' : `Projekt ${saved.code} angelegt.`, 'success');
onSaved(saved);
@@ -254,10 +258,11 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
<section className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Projekttyp</span>
<div className="grid gap-2 sm:grid-cols-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
{(
[
['billable', 'Billable', 'Abrechnung nach Stunden oder Tagen'],
['billable', 'Billable', 'Budgettopf mit Volumen und Laufzeit'],
['abruf', 'Abruf', 'Nach Aufwand, ohne Budget – nur ein Satz'],
['non_billable', 'Non-billable', 'z. B. perinco intern'],
['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'],
] as const
@@ -274,11 +279,20 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
))}
</div>
</div>
{type === 'billable' && (
{(type === 'billable' || type === 'abruf') && (
<Field label="Abrechnung">
<Segmented value={unit} onChange={setUnit} options={[{ value: 'hour', label: 'Preis pro Stunde' }, { value: 'day', label: 'Preis pro Tag' }]} className="w-full" />
</Field>
)}
{type === 'abruf' && (
<Field
label={unit === 'day' ? 'Tagessatz (€)' : 'Stundensatz (€)'}
required
hint="Abruf hat kein Budget: es wird gebucht, was anfällt, und mit diesem Satz bewertet."
>
<input inputMode="decimal" className={inputClass} value={rate} onChange={(e) => setRate(e.target.value)} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} />
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Startdatum" required>
<input type="date" className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
@@ -289,7 +303,14 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
</div>
</section>
{!editing && (
{!editing && type === 'abruf' && (
<p className="flex items-start gap-2 rounded-xl bg-brand-50 px-3 py-2 text-xs font-semibold text-brand-800">
<Icon name="pot" className="mt-0.5 h-4 w-4 shrink-0" />
Kein Budgettopf: gebucht werden kann jederzeit innerhalb der Projektlaufzeit, der Umsatz ergibt sich aus den abrechenbaren Stunden mal dem Satz.
</p>
)}
{!editing && type !== 'abruf' && (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm text-ink-900">Budgettöpfe</h3>