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:
co-authored by
Claude Opus 5
parent
7606df9280
commit
7e7188d476
@@ -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>
|
||||
|
||||
@@ -142,7 +142,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
|
||||
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 [tasks, setTasks] = useState<{ name: string; billable: boolean; rate: string }[]>([{ name: '', billable: true, rate: '' }]);
|
||||
const [pots, setPots] = useState<PotDraft[]>([emptyPot()]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [newCustomer, setNewCustomer] = useState(false);
|
||||
@@ -186,7 +186,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
|
||||
? await projectsApi.update(project!.id, base)
|
||||
: await projectsApi.create({
|
||||
...base,
|
||||
tasks: tasks.filter((t) => t.name.trim()),
|
||||
tasks: tasks.filter((t) => t.name.trim()).map((t) => ({ name: t.name.trim(), billable: t.billable, rate: num(t.rate) })),
|
||||
// Abruf kommt ohne Topf.
|
||||
pots: type === 'abruf' ? [] : pots.map((p) => potPayload(p, settings.hours_per_day)),
|
||||
});
|
||||
@@ -334,6 +334,10 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
|
||||
{!editing && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm text-ink-900">Tasks</h3>
|
||||
<p className="mb-2 text-[11px] leading-snug text-ink-400">
|
||||
Der Satz je Task ist freiwillig. Bleibt er leer, gilt der Satz des Budgettopfs bzw. des Abruf-Projekts – ein
|
||||
eigener Satz lohnt etwa für „vor Ort“ neben „remote“.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
@@ -343,15 +347,25 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
|
||||
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 });
|
||||
if (i === tasks.length - 1 && e.target.value) next.push({ name: '', billable: true, rate: '' });
|
||||
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>
|
||||
<>
|
||||
<input
|
||||
className={`${inputClass} w-28 shrink-0`}
|
||||
value={task.rate}
|
||||
disabled={!task.billable}
|
||||
placeholder={unit === 'day' ? '€/Tag' : '€/Std.'}
|
||||
title="Eigener Satz für diesen Task – leer heißt: Satz des Budgettopfs"
|
||||
onChange={(e) => setTasks(tasks.map((t, j) => (j === i ? { ...t, rate: e.target.value } : t)))}
|
||||
/>
|
||||
<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, rate: e.target.checked ? t.rate : '' } : t)))} />
|
||||
billable
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
+2
-2
@@ -232,8 +232,8 @@ export const projectsApi = {
|
||||
addPot: (id: number, pot: PotInput) => send<Project>('POST', `/projects/${id}/pots`, pot),
|
||||
updatePot: (id: number, potId: number, pot: PotInput) => send<Project>('PUT', `/projects/${id}/pots/${potId}`, pot),
|
||||
removePot: (id: number, potId: number) => send<Project>('DELETE', `/projects/${id}/pots/${potId}`),
|
||||
addTask: (id: number, task: { name: string; billable?: boolean }) => send<Project>('POST', `/projects/${id}/tasks`, task),
|
||||
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean }>) =>
|
||||
addTask: (id: number, task: { name: string; billable?: boolean; rate?: number | null }) => send<Project>('POST', `/projects/${id}/tasks`, task),
|
||||
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean; rate: number | null }>) =>
|
||||
send<Project>('PUT', `/projects/${id}/tasks/${taskId}`, task),
|
||||
removeTask: (id: number, taskId: number) => send<Project>('DELETE', `/projects/${id}/tasks/${taskId}`),
|
||||
};
|
||||
|
||||
@@ -128,6 +128,8 @@ export interface Task {
|
||||
project_id: number;
|
||||
name: string;
|
||||
billable: boolean;
|
||||
/** Eigener Satz fuer diesen Task (z. B. "vor Ort"); null = Satz des Budgettopfs. */
|
||||
rate: number | null;
|
||||
is_active: boolean;
|
||||
used_minutes: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user