Files
perinco-Pulse/src/components/projects/ProjectForm.tsx
T
schakal00783andClaude Opus 5 ef676a95f4 feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:52:20 +02:00

406 lines
19 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');
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,
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()),
pots: 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-3">
{(
[
['billable', 'Billable', 'Abrechnung nach Stunden oder Tagen'],
['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' && (
<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>
)}
<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 && (
<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);
}}
/>
)}
</>
);
};