import React, { useEffect, useState } from 'react'; import { Avatar, Badge, BudgetBar, Button, Card, IconButton, inputClass } from '../ui/controls'; import { ConfirmDialog, Modal } from '../ui/Modal'; import { Icon } from '../ui/Icon'; import { PotFields, emptyPot, potPayload, type PotDraft } from './ProjectForm'; import { TYPE_TONE } from './Projects'; import { useAuth } from '../../lib/auth'; import { projectsApi } from '../../lib/api'; import { formatDate, formatEuro, formatHours, formatNumber } from '../../lib/format'; import { useRouter } from '../../lib/router'; import { useToast } from '../Toast'; import { PROJECT_TYPE_LABELS, type BudgetPot, type DirectoryUser, type Project, type Task } from '../../types'; const POT_STATUS: Record = { active: { label: 'aktiv', tone: 'brand' }, upcoming: { label: 'geplant', tone: 'sky' }, expired: { label: 'abgelaufen', tone: 'neutral' }, exhausted: { label: 'aufgebraucht', tone: 'red' }, }; interface Props { project: Project; canEdit: boolean; users: DirectoryUser[]; onChanged: (project: Project) => void; onEdit: () => void; onDeleted: () => void; onClose: () => void; } /** Aufgeklappte Projektansicht unter der Karte: Toepfe, Tasks, Team. */ export const ProjectDetail: React.FC = ({ project: p, canEdit, users, onChanged, onEdit, onDeleted, onClose }) => { const { settings } = useAuth(); const { addToast } = useToast(); const { navigate } = useRouter(); const [potDialog, setPotDialog] = useState<{ pot: BudgetPot | null } | null>(null); const [membersOpen, setMembersOpen] = useState(false); const [newTask, setNewTask] = useState(''); const [newTaskRate, setNewTaskRate] = useState(''); const [confirm, setConfirm] = useState<{ title: string; message: string; action: () => Promise; label?: string } | null>(null); const hpd = settings.hours_per_day; const run = async (action: () => Promise, success?: string) => { try { const result = await action(); if ('id' in result) onChanged(result); if (success) addToast(success, 'success'); } catch (err) { addToast(err instanceof Error ? err.message : 'Aktion fehlgeschlagen.', 'error'); } }; const addTask = async () => { if (!newTask.trim()) return; const rate = newTaskRate.trim().replace(',', '.'); if (rate !== '' && !Number.isFinite(Number(rate))) return addToast('Der Satz ist keine Zahl.', 'error'); await run(() => projectsApi.addTask(p.id, { name: newTask.trim(), billable: p.type !== 'non_billable', rate: rate === '' ? null : Number(rate), }) ); setNewTask(''); setNewTaskRate(''); }; const isAbruf = p.type === 'abruf'; const maxTask = Math.max(1, ...p.tasks.map((t) => t.used_minutes)); const totalPotHours = p.pots.reduce((s, pot) => s + (pot.budget_hours ?? 0), 0); return (
{PROJECT_TYPE_LABELS[p.type]} {(p.type === 'billable' || p.type === 'abruf') && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`} {p.code} {p.tags.map((t) => ( {t} ))}

{p.display_name}

{p.customer.name} · {formatDate(p.start_date)} – {p.end_date ? formatDate(p.end_date) : 'offen'} {p.owner_name && ` · verantwortlich: ${p.owner_name}`}

{p.notes &&

{p.notes}

}
{canEdit && ( <> setConfirm({ title: 'Projekt löschen?', message: 'Nur möglich, solange keine Zeiten gebucht sind. Sonst bitte archivieren.', action: async () => { await projectsApi.remove(p.id); addToast('Projekt gelöscht.', 'success'); onDeleted(); }, }) } /> )}
{/* Budgettoepfe */}

{isAbruf ? 'Abrechnung nach Aufwand' : 'Budgettöpfe'}

{canEdit && !isAbruf && ( )}
{isAbruf && ( /* Abruf hat kein Volumen und kein Ablaufdatum - nur einen Satz. */

Kein Budgettopf: gebucht wird innerhalb der Projektlaufzeit ohne Limit, der Umsatz ergibt sich aus den abrechenbaren Stunden.

)} {p.pots.length > 1 && (

Gesamt über alle Töpfe

{formatHours(p.used_minutes, 1)} h {totalPotHours ? `von ${formatNumber(totalPotHours, 0)} h` : ''} {canEdit && p.budget_value ? ( {formatEuro(p.earned)} erbracht · {formatEuro(p.open)} offen ) : null}
)}
    {p.pots.map((pot, i) => (
  • {pot.label} {POT_STATUS[pot.status].label}

    {formatDate(pot.valid_from)} – {formatDate(pot.valid_to)} {pot.status === 'active' && ` · noch ${pot.days_left} Tage`} {pot.order_number && ` · Bestellnr. ${pot.order_number}`}

    {canEdit && (
    setPotDialog({ pot })} /> setConfirm({ title: `Topf „${pot.label}“ löschen?`, message: 'Nur möglich, wenn noch nichts darauf gebucht ist.', action: () => run(() => projectsApi.removePot(p.id, pot.id), 'Topf gelöscht.'), }) } />
    )}
    {formatHours(pot.used_minutes, 1)} h / {formatNumber(pot.used_hours / hpd, 1)} T gebucht {pot.budget_hours != null ? `${formatNumber(pot.remaining_hours ?? 0, 1)} h / ${formatNumber((pot.remaining_hours ?? 0) / hpd, 1)} T frei von ${formatNumber(pot.budget_hours, 0)} h` : 'unbegrenzt'}
    {canEdit && (pot.budget_value || pot.rate) && (
    )} {pot.warning && pot.status !== 'expired' && (

    {pot.warning}

    )}
  • ))}
{/* Tasks + Team */}

Tasks

    {p.tasks.length === 0 &&
  • Noch keine Tasks – gebucht wird dann direkt aufs Projekt.
  • } {p.tasks.map((t, i) => (
  • {t.name} {!t.is_active && (inaktiv)} {t.rate != null && !canEdit && ( {formatEuro(t.rate, 2)} / {p.billing_unit === 'day' ? 'Tag' : 'Std.'} )} {formatHours(t.used_minutes, 1)} h
    {p.type !== 'non_billable' && canEdit && t.billable && ( /* Satz direkt in der Zeile: leer heisst "Satz des Topfs". */ run(() => projectsApi.updateTask(p.id, t.id, { rate }))} /> )} {p.type !== 'non_billable' && ( )} {canEdit && (
    { const name = window.prompt('Neuer Taskname', t.name); if (name && name.trim() !== t.name) run(() => projectsApi.updateTask(p.id, t.id, { name: name.trim() })); }} /> {!t.is_active ? ( run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} /> ) : ( run(() => projectsApi.removeTask(p.id, t.id), t.used_minutes ? 'Task deaktiviert (hat Buchungen).' : 'Task entfernt.')} /> )}
    )}
  • ))}
{canEdit && ( /* flex-wrap und min-w-0: die Spalte ist schmal, sonst laufen die Felder aus der Karte heraus. */
setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} /> {p.type !== 'non_billable' && ( setNewTaskRate(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} /> )}
)}

Zugewiesen ({p.members.length})

{canEdit && ( )}
{p.members.map((m) => ( {m.name} ))} {p.members.length === 0 &&

Noch niemand zugewiesen – niemand kann buchen.

}
{potDialog && setPotDialog(null)} onSaved={onChanged} />} {membersOpen && setMembersOpen(false)} onSaved={onChanged} />} {confirm && ( setConfirm(null)} onConfirm={async () => { const current = confirm; setConfirm(null); try { await current.action(); } catch (err) { addToast(err instanceof Error ? err.message : 'Aktion fehlgeschlagen.', 'error'); } }} /> )}
); }; const Kv: React.FC<{ label: string; value: string }> = ({ label, value }) => (

{label}

{value}

); const PotDialog: React.FC<{ project: Project; pot: BudgetPot | null; onClose: () => void; onSaved: (p: Project) => void }> = ({ project, pot, onClose, onSaved }) => { const { settings } = useAuth(); const { addToast } = useToast(); const [draft, setDraft] = useState(() => pot ? { label: pot.label, valid_from: pot.valid_from, valid_to: pot.valid_to, budget_amount: pot.budget_amount == null ? '' : String(pot.budget_amount).replace('.', ','), rate: pot.rate == null ? '' : String(pot.rate).replace('.', ','), budget_hours: pot.budget_hours == null ? '' : String(pot.budget_hours).replace('.', ','), budget_unit: 'hours', order_number: pot.order_number ?? '', notes: pot.notes ?? '', } : emptyPot(project.pots.length ? project.pots[project.pots.length - 1].valid_to : project.start_date, `Budget ${project.pots.length + 1}`) ); const [busy, setBusy] = useState(false); const save = async () => { setBusy(true); try { const payload = potPayload(draft, settings.hours_per_day); const saved = pot ? await projectsApi.updatePot(project.id, pot.id, payload) : await projectsApi.addPot(project.id, payload); addToast(pot ? 'Topf gespeichert – Buchungen neu zugeordnet.' : 'Topf angelegt.', 'success'); onSaved(saved); onClose(); } catch (err) { addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); } finally { setBusy(false); } }; return ( } >

Gebucht wird immer auf den Topf, dessen Laufzeit das Leistungsdatum enthält. Überlappen Töpfe, zuerst der, der früher endet.

); }; const MembersDialog: React.FC<{ project: Project; users: DirectoryUser[]; onClose: () => void; onSaved: (p: Project) => void }> = ({ project, users, onClose, onSaved }) => { const { addToast } = useToast(); const [ids, setIds] = useState(project.members.map((m) => m.id)); const [filter, setFilter] = useState(''); const [busy, setBusy] = useState(false); const list = users.filter((u) => u.role !== 'admin' && (u.is_active || ids.includes(u.id)) && (!filter || `${u.name} ${u.company ?? ''} ${u.job_title ?? ''}`.toLowerCase().includes(filter.toLowerCase()))); const save = async () => { setBusy(true); try { onSaved(await projectsApi.setMembers(project.id, ids)); addToast('Zuweisung gespeichert.', 'success'); onClose(); } catch (err) { addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); } finally { setBusy(false); } }; return ( } > setFilter(e.target.value)} autoFocus />
{list.map((u) => { const checked = ids.includes(u.id); return ( ); })}
); }; /** * Satz eines Tasks direkt in der Zeile. Leer bedeutet "Satz des Budgettopfs" * bzw. bei Abruf "Satz des Projekts". Gespeichert wird beim Verlassen des * Feldes oder mit der Eingabetaste - ein eigener Dialog waere fuer eine Zahl * zu viel Aufwand. */ const TaskRate: React.FC<{ task: Task; unit: 'hour' | 'day'; onSave: (rate: number | null) => void }> = ({ task, unit, onSave }) => { const [text, setText] = useState(task.rate == null ? '' : String(task.rate).replace('.', ',')); useEffect(() => setText(task.rate == null ? '' : String(task.rate).replace('.', ',')), [task.rate]); const speichern = () => { const wert = text.trim().replace(',', '.'); const zahl = wert === '' ? null : Number(wert); if (wert !== '' && !Number.isFinite(zahl)) return setText(task.rate == null ? '' : String(task.rate).replace('.', ',')); if (zahl === task.rate) return; onSave(zahl); }; return ( setText(e.target.value)} onBlur={speichern} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); if (e.key === 'Escape') setText(task.rate == null ? '' : String(task.rate).replace('.', ',')); }} /> ); };