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) => onChange({ ...draft, ...patch }); const volume = potVolume(draft, type, unit, settings.hours_per_day); const unitLabel = unit === 'day' ? 'Tagessatz' : 'Stundensatz'; return (
set({ label: e.target.value })} placeholder="z. B. Bestellung 4500030022" /> set({ valid_from: e.target.value })} /> set({ valid_to: e.target.value })} />
{type !== 'non_billable' && ( set({ budget_amount: e.target.value })} placeholder="z. B. 48000" /> )} {type === 'billable' && ( set({ rate: e.target.value })} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} /> )} {(type !== 'billable' || !num(draft.budget_amount)) && (
set({ budget_hours: e.target.value })} placeholder="–" />
)}

{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).'}

); }; interface Props { project?: Project | null; customers: Customer[]; users: DirectoryUser[]; onClose: () => void; onSaved: (project: Project) => void; onCustomerCreated: (customer: Customer) => void; } export const ProjectForm: React.FC = ({ project, customers, users, onClose, onSaved, onCustomerCreated }) => { const { user, settings } = useAuth(); const { addToast } = useToast(); const editing = Boolean(project); const [customerId, setCustomerId] = useState(project?.customer_id ?? ''); const [name, setName] = useState(project?.name ?? ''); const [type, setType] = useState(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(project?.owner_id ?? (isAdmin ? '' : user!.id)); const [tags, setTags] = useState(project?.tags ?? []); const [tagText, setTagText] = useState(''); const [notes, setNotes] = useState(project?.notes ?? ''); const [members, setMembers] = useState(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([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 ( <> } >
setNewCustomer(true)} className="border border-ink-200" />
Anzeige: {namePreview} : `Muster: ${settings.project_name_pattern}`}> setName(e.target.value)} placeholder="z. B. PMO" />
Projekttyp
{( [ ['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]) => ( ))}
{(type === 'billable' || type === 'abruf') && ( )} {type === 'abruf' && ( setRate(e.target.value)} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} /> )}
setStart(e.target.value)} /> setEnd(e.target.value)} />
{!editing && type === 'abruf' && (

Kein Budgettopf: gebucht werden kann jederzeit innerhalb der Projektlaufzeit, der Umsatz ergibt sich aus den abrechenbaren Stunden mal dem Satz.

)} {!editing && type !== 'abruf' && (

Budgettöpfe

{pots.map((pot, i) => (
{pots.length > 1 && ( setPots(pots.filter((_, j) => j !== i))} /> )} setPots(pots.map((p, j) => (j === i ? next : p)))} />
))}
)} {!editing && (

Tasks

{tasks.map((task, i) => (
{ 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' && ( )}
))}
)}

Zugewiesene Mitarbeiter ({members.length})

setMemberFilter(e.target.value)} />
{activeUsers.map((u) => { const checked = members.includes(u.id); return ( ); })}
{tags.map((t) => ( {t} ))} setTagText(e.target.value)} onBlur={addTag} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag(); } }} />