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
+405
View File
@@ -0,0 +1,405 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { Avatar, Button, Field, IconButton, Segmented, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { projectsApi, type PotInput } from '../../lib/api';
import { addDays, addMonths, formatNumber, todayIso } from '../../lib/format';
import { useToast } from '../Toast';
import { CustomerForm } from '../customers/CustomerForm';
import type { Customer, DirectoryUser, Project, ProjectType } from '../../types';
export interface PotDraft {
label: string;
valid_from: string;
valid_to: string;
budget_amount: string;
rate: string;
budget_hours: string;
budget_unit: 'hours' | 'days';
notes: string;
}
export function emptyPot(start = todayIso(), label = 'Budget 1'): PotDraft {
return { label, valid_from: start, valid_to: addDays(addMonths(start, 12), -1), budget_amount: '', rate: '', budget_hours: '', budget_unit: 'hours', notes: '' };
}
/** Deutsche und englische Schreibweise: "48.000,50", "48000.5", "110,5". */
export function num(value: string): number | null {
const v = value.trim().replace(/\s|€/g, '');
if (v === '') return null;
if (v.includes(',')) return Number(v.replace(/\./g, '').replace(',', '.'));
if (/^\d{1,3}(\.\d{3})+$/.test(v)) return Number(v.replace(/\./g, ''));
return Number(v);
}
export function potPayload(draft: PotDraft, hoursPerDay: number): PotInput {
const hours = num(draft.budget_hours);
return {
label: draft.label,
valid_from: draft.valid_from,
valid_to: draft.valid_to,
budget_amount: num(draft.budget_amount),
rate: num(draft.rate),
budget_hours: hours == null ? null : draft.budget_unit === 'days' ? hours * hoursPerDay : hours,
notes: draft.notes || null,
};
}
/** Vorschau des buchbaren Volumens - gleiche Rechnung wie im Backend. */
export function potVolume(draft: PotDraft, type: ProjectType, unit: 'hour' | 'day', hoursPerDay: number) {
const amount = num(draft.budget_amount);
const rate = num(draft.rate);
if (type === 'billable' && amount != null && rate) return (amount / rate) * (unit === 'day' ? hoursPerDay : 1);
const hours = num(draft.budget_hours);
if (hours == null) return null;
return draft.budget_unit === 'days' ? hours * hoursPerDay : hours;
}
/** Felder eines Budgettopfs - im Projektformular und im Topfdialog. */
export const PotFields: React.FC<{
draft: PotDraft;
onChange: (draft: PotDraft) => void;
type: ProjectType;
unit: 'hour' | 'day';
}> = ({ draft, onChange, type, unit }) => {
const { settings } = useAuth();
const set = (patch: Partial<PotDraft>) => onChange({ ...draft, ...patch });
const volume = potVolume(draft, type, unit, settings.hours_per_day);
const unitLabel = unit === 'day' ? 'Tagessatz' : 'Stundensatz';
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Bezeichnung" required className="sm:col-span-1">
<input className={inputClass} value={draft.label} onChange={(e) => set({ label: e.target.value })} placeholder="z. B. Bestellung 4500030022" />
</Field>
<Field label="Laufzeit von" required>
<input type="date" className={inputClass} value={draft.valid_from} onChange={(e) => set({ valid_from: e.target.value })} />
</Field>
<Field label="Laufzeit bis" required>
<input type="date" className={inputClass} value={draft.valid_to} onChange={(e) => set({ valid_to: e.target.value })} />
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{type !== 'non_billable' && (
<Field label={type === 'fixed' ? 'Festpreis (€)' : 'Budget (€)'} required={type === 'fixed'}>
<input inputMode="decimal" className={inputClass} value={draft.budget_amount} onChange={(e) => set({ budget_amount: e.target.value })} placeholder="z. B. 48000" />
</Field>
)}
{type === 'billable' && (
<Field label={`${unitLabel} (€)`} required>
<input inputMode="decimal" className={inputClass} value={draft.rate} onChange={(e) => set({ rate: e.target.value })} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} />
</Field>
)}
{(type !== 'billable' || !num(draft.budget_amount)) && (
<Field label={type === 'fixed' ? 'Kontingent (optional)' : type === 'billable' ? 'oder Kontingent' : 'Kontingent (leer = unbegrenzt)'}>
<div className="flex gap-2">
<input inputMode="decimal" className={inputClass} value={draft.budget_hours} onChange={(e) => set({ budget_hours: e.target.value })} placeholder="–" />
<select className={`${inputClass} w-24`} value={draft.budget_unit} onChange={(e) => set({ budget_unit: e.target.value as 'hours' | 'days' })}>
<option value="hours">Std.</option>
<option value="days">Tage</option>
</select>
</div>
</Field>
)}
</div>
<p className="flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2 text-xs font-semibold text-brand-800">
<Icon name="pot" className="h-4 w-4 shrink-0" />
{volume == null
? 'Unbegrenzt buchbar innerhalb der Laufzeit.'
: `Buchbar: ${formatNumber(volume, 2)} Std. = ${formatNumber(volume / settings.hours_per_day, 1)} Tage – danach gesperrt, ebenso nach Ablauf.`}
{type === 'fixed' && ' Virtueller Stundensatz = Festpreis ÷ gebuchte Stunden (intern).'}
</p>
</div>
);
};
interface Props {
project?: Project | null;
customers: Customer[];
users: DirectoryUser[];
onClose: () => void;
onSaved: (project: Project) => void;
onCustomerCreated: (customer: Customer) => void;
}
export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClose, onSaved, onCustomerCreated }) => {
const { user, settings } = useAuth();
const { addToast } = useToast();
const editing = Boolean(project);
const [customerId, setCustomerId] = useState<number | ''>(project?.customer_id ?? '');
const [name, setName] = useState(project?.name ?? '');
const [type, setType] = useState<ProjectType>(project?.type ?? 'billable');
const [unit, setUnit] = useState<'hour' | 'day'>(project?.billing_unit ?? 'hour');
const [start, setStart] = useState(project?.start_date ?? todayIso());
const [end, setEnd] = useState(project?.end_date ?? '');
const isAdmin = user!.role === 'admin';
const [ownerId, setOwnerId] = useState<number | ''>(project?.owner_id ?? (isAdmin ? '' : user!.id));
const [tags, setTags] = useState<string[]>(project?.tags ?? []);
const [tagText, setTagText] = useState('');
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 [pots, setPots] = useState<PotDraft[]>([emptyPot()]);
const [busy, setBusy] = useState(false);
const [newCustomer, setNewCustomer] = useState(false);
const customer = customers.find((c) => c.id === customerId);
const codePreview = customer
? settings.project_code_pattern.replaceAll('{KUERZEL}', customer.short_name).replaceAll('{JAHR}', start.slice(0, 4)).replaceAll('{JJ}', start.slice(2, 4)).replaceAll('{NR}', '###')
: '–';
const namePreview = customer && name
? settings.project_name_pattern.replaceAll('{KUNDE}', customer.short_name === 'PERINCO' ? 'perinco' : customer.short_name).replaceAll('{KUNDENNAME}', customer.name).replaceAll('{PROJEKT}', name).replaceAll('{CODE}', project?.code ?? codePreview)
: '';
const activeUsers = useMemo(
() => users.filter((u) => u.is_active && u.role !== 'admin' && (!memberFilter || u.name.toLowerCase().includes(memberFilter.toLowerCase()))),
[users, memberFilter]
);
const addTag = () => {
const parts = tagText.split(',').map((t) => t.trim()).filter(Boolean);
if (parts.length) setTags([...new Set([...tags, ...parts])]);
setTagText('');
};
const save = async () => {
setBusy(true);
try {
const base = {
customer_id: customerId,
name,
type,
billing_unit: unit,
start_date: start,
end_date: end || null,
owner_id: ownerId || null,
tags,
notes,
member_ids: members,
};
const saved = editing
? await projectsApi.update(project!.id, base)
: await projectsApi.create({
...base,
tasks: tasks.filter((t) => t.name.trim()),
pots: pots.map((p) => potPayload(p, settings.hours_per_day)),
});
addToast(editing ? 'Projekt gespeichert.' : `Projekt ${saved.code} angelegt.`, 'success');
onSaved(saved);
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
return (
<>
<Modal
size="xl"
title={editing ? `Projekt bearbeiten` : 'Neues Projekt'}
subtitle={editing ? `${project!.code} · ${project!.display_name}` : 'Projektcode wird automatisch vergeben'}
onClose={onClose}
footer={
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="check" busy={busy} onClick={save}>
{editing ? 'Speichern' : 'Projekt anlegen'}
</Button>
</div>
}
>
<div className="space-y-6">
<section className="grid gap-4 sm:grid-cols-2">
<Field label="Kunde" required>
<div className="flex gap-2">
<select className={inputClass} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Kunde wählen…</option>
{customers
.filter((c) => c.is_active || c.id === customerId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.short_name})
</option>
))}
</select>
<IconButton icon="plus" label="Neuen Kunden anlegen" type="button" onClick={() => setNewCustomer(true)} className="border border-ink-200" />
</div>
</Field>
<Field label="Projektname" required hint={namePreview ? <>Anzeige: <b className="text-ink-700">{namePreview}</b></> : `Muster: ${settings.project_name_pattern}`}>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="z. B. PMO" />
</Field>
<Field label="Projektcode">
<input className={`${inputClass} tabular`} value={project?.code ?? codePreview} disabled />
</Field>
<Field label="Projektverantwortlicher">
<select className={inputClass} value={ownerId} onChange={(e) => setOwnerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">–</option>
{users
.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management'))
.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
</section>
<section className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Projekttyp</span>
<div className="grid gap-2 sm:grid-cols-3">
{(
[
['billable', 'Billable', 'Abrechnung nach Stunden oder Tagen'],
['non_billable', 'Non-billable', 'z. B. perinco intern'],
['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'],
] as const
).map(([value, label, hint]) => (
<button
key={value}
type="button"
onClick={() => setType(value)}
className={`rounded-xl border p-3 text-left transition ${type === value ? 'border-brand-400 bg-brand-50 shadow-card' : 'border-ink-200 hover:border-brand-200'}`}
>
<span className={`block text-sm font-extrabold ${type === value ? 'text-brand-700' : 'text-ink-800'}`}>{label}</span>
<span className="block text-[11px] text-ink-400">{hint}</span>
</button>
))}
</div>
</div>
{type === 'billable' && (
<Field label="Abrechnung">
<Segmented value={unit} onChange={setUnit} options={[{ value: 'hour', label: 'Preis pro Stunde' }, { value: 'day', label: 'Preis pro Tag' }]} className="w-full" />
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Startdatum" required>
<input type="date" className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
</Field>
<Field label="Enddatum">
<input type="date" className={inputClass} value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
</div>
</section>
{!editing && (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm text-ink-900">Budgettöpfe</h3>
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPots([...pots, emptyPot(start, `Budget ${pots.length + 1}`)])}>
Topf hinzufügen
</Button>
</div>
<div className="space-y-3">
{pots.map((pot, i) => (
<div key={i} className="relative rounded-2xl border border-ink-200 p-4">
{pots.length > 1 && (
<IconButton icon="trash" tone="danger" label="Topf entfernen" className="absolute right-2 top-2" onClick={() => setPots(pots.filter((_, j) => j !== i))} />
)}
<PotFields draft={pot} type={type} unit={unit} onChange={(next) => setPots(pots.map((p, j) => (j === i ? next : p)))} />
</div>
))}
</div>
</section>
)}
{!editing && (
<section>
<h3 className="mb-2 text-sm text-ink-900">Tasks</h3>
<div className="space-y-2">
{tasks.map((task, i) => (
<div key={i} className="flex items-center gap-2">
<input
className={inputClass}
value={task.name}
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 });
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>
)}
</div>
))}
</div>
</section>
)}
<section>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm text-ink-900">
Zugewiesene Mitarbeiter <span className="text-ink-400">({members.length})</span>
</h3>
<input className={`${inputClass} w-48 py-1.5`} placeholder="Suchen…" value={memberFilter} onChange={(e) => setMemberFilter(e.target.value)} />
</div>
<div className="scrollbar-slim grid max-h-56 gap-1.5 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">
{activeUsers.map((u) => {
const checked = members.includes(u.id);
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'}`}>
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={checked} onChange={() => setMembers(checked ? members.filter((id) => id !== u.id) : [...members, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={24} external={u.is_external} />
<span className="min-w-0 truncate text-xs font-bold text-ink-700">{u.name}</span>
</label>
);
})}
</div>
</section>
<section className="grid gap-4 sm:grid-cols-2">
<Field label="Tags">
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-ink-200 px-2 py-1.5">
{tags.map((t) => (
<span key={t} className="flex items-center gap-1 rounded-full bg-ink-100 px-2 py-0.5 text-xs font-bold text-ink-600">
{t}
<button type="button" onClick={() => setTags(tags.filter((x) => x !== t))} aria-label={`${t} entfernen`}>
<Icon name="close" className="h-3 w-3" />
</button>
</span>
))}
<input
className="min-w-[80px] flex-1 bg-transparent px-1 py-1 text-sm outline-none"
value={tagText}
placeholder="Tag + Enter"
onChange={(e) => setTagText(e.target.value)}
onBlur={addTag}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag();
}
}}
/>
</div>
</Field>
<Field label="Notizen">
<textarea className={`${inputClass} h-24 resize-y`} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="z. B. Rechnungen nur mit Belegnummer…" />
</Field>
</section>
</div>
</Modal>
{newCustomer && (
<CustomerForm
onClose={() => setNewCustomer(false)}
onSaved={(c) => {
onCustomerCreated(c);
setCustomerId(c.id);
}}
/>
)}
</>
);
};