Files
perinco-Pulse/src/components/projects/ProjectDetail.tsx
T
schakal00783andClaude Opus 5 79bfd561bd feat(mehrere): Auslastung bis heute, Satz je Task bearbeitbar, Bestellnummer, Rolle Externe
Auslastung
  Verglichen werden jetzt die bis heute gebuchten Stunden mit der Kapazitaet
  bis heute. Die Kapazitaet endete schon immer am heutigen Tag; wurde im
  Voraus gebucht - eine ganze Woche Urlaub am Montag - standen 40 Stunden
  gegen 8 Stunden Kapazitaet und die Wochenansicht zeigte 500 Prozent. Die
  Spalte "Stunden" zeigt weiter den ganzen Zeitraum.

Tasks
  Das Feld zum Hinzufuegen lief aus der Karte heraus: die Zeile darf jetzt
  umbrechen, und die Breitenangaben tragen "!", weil inputClass w-full
  mitbringt und sonst gewinnt. Der Satz eines Tasks steht als schmales Feld
  direkt in der Zeile und wird beim Verlassen gespeichert - vorher lag er
  hinter einem Abfragedialog und war praktisch nicht zu finden.

Export der Buchungen
  Kunde und Projekt kommen nur noch aus den eigenen Zuweisungen, auch fuer
  Vorgesetzte. Eine Liste aller Projekte des Hauses war dort nur Suchaufwand.

Budgettopf
  Neues Feld Bestellnummer (optional). Es erscheint im Stundennachweis als
  eigene Kopfzeile, wenn die gebuchten Toepfe eine tragen.

Rolle "Externe"
  Gleiche Rechte wie "Mitarbeiter" und derselbe Rang, die Rolle trennt nur,
  wer zum Haus gehoert. Sie setzt is_external automatisch, damit Liste und
  Kennzeichnung nicht widersprechen. Vorgesetzte duerfen sie vergeben.
  Stellen, die bisher role === 'user' pruefen, fragen nun hasRole(lead) ab -
  sonst haetten Externe alle Projekte gesehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:06:26 +02:00

