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:
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user