feat: perinco Pulse - Zeiterfassung fuer perinco

Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+441
View File
@@ -0,0 +1,441 @@
import React, { 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 } 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 [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;
await run(() => projectsApi.addTask(p.id, { name: newTask.trim(), billable: p.type !== 'non_billable' }));
setNewTask('');
};
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' && ` · 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" />
Budgettöpfe
</h3>
{canEdit && (
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}>
Topf
</Button>
)}
</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`}
</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="tabular shrink-0 text-xs font-extrabold text-ink-600">{formatHours(t.used_minutes, 1)} h</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' && (
<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 && (
<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()} />
<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} 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',
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} 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>
);
};