feat(saetze): Satz je Task plus Import-Endpunkt fuer Harvest

Satz je Task
  Ein Task kann einen eigenen Satz tragen; leer bedeutet weiter "Satz des
  Budgettopfs" bzw. bei Abruf "Satz des Projekts". Damit lassen sich
  "remote" und "vor Ort" im selben Projekt abrechnen, ohne dafuer getrennte
  Projekte anzulegen.

  Das Budget bleibt in Stunden - gemischte Saetze aendern nichts daran, wann
  ein Topf voll ist. Der Umsatz wird je Task-Satz aufsummiert: loadProjects
  gruppiert den Topfverbrauch nach Task-Satz und gibt das Ergebnis an
  potStats, Abruf rechnet je Task. Mitarbeiter sehen keine Saetze, auch
  nicht die am Task.

Import
  POST /api/imports/entries (nur Administrator) schreibt Buchungen fuer
  beliebige Personen - POST /api/entries bucht bewusst nur auf den
  angemeldeten Benutzer. Der Import laeuft durch dieselben Regeln: Projekt
  aktiv, Person zugewiesen, Topf passend zum Datum, maximal 24 h am Tag.
  dry_run schreibt nichts.

  Jede uebernommene Buchung traegt ihre harvest_id (unique). Ein zweiter
  Lauf legt nichts doppelt an. DELETE /api/imports/entries entfernt genau
  die uebernommenen Zeiten; handgetippte bleiben unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-21 11:37:26 +02:00
co-authored by Claude Opus 5
parent 7606df9280
commit 7e7188d476
12 changed files with 403 additions and 37 deletions
+49 -2
View File
@@ -36,6 +36,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
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;
@@ -51,8 +52,17 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
const addTask = async () => {
if (!newTask.trim()) return;
await run(() => projectsApi.addTask(p.id, { name: newTask.trim(), billable: p.type !== 'non_billable' }));
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';
@@ -252,7 +262,14 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
{t.name}
{!t.is_active && <span className="ml-1 text-[10px] text-ink-400">(inaktiv)</span>}
</span>
<span className="tabular shrink-0 text-xs font-extrabold text-ink-600">{formatHours(t.used_minutes, 1)} h</span>
<span className="flex shrink-0 items-center gap-2">
{t.rate != null && (
<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}%` }} />
@@ -278,6 +295,26 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
if (name && name.trim() !== t.name) run(() => projectsApi.updateTask(p.id, t.id, { name: name.trim() }));
}}
/>
{t.billable && p.type !== 'non_billable' && (
<IconButton
icon="euro"
label="Satz für diesen Task"
onClick={() => {
const eingabe = window.prompt(
`Satz für "${t.name}" in € pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}.
Leer lassen = Satz des Budgettopfs.`,
t.rate == null ? '' : String(t.rate).replace('.', ',')
);
if (eingabe === null) return;
const wert = eingabe.trim().replace(',', '.');
if (wert !== '' && !Number.isFinite(Number(wert))) return addToast('Das ist keine Zahl.', 'error');
run(
() => projectsApi.updateTask(p.id, t.id, { rate: wert === '' ? null : Number(wert) }),
wert === '' ? 'Satz entfernt – es gilt der Topfsatz.' : 'Satz am Task gespeichert.'
);
}}
/>
)}
{!t.is_active ? (
<IconButton icon="refresh" label="Reaktivieren" onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} />
) : (
@@ -291,6 +328,16 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
{canEdit && (
<div className="mt-2 flex gap-2">
<input className={`${inputClass} py-2`} value={newTask} placeholder="Neuer Task, z. B. Administration Server" onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} />
{p.type !== 'non_billable' && (
<input
className={`${inputClass} w-28 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>