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>
536 lines
25 KiB
TypeScript
536 lines
25 KiB
TypeScript
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('.', ','));
|
||
}}
|
||
/>
|
||
);
|
||
};
|