536 lines
25 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, { 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<BudgetPot['status'], { label: string; tone: 'brand' | 'neutral' | 'amber' | 'red' | 'sky' }> = {
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<Props> = ({ 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<unknown>; label?: string } | null>(null);
const hpd = settings.hours_per_day;
const run = async (action: () => Promise<Project | { ok: true }>, 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 (
<Card className="animate-pop overflow-hidden border-brand-200">
<div className="flex flex-wrap items-start gap-3 border-b border-ink-100 bg-brand-50/40 p-4 sm:p-5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={TYPE_TONE[p.type]}>
{PROJECT_TYPE_LABELS[p.type]}
{(p.type === 'billable' || p.type === 'abruf') && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`}
</Badge>
<span className="tabular text-xs font-bold text-ink-400">{p.code}</span>
{p.tags.map((t) => (
<Badge key={t}>
<Icon name="tag" className="h-3 w-3" />
{t}
</Badge>
))}
</div>
<h2 className="mt-1 text-xl text-ink-900">{p.display_name}</h2>
<p className="mt-1 text-xs text-ink-500">
{p.customer.name} · {formatDate(p.start_date)} – {p.end_date ? formatDate(p.end_date) : 'offen'}
{p.owner_name && ` · verantwortlich: ${p.owner_name}`}
</p>
{p.notes && <p className="mt-2 whitespace-pre-line text-sm text-ink-600">{p.notes}</p>}
</div>
<div className="flex flex-wrap items-center gap-2">
{canEdit && (
<>
<Button size="sm" variant="secondary" icon="chart" onClick={() => navigate(`reports/projekt/${p.id}`)}>
Report
</Button>
<Button size="sm" variant="secondary" icon="edit" onClick={onEdit}>
Bearbeiten
</Button>
<Button
size="sm"
variant="secondary"
icon="archive"
onClick={() => run(() => projectsApi.update(p.id, { is_active: !p.is_active }), p.is_active ? 'Projekt archiviert.' : 'Projekt reaktiviert.')}
>
{p.is_active ? 'Archivieren' : 'Reaktivieren'}
</Button>
<IconButton
icon="trash"
tone="danger"
label="Projekt löschen"
onClick={() =>
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();
},
})
}
/>
</>
)}
<IconButton icon="close" label="Schließen" onClick={onClose} />
</div>
</div>
<div className="grid gap-0 lg:grid-cols-2">
{/* Budgettoepfe */}
<section className="border-b border-ink-100 p-4 sm:p-5 lg:border-b-0 lg:border-r">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm text-ink-900">
<Icon name="pot" className="h-4 w-4 text-brand-600" />
{isAbruf ? 'Abrechnung nach Aufwand' : 'Budgettöpfe'}
</h3>
{canEdit && !isAbruf && (
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}>
Topf
</Button>
)}
</div>
{isAbruf && (
/* Abruf hat kein Volumen und kein Ablaufdatum - nur einen Satz. */
<div className="rounded-xl border border-ink-200 p-3">
<div className="tabular grid grid-cols-2 gap-2 text-[11px] sm:grid-cols-4">
<Kv label="Gebucht" value={`${formatHours(p.used_minutes, 1)} h`} />
<Kv label="davon abrechenbar" value={`${formatHours(p.billable_minutes, 1)} h`} />
<Kv
label={p.billing_unit === 'day' ? 'Tagessatz' : 'Stundensatz'}
value={canEdit ? formatEuro(p.rate, 2) : '–'}
/>
<Kv label="Umsatz" value={canEdit ? formatEuro(p.earned, 2) : '–'} />
</div>
<p className="mt-2 text-xs text-ink-400">
Kein Budgettopf: gebucht wird innerhalb der Projektlaufzeit ohne Limit, der Umsatz ergibt sich aus den abrechenbaren Stunden.
</p>
</div>
)}
{p.pots.length > 1 && (
<div className="mb-3 rounded-xl bg-ink-50 p-3">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt über alle Töpfe</p>
<div className="mt-1 flex items-baseline justify-between">
<span className="tabular text-sm font-extrabold text-ink-900">
{formatHours(p.used_minutes, 1)} h {totalPotHours ? `von ${formatNumber(totalPotHours, 0)} h` : ''}
</span>
{canEdit && p.budget_value ? (
<span className="tabular text-xs font-bold text-ink-500">
{formatEuro(p.earned)} erbracht · {formatEuro(p.open)} offen
</span>
) : null}
</div>
<BudgetBar className="mt-2" percent={totalPotHours ? (p.budget_used_minutes / 60 / totalPotHours) * 100 : null} warnAt={settings.budget_warn_percent} />
</div>
)}
<ul className="space-y-2">
{p.pots.map((pot, i) => (
<li key={pot.id} className="animate-rise rounded-xl border border-ink-200 p-3" style={{ '--i': i } as React.CSSProperties}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-sm font-extrabold text-ink-900">
{pot.label}
<Badge tone={POT_STATUS[pot.status].tone}>{POT_STATUS[pot.status].label}</Badge>
</p>
<p className="text-xs text-ink-400">
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)}
{pot.status === 'active' && ` · noch ${pot.days_left} Tage`}
{pot.order_number && ` · Bestellnr. ${pot.order_number}`}
</p>
</div>
{canEdit && (
<div className="flex shrink-0">
<IconButton icon="edit" label="Topf bearbeiten" onClick={() => setPotDialog({ pot })} />
<IconButton
icon="trash"
tone="danger"
label="Topf löschen"
onClick={() =>
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.'),
})
}
/>
</div>
)}
</div>
<BudgetBar className="mt-2" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
<div className="tabular mt-1.5 flex flex-wrap justify-between gap-x-3 text-[11px] font-bold text-ink-500">
<span>
{formatHours(pot.used_minutes, 1)} h / {formatNumber(pot.used_hours / hpd, 1)} T gebucht
</span>
<span>
{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'}
</span>
</div>
{canEdit && (pot.budget_value || pot.rate) && (
<div className="tabular mt-2 grid grid-cols-3 gap-2 text-[11px]">
<Kv label="Budget" value={formatEuro(pot.budget_value)} />
<Kv
label={p.type === 'fixed' ? 'virt. Stundensatz' : p.billing_unit === 'day' ? 'Tagessatz' : 'Stundensatz'}
value={p.type === 'fixed' ? (pot.virtual_rate ? formatEuro(pot.virtual_rate, 2) : '–') : formatEuro(pot.rate, 2)}
/>
<Kv label="Offen" value={formatEuro(pot.open)} />
</div>
)}
{pot.warning && pot.status !== 'expired' && (
<p className="mt-2 flex items-center gap-1.5 text-[11px] font-semibold text-amber-700">
<Icon name="warning" className="h-3.5 w-3.5" />
{pot.warning}
</p>
)}
</li>
))}
</ul>
</section>
{/* Tasks + Team */}
<section className="p-4 sm:p-5">
<h3 className="mb-3 flex items-center gap-2 text-sm text-ink-900">
<Icon name="list" className="h-4 w-4 text-brand-600" />
Tasks
</h3>
<ul className="space-y-1">
{p.tasks.length === 0 && <li className="text-xs text-ink-400">Noch keine Tasks – gebucht wird dann direkt aufs Projekt.</li>}
{p.tasks.map((t, i) => (
<li key={t.id} className={`animate-rise group flex items-center gap-3 rounded-xl px-2 py-1.5 hover:bg-ink-50 ${t.is_active ? '' : 'opacity-50'}`} style={{ '--i': i } as React.CSSProperties}>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-bold text-ink-800">
{t.name}
{!t.is_active && <span className="ml-1 text-[10px] text-ink-400">(inaktiv)</span>}
</span>
<span className="flex shrink-0 items-center gap-2">
{t.rate != null && !canEdit && (
<span className="tabular rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-bold text-brand-700" title="Eigener Satz für diesen Task">
{formatEuro(t.rate, 2)} / {p.billing_unit === 'day' ? 'Tag' : 'Std.'}
</span>
)}
<span className="tabular text-xs font-extrabold text-ink-600">{formatHours(t.used_minutes, 1)} h</span>
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-ink-100">
<div className="h-full rounded-full bg-brand-400 transition-[width] duration-700" style={{ width: `${(t.used_minutes / maxTask) * 100}%` }} />
</div>
</div>
{p.type !== 'non_billable' && canEdit && t.billable && (
/* Satz direkt in der Zeile: leer heisst "Satz des Topfs". */
<TaskRate task={t} unit={p.billing_unit} onSave={(rate) => run(() => projectsApi.updateTask(p.id, t.id, { rate }))} />
)}
{p.type !== 'non_billable' && (
<button
disabled={!canEdit}
onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { billable: !t.billable }))}
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${t.billable ? 'bg-brand-50 text-brand-700' : 'bg-ink-100 text-ink-500'}`}
title={canEdit ? 'Abrechenbar umschalten' : undefined}
>
{t.billable ? 'billable' : 'non-billable'}
</button>
)}
{canEdit && (
<div className="flex shrink-0 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
<IconButton
icon="edit"
label="Umbenennen"
onClick={() => {
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 ? (
<IconButton icon="refresh" label="Reaktivieren" onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} />
) : (
<IconButton icon="trash" tone="danger" label="Entfernen" onClick={() => run(() => projectsApi.removeTask(p.id, t.id), t.used_minutes ? 'Task deaktiviert (hat Buchungen).' : 'Task entfernt.')} />
)}
</div>
)}
</li>
))}
</ul>
{canEdit && (
/* flex-wrap und min-w-0: die Spalte ist schmal, sonst laufen die
Felder aus der Karte heraus. */
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
className={`${inputClass} min-w-[150px] flex-1 py-2`}
value={newTask}
placeholder="Neuer Task"
onChange={(e) => setNewTask(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
{p.type !== 'non_billable' && (
<input
className={`${inputClass} !w-24 shrink-0 py-2`}
value={newTaskRate}
placeholder={p.billing_unit === 'day' ? '€/Tag' : '€/Std.'}
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs"
onChange={(e) => setNewTaskRate(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
)}
<Button size="sm" icon="plus" onClick={addTask} disabled={!newTask.trim()}>
Hinzufügen
</Button>
</div>
)}
<div className="mb-3 mt-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm text-ink-900">
<Icon name="team" className="h-4 w-4 text-brand-600" />
Zugewiesen ({p.members.length})
</h3>
{canEdit && (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setMembersOpen(true)}>
Zuweisen
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{p.members.map((m) => (
<span key={m.id} className="flex items-center gap-2 rounded-full border border-ink-200 py-1 pl-1 pr-3 text-xs font-bold text-ink-700">
<Avatar id={m.id} name={m.name} color={m.color} avatar={m.avatar} size={22} external={m.is_external} />
{m.name}
</span>
))}
{p.members.length === 0 && <p className="text-xs text-ink-400">Noch niemand zugewiesen – niemand kann buchen.</p>}
</div>
</section>
</div>
{potDialog && <PotDialog project={p} pot={potDialog.pot} onClose={() => setPotDialog(null)} onSaved={onChanged} />}
{membersOpen && <MembersDialog project={p} users={users} onClose={() => setMembersOpen(false)} onSaved={onChanged} />}
{confirm && (
<ConfirmDialog
title={confirm.title}
message={confirm.message}
confirmLabel={confirm.label}
onCancel={() => setConfirm(null)}
onConfirm={async () => {
const current = confirm;
setConfirm(null);
try {
await current.action();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Aktion fehlgeschlagen.', 'error');
}
}}
/>
)}
</Card>
);
};
const Kv: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="rounded-lg bg-ink-50 px-2 py-1">
<p className="text-[9px] font-bold uppercase tracking-wider text-ink-400">{label}</p>
<p className="font-extrabold text-ink-800">{value}</p>
</div>
);
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<PotDraft>(() =>
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 (
<Modal
title={pot ? 'Budgettopf bearbeiten' : 'Neuer Budgettopf'}
subtitle={project.display_name}
onClose={onClose}
size="lg"
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
Speichern
</Button>
</div>
}
>
<PotFields draft={draft} onChange={setDraft} type={project.type} unit={project.billing_unit} />
<p className="mt-3 text-xs text-ink-400">Gebucht wird immer auf den Topf, dessen Laufzeit das Leistungsdatum enthält. Überlappen Töpfe, zuerst der, der früher endet.</p>
</Modal>
);
};
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<number[]>(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 (
<Modal
title="Mitarbeiter zuweisen"
subtitle={`${project.display_name} · ${ids.length} ausgewählt`}
onClose={onClose}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
Speichern
</Button>
</div>
}
>
<input className={`${inputClass} mb-3`} placeholder="Suchen…" value={filter} onChange={(e) => setFilter(e.target.value)} autoFocus />
<div className="space-y-1">
{list.map((u) => {
const checked = ids.includes(u.id);
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-transparent hover:bg-ink-50'}`}>
<input type="checkbox" checked={checked} onChange={() => setIds(checked ? ids.filter((id) => id !== u.id) : [...ids, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={30} external={u.is_external} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{u.name}</span>
<span className="block truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title ?? ''}</span>
</span>
</label>
);
})}
</div>
</Modal>
);
};
/**
* 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 (
<input
className={`${inputClass} !w-[86px] shrink-0 px-2 py-1 text-right text-xs`}
value={text}
placeholder={unit === 'day' ? '€/Tag' : '€/Std.'}
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs"
onChange={(e) => 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('.', ','));
}}
/>
);
};