Files
perinco-Pulse/src/components/projects/ProjectForm.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

427 lines
20 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 { Modal } from '../ui/Modal';
import { Avatar, Button, Field, IconButton, Segmented, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { projectsApi, type PotInput } from '../../lib/api';
import { addDays, addMonths, formatNumber, todayIso } from '../../lib/format';
import { useToast } from '../Toast';
import { CustomerForm } from '../customers/CustomerForm';
import type { Customer, DirectoryUser, Project, ProjectType } from '../../types';
export interface PotDraft {
label: string;
valid_from: string;
valid_to: string;
budget_amount: string;
rate: string;
budget_hours: string;
budget_unit: 'hours' | 'days';
notes: string;
}
export function emptyPot(start = todayIso(), label = 'Budget 1'): PotDraft {
return { label, valid_from: start, valid_to: addDays(addMonths(start, 12), -1), budget_amount: '', rate: '', budget_hours: '', budget_unit: 'hours', notes: '' };
}
/** Deutsche und englische Schreibweise: "48.000,50", "48000.5", "110,5". */
export function num(value: string): number | null {
const v = value.trim().replace(/\s|€/g, '');
if (v === '') return null;
if (v.includes(',')) return Number(v.replace(/\./g, '').replace(',', '.'));
if (/^\d{1,3}(\.\d{3})+$/.test(v)) return Number(v.replace(/\./g, ''));
return Number(v);
}
export function potPayload(draft: PotDraft, hoursPerDay: number): PotInput {
const hours = num(draft.budget_hours);
return {
label: draft.label,
valid_from: draft.valid_from,
valid_to: draft.valid_to,
budget_amount: num(draft.budget_amount),
rate: num(draft.rate),
budget_hours: hours == null ? null : draft.budget_unit === 'days' ? hours * hoursPerDay : hours,
notes: draft.notes || null,
};
}
/** Vorschau des buchbaren Volumens - gleiche Rechnung wie im Backend. */
export function potVolume(draft: PotDraft, type: ProjectType, unit: 'hour' | 'day', hoursPerDay: number) {
const amount = num(draft.budget_amount);
const rate = num(draft.rate);
if (type === 'billable' && amount != null && rate) return (amount / rate) * (unit === 'day' ? hoursPerDay : 1);
const hours = num(draft.budget_hours);
if (hours == null) return null;
return draft.budget_unit === 'days' ? hours * hoursPerDay : hours;
}
/** Felder eines Budgettopfs - im Projektformular und im Topfdialog. */
export const PotFields: React.FC<{
draft: PotDraft;
onChange: (draft: PotDraft) => void;
type: ProjectType;
unit: 'hour' | 'day';
}> = ({ draft, onChange, type, unit }) => {
const { settings } = useAuth();
const set = (patch: Partial<PotDraft>) => onChange({ ...draft, ...patch });
const volume = potVolume(draft, type, unit, settings.hours_per_day);
const unitLabel = unit === 'day' ? 'Tagessatz' : 'Stundensatz';
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Bezeichnung" required className="sm:col-span-1">
<input className={inputClass} value={draft.label} onChange={(e) => set({ label: e.target.value })} placeholder="z. B. Bestellung 4500030022" />
</Field>
<Field label="Laufzeit von" required>
<input type="date" className={inputClass} value={draft.valid_from} onChange={(e) => set({ valid_from: e.target.value })} />
</Field>
<Field label="Laufzeit bis" required>
<input type="date" className={inputClass} value={draft.valid_to} onChange={(e) => set({ valid_to: e.target.value })} />
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{type !== 'non_billable' && (
<Field label={type === 'fixed' ? 'Festpreis (€)' : 'Budget (€)'} required={type === 'fixed'}>
<input inputMode="decimal" className={inputClass} value={draft.budget_amount} onChange={(e) => set({ budget_amount: e.target.value })} placeholder="z. B. 48000" />
</Field>
)}
{type === 'billable' && (
<Field label={`${unitLabel} (€)`} required>
<input inputMode="decimal" className={inputClass} value={draft.rate} onChange={(e) => set({ rate: e.target.value })} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} />
</Field>
)}
{(type !== 'billable' || !num(draft.budget_amount)) && (
<Field label={type === 'fixed' ? 'Kontingent (optional)' : type === 'billable' ? 'oder Kontingent' : 'Kontingent (leer = unbegrenzt)'}>
<div className="flex gap-2">
<input inputMode="decimal" className={inputClass} value={draft.budget_hours} onChange={(e) => set({ budget_hours: e.target.value })} placeholder="–" />
<select className={`${inputClass} w-24`} value={draft.budget_unit} onChange={(e) => set({ budget_unit: e.target.value as 'hours' | 'days' })}>
<option value="hours">Std.</option>
<option value="days">Tage</option>
</select>
</div>
</Field>
)}
</div>
<p className="flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2 text-xs font-semibold text-brand-800">
<Icon name="pot" className="h-4 w-4 shrink-0" />
{volume == null
? 'Unbegrenzt buchbar innerhalb der Laufzeit.'
: `Buchbar: ${formatNumber(volume, 2)} Std. = ${formatNumber(volume / settings.hours_per_day, 1)} Tage – danach gesperrt, ebenso nach Ablauf.`}
{type === 'fixed' && ' Virtueller Stundensatz = Festpreis ÷ gebuchte Stunden (intern).'}
</p>
</div>
);
};
interface Props {
project?: Project | null;
customers: Customer[];
users: DirectoryUser[];
onClose: () => void;
onSaved: (project: Project) => void;
onCustomerCreated: (customer: Customer) => void;
}
export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClose, onSaved, onCustomerCreated }) => {
const { user, settings } = useAuth();
const { addToast } = useToast();
const editing = Boolean(project);
const [customerId, setCustomerId] = useState<number | ''>(project?.customer_id ?? '');
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';
const [ownerId, setOwnerId] = useState<number | ''>(project?.owner_id ?? (isAdmin ? '' : user!.id));
const [tags, setTags] = useState<string[]>(project?.tags ?? []);
const [tagText, setTagText] = useState('');
const [notes, setNotes] = useState(project?.notes ?? '');
const [members, setMembers] = useState<number[]>(project?.members.map((m) => m.id) ?? (isAdmin ? [] : [user!.id]));
const [memberFilter, setMemberFilter] = useState('');
const [tasks, setTasks] = useState<{ name: string; billable: boolean }[]>([{ name: '', billable: true }]);
const [pots, setPots] = useState<PotDraft[]>([emptyPot()]);
const [busy, setBusy] = useState(false);
const [newCustomer, setNewCustomer] = useState(false);
const customer = customers.find((c) => c.id === customerId);
const codePreview = customer
? settings.project_code_pattern.replaceAll('{KUERZEL}', customer.short_name).replaceAll('{JAHR}', start.slice(0, 4)).replaceAll('{JJ}', start.slice(2, 4)).replaceAll('{NR}', '###')
: '–';
const namePreview = customer && name
? settings.project_name_pattern.replaceAll('{KUNDE}', customer.short_name === 'PERINCO' ? 'perinco' : customer.short_name).replaceAll('{KUNDENNAME}', customer.name).replaceAll('{PROJEKT}', name).replaceAll('{CODE}', project?.code ?? codePreview)
: '';
const activeUsers = useMemo(
() => users.filter((u) => u.is_active && u.role !== 'admin' && (!memberFilter || u.name.toLowerCase().includes(memberFilter.toLowerCase()))),
[users, memberFilter]
);
const addTag = () => {
const parts = tagText.split(',').map((t) => t.trim()).filter(Boolean);
if (parts.length) setTags([...new Set([...tags, ...parts])]);
setTagText('');
};
const save = async () => {
setBusy(true);
try {
const base = {
customer_id: customerId,
name,
type,
billing_unit: unit,
rate: type === 'abruf' ? num(rate) : null,
start_date: start,
end_date: end || null,
owner_id: ownerId || null,
tags,
notes,
member_ids: members,
};
const saved = editing
? await projectsApi.update(project!.id, base)
: await projectsApi.create({
...base,
tasks: tasks.filter((t) => t.name.trim()),
// 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);
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return (
<>
<Modal
size="xl"
title={editing ? `Projekt bearbeiten` : 'Neues Projekt'}
subtitle={editing ? `${project!.code} · ${project!.display_name}` : 'Projektcode wird automatisch vergeben'}
onClose={onClose}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
{editing ? 'Speichern' : 'Projekt anlegen'}
</Button>
</div>
}
>
<div className="space-y-6">
<section className="grid gap-4 sm:grid-cols-2">
<Field label="Kunde" required>
<div className="flex gap-2">
<select className={inputClass} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Kunde wählen…</option>
{customers
.filter((c) => c.is_active || c.id === customerId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.short_name})
</option>
))}
</select>
<IconButton icon="plus" label="Neuen Kunden anlegen" type="button" onClick={() => setNewCustomer(true)} className="border border-ink-200" />
</div>
</Field>
<Field label="Projektname" required hint={namePreview ? <>Anzeige: <b className="text-ink-700">{namePreview}</b></> : `Muster: ${settings.project_name_pattern}`}>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="z. B. PMO" />
</Field>
<Field label="Projektcode">
<input className={`${inputClass} tabular`} value={project?.code ?? codePreview} disabled />
</Field>
<Field label="Projektverantwortlicher">
<select className={inputClass} value={ownerId} onChange={(e) => setOwnerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">–</option>
{users
.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management'))
.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
</section>
<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-2 xl:grid-cols-4">
{(
[
['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
).map(([value, label, hint]) => (
<button
key={value}
type="button"
onClick={() => setType(value)}
className={`rounded-xl border p-3 text-left transition ${type === value ? 'border-brand-400 bg-brand-50 shadow-card' : 'border-ink-200 hover:border-brand-200'}`}
>
<span className={`block text-sm font-extrabold ${type === value ? 'text-brand-700' : 'text-ink-800'}`}>{label}</span>
<span className="block text-[11px] text-ink-400">{hint}</span>
</button>
))}
</div>
</div>
{(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)} />
</Field>
<Field label="Enddatum">
<input type="date" className={inputClass} value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
</div>
</section>
{!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>
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPots([...pots, emptyPot(start, `Budget ${pots.length + 1}`)])}>
Topf hinzufügen
</Button>
</div>
<div className="space-y-3">
{pots.map((pot, i) => (
<div key={i} className="relative rounded-2xl border border-ink-200 p-4">
{pots.length > 1 && (
<IconButton icon="trash" tone="danger" label="Topf entfernen" className="absolute right-2 top-2" onClick={() => setPots(pots.filter((_, j) => j !== i))} />
)}
<PotFields draft={pot} type={type} unit={unit} onChange={(next) => setPots(pots.map((p, j) => (j === i ? next : p)))} />
</div>
))}
</div>
</section>
)}
{!editing && (
<section>
<h3 className="mb-2 text-sm text-ink-900">Tasks</h3>
<div className="space-y-2">
{tasks.map((task, i) => (
<div key={i} className="flex items-center gap-2">
<input
className={inputClass}
value={task.name}
placeholder="z. B. Administration Server"
onChange={(e) => {
const next = tasks.map((t, j) => (j === i ? { ...t, name: e.target.value } : t));
if (i === tasks.length - 1 && e.target.value) next.push({ name: '', billable: true });
setTasks(next);
}}
/>
{type !== 'non_billable' && (
<label className="flex shrink-0 items-center gap-1.5 text-xs font-bold text-ink-500">
<input type="checkbox" checked={task.billable} onChange={(e) => setTasks(tasks.map((t, j) => (j === i ? { ...t, billable: e.target.checked } : t)))} />
billable
</label>
)}
</div>
))}
</div>
</section>
)}
<section>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm text-ink-900">
Zugewiesene Mitarbeiter <span className="text-ink-400">({members.length})</span>
</h3>
<input className={`${inputClass} w-48 py-1.5`} placeholder="Suchen…" value={memberFilter} onChange={(e) => setMemberFilter(e.target.value)} />
</div>
<div className="scrollbar-slim grid max-h-56 gap-1.5 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">
{activeUsers.map((u) => {
const checked = members.includes(u.id);
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'}`}>
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={checked} onChange={() => setMembers(checked ? members.filter((id) => id !== u.id) : [...members, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={24} external={u.is_external} />
<span className="min-w-0 truncate text-xs font-bold text-ink-700">{u.name}</span>
</label>
);
})}
</div>
</section>
<section className="grid gap-4 sm:grid-cols-2">
<Field label="Tags">
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-ink-200 px-2 py-1.5">
{tags.map((t) => (
<span key={t} className="flex items-center gap-1 rounded-full bg-ink-100 px-2 py-0.5 text-xs font-bold text-ink-600">
{t}
<button type="button" onClick={() => setTags(tags.filter((x) => x !== t))} aria-label={`${t} entfernen`}>
<Icon name="close" className="h-3 w-3" />
</button>
</span>
))}
<input
className="min-w-[80px] flex-1 bg-transparent px-1 py-1 text-sm outline-none"
value={tagText}
placeholder="Tag + Enter"
onChange={(e) => setTagText(e.target.value)}
onBlur={addTag}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag();
}
}}
/>
</div>
</Field>
<Field label="Notizen">
<textarea className={`${inputClass} h-24 resize-y`} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="z. B. Rechnungen nur mit Belegnummer…" />
</Field>
</section>
</div>
</Modal>
{newCustomer && (
<CustomerForm
onClose={() => setNewCustomer(false)}
onSaved={(c) => {
onCustomerCreated(c);
setCustomerId(c.id);
}}
/>
)}
</>
);
};