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,319 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Modal, ConfirmDialog } from '../ui/Modal';
|
||||
import { BudgetBar, Button, Field, inputClass } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { entriesApi } from '../../lib/api';
|
||||
import { formatDate, formatDuration, parseDuration } from '../../lib/format';
|
||||
import { useToast } from '../Toast';
|
||||
import { checkPot } from './budget';
|
||||
import { PROJECT_TYPE_LABELS, type Project, type TimeEntry } from '../../types';
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
date: string;
|
||||
entry?: TimeEntry | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
|
||||
function inListOrder(projects: Project[]) {
|
||||
const map = new Map<string, Project[]>();
|
||||
for (const p of projects) map.set(p.customer.name, [...(map.get(p.customer.name) || []), p]);
|
||||
return [...map.values()].flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorauswahl fuer neue Buchungen: das erste billable Projekt der Liste -
|
||||
* bevorzugt eines, auf das am Datum auch gebucht werden kann (Topf-Laufzeit,
|
||||
* Budget). Gibt es kein billable Projekt, bleibt nur ein einziges Projekt
|
||||
* vorausgewaehlt.
|
||||
*/
|
||||
function defaultProject(projects: Project[], date: string, settings: { hours_per_day: number; budget_warn_percent: number; budget_warn_days: number }): number | '' {
|
||||
const ordered = inListOrder(projects);
|
||||
const billable = ordered.filter((p) => p.type === 'billable');
|
||||
const bookableNow = billable.find(
|
||||
(p) => checkPot(p, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days).ok
|
||||
);
|
||||
if (bookableNow) return bookableNow.id;
|
||||
if (billable.length) return billable[0].id;
|
||||
return ordered.length === 1 ? ordered[0].id : '';
|
||||
}
|
||||
|
||||
/** Erster aktiver Task in Listenreihenfolge; ohne Tasks bleibt "Ohne Task". */
|
||||
function firstTask(project: Project | undefined): number | '' {
|
||||
return project?.tasks.find((t) => t.is_active)?.id ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
|
||||
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
|
||||
*/
|
||||
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved }) => {
|
||||
const { settings } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
|
||||
|
||||
const [projectId, setProjectId] = useState<number | ''>(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings));
|
||||
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
|
||||
const [taskId, setTaskId] = useState<number | ''>(() =>
|
||||
entry ? entry.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId))
|
||||
);
|
||||
const [note, setNote] = useState(entry?.note ?? '');
|
||||
const [date, setDate] = useState(entry?.entry_date ?? initialDate);
|
||||
const [timeText, setTimeText] = useState(entry ? formatDuration(entry.minutes) : '0:00');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const timeRef = useRef<HTMLInputElement>(null);
|
||||
const projectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
/*
|
||||
* Schnell buchen: ist ein Projekt vorausgewaehlt, liegt der Fokus sofort auf
|
||||
* der Zeit (Inhalt markiert, also direkt ueberschreiben). Sonst zuerst das
|
||||
* Projekt waehlen. Kurz verzoegert, damit die Dialog-Animation nicht stoert.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (projectId) {
|
||||
timeRef.current?.focus();
|
||||
timeRef.current?.select();
|
||||
} else {
|
||||
projectRef.current?.focus();
|
||||
}
|
||||
}, 60);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const project = bookable.find((p) => p.id === projectId) ?? null;
|
||||
const tasks = project?.tasks.filter((t) => t.is_active || t.id === entry?.task_id) ?? [];
|
||||
const minutes = parseDuration(timeText);
|
||||
const validTime = minutes != null && minutes > 0 && minutes <= 24 * 60;
|
||||
|
||||
// Projekt gewechselt: passt der Task nicht mehr, den ersten Task des neuen Projekts nehmen.
|
||||
useEffect(() => {
|
||||
if (taskId && tasks.some((t) => t.id === taskId)) return;
|
||||
if (!taskId && entry && entry.project_id === projectId) return; // bewusst ohne Task gebucht
|
||||
setTaskId(firstTask(project ?? undefined));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
const billable = project ? project.type !== 'non_billable' && (task ? task.billable : true) : true;
|
||||
const check = project
|
||||
? checkPot(
|
||||
project,
|
||||
date,
|
||||
validTime ? minutes! : 0,
|
||||
settings.hours_per_day,
|
||||
settings.budget_warn_percent,
|
||||
settings.budget_warn_days,
|
||||
entry ? { potId: entry.pot_id, minutes: project.type === 'billable' && !entry.billable ? 0 : entry.minutes } : null,
|
||||
billable
|
||||
)
|
||||
: null;
|
||||
|
||||
// Gruppiert nach Kunde fuer die Auswahl.
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Project[]>();
|
||||
for (const p of bookable) {
|
||||
const key = p.customer.name;
|
||||
map.set(key, [...(map.get(key) || []), p]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}, [bookable]);
|
||||
|
||||
const save = async () => {
|
||||
if (!project) return addToast('Bitte ein Projekt wählen.', 'error');
|
||||
if (!validTime) {
|
||||
timeRef.current?.focus();
|
||||
return addToast('Bitte eine Zeit eingeben, z. B. 1:30 oder 1,5.', 'error');
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = { project_id: project.id, task_id: taskId || null, entry_date: date, minutes: minutes!, note };
|
||||
const result = entry ? await entriesApi.update(entry.id, payload) : await entriesApi.create(payload);
|
||||
addToast(`${formatDuration(minutes!)} h auf ${project.display_name} ${entry ? 'aktualisiert' : 'gebucht'}.`, 'success');
|
||||
if (result.warning) addToast(result.warning, 'info');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!entry) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await entriesApi.remove(entry.id);
|
||||
addToast('Buchung gelöscht.', 'success');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
save();
|
||||
}
|
||||
};
|
||||
|
||||
const pot = check?.pot;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={entry ? 'Buchung bearbeiten' : 'Zeit buchen'}
|
||||
subtitle={formatDate(date, 'long')}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{entry ? (
|
||||
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
|
||||
Löschen
|
||||
</Button>
|
||||
) : (
|
||||
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button icon="check" busy={busy} onClick={save} disabled={!project || !validTime || check?.ok === false}>
|
||||
{entry ? 'Speichern' : 'Buchen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{bookable.length === 0 ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm font-semibold text-amber-700">
|
||||
Dir ist noch kein Projekt zugewiesen. Bitte deinen Vorgesetzten, dich einem Projekt zuzuordnen.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4" onKeyDown={onKeyDown}>
|
||||
<Field label="Projekt" required>
|
||||
<select className={inputClass} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')} ref={projectRef}>
|
||||
<option value="">Projekt wählen…</option>
|
||||
{groups.map(([customer, list]) => (
|
||||
<optgroup key={customer} label={customer}>
|
||||
{list.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name} · {p.code}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Task">
|
||||
<select className={inputClass} value={taskId} onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : '')} disabled={!project || tasks.length === 0}>
|
||||
<option value="">{project && tasks.length === 0 ? 'Keine Tasks angelegt' : 'Ohne Task'}</option>
|
||||
{tasks.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
{project?.type !== 'non_billable' && !t.billable ? ' (nicht abrechenbar)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-[1fr_7.5rem] gap-3 sm:grid-cols-[1fr_9rem]">
|
||||
<Field label="Beschreibung (optional)">
|
||||
<textarea
|
||||
className={`${inputClass} h-[92px] resize-none`}
|
||||
value={note}
|
||||
maxLength={1000}
|
||||
placeholder="Was wurde gemacht?"
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zeit" required>
|
||||
<input
|
||||
ref={timeRef}
|
||||
inputMode="decimal"
|
||||
className={`${inputClass} tabular h-[92px] !px-2 text-center !text-3xl font-extrabold ${timeText && !validTime && timeText !== '0:00' ? 'border-red-300 text-red-600' : ''}`}
|
||||
value={timeText}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onKeyDown={(e) => {
|
||||
// Im Zeitfeld reicht Enter zum Buchen.
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setTimeText(e.target.value)}
|
||||
onBlur={() => validTime && setTimeText(formatDuration(minutes!))}
|
||||
aria-describedby="time-hint"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<p id="time-hint" className="-mt-2 text-right text-[11px] text-ink-400">
|
||||
{validTime ? (
|
||||
<span className="font-bold text-brand-700">
|
||||
= {Math.floor(minutes! / 60)} Std. {minutes! % 60} Min. · {(minutes! / 60).toLocaleString('de-DE', { maximumFractionDigits: 2 })} h
|
||||
</span>
|
||||
) : (
|
||||
'Format 1:30 oder 1,5'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Field label="Datum">
|
||||
<input type="date" className={inputClass} value={date} onChange={(e) => e.target.value && setDate(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
{project && (
|
||||
<div
|
||||
className={`animate-fade rounded-2xl border p-4 ${
|
||||
check?.level === 'error' ? 'border-red-200 bg-red-50' : check?.level === 'warn' ? 'border-amber-200 bg-amber-50' : 'border-ink-200 bg-ink-50/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon
|
||||
name={check?.level === 'error' ? 'warning' : check?.level === 'warn' ? 'warning' : 'pot'}
|
||||
className={`mt-0.5 h-5 w-5 shrink-0 ${check?.level === 'error' ? 'text-red-600' : check?.level === 'warn' ? 'text-amber-600' : 'text-brand-600'}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-extrabold uppercase tracking-wider text-ink-500">
|
||||
{PROJECT_TYPE_LABELS[project.type]} · {project.code}
|
||||
</p>
|
||||
<p className={`mt-1 text-sm font-semibold ${check?.level === 'error' ? 'text-red-700' : check?.level === 'warn' ? 'text-amber-700' : 'text-ink-700'}`}>
|
||||
{check?.message ?? 'Freies Projekt ohne Budgettopf.'}
|
||||
</p>
|
||||
{pot && pot.budget_hours != null && (
|
||||
<div className="mt-3">
|
||||
<BudgetBar percent={pot.percent_used} warnAt={settings.budget_warn_percent} />
|
||||
<p className="tabular mt-1 flex justify-between text-[11px] font-bold text-ink-500">
|
||||
<span>{formatDuration(pot.used_minutes)} h gebucht</span>
|
||||
<span>{pot.budget_hours.toLocaleString('de-DE')} h Budget</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title="Buchung löschen?"
|
||||
message={`${formatDuration(entry!.minutes)} h auf ${entry!.project_display} am ${formatDate(entry!.entry_date)}.`}
|
||||
onConfirm={remove}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user