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>
406 lines
19 KiB
TypeScript
406 lines
19 KiB
TypeScript
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);
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
};
|