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
+164
View File
@@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { Icon } from '../ui/Icon';
import { useIsMobile, useGrow } from '../../lib/hooks';
import { WEEKDAYS_LONG, WEEKDAYS_SHORT, addDays, colorFor, formatDuration, fromIso, todayIso } from '../../lib/format';
import type { TimeEntry } from '../../types';
interface Props {
from: string;
entries: TimeEntry[];
readOnly: boolean;
dailyTarget: number;
onAdd: (date: string) => void;
onEdit: (entry: TimeEntry) => void;
}
/**
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
*/
export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit }) => {
const isMobile = useIsMobile();
const today = todayIso();
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
const byDay = (date: string) => entries.filter((e) => e.entry_date === date);
const [selected, setSelected] = useState(() => (today >= from && today <= days[6] ? today : from));
useEffect(() => {
setSelected(today >= from && today <= addDays(from, 6) ? today : from);
}, [from, today]);
if (isMobile) {
const list = byDay(selected);
const total = list.reduce((s, e) => s + e.minutes, 0);
return (
<div>
<div className="mb-3 grid grid-cols-7 gap-1">
{days.map((date, i) => {
const minutes = byDay(date).reduce((s, e) => s + e.minutes, 0);
const active = date === selected;
return (
<button
key={date}
onClick={() => setSelected(date)}
className={`flex flex-col items-center rounded-2xl border py-2 transition ${
active ? 'border-brand-400 bg-brand-500 text-white shadow-card' : date === today ? 'border-brand-200 bg-brand-50 text-brand-700' : 'border-ink-200 bg-surface text-ink-600'
}`}
>
<span className="text-[10px] font-bold uppercase">{WEEKDAYS_SHORT[i]}</span>
<span className="text-lg font-extrabold leading-tight">{fromIso(date).getDate()}</span>
<span className={`tabular text-[10px] font-bold ${active ? 'text-white/85' : minutes ? 'text-ink-500' : 'text-ink-300'}`}>{minutes ? formatDuration(minutes) : '–'}</span>
</button>
);
})}
</div>
<div className="mb-2 flex items-baseline justify-between px-1">
<p className="text-sm font-extrabold text-ink-900">{WEEKDAYS_LONG[days.indexOf(selected)]}</p>
<p className="tabular text-sm font-extrabold text-ink-600">{formatDuration(total)} h</p>
</div>
<div key={selected} className="space-y-2">
{list.map((entry, i) => (
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} readOnly={readOnly} large />
))}
{list.length === 0 && (
<button
disabled={readOnly}
onClick={() => onAdd(selected)}
className="flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-ink-200 py-10 text-sm font-bold text-ink-400"
>
<Icon name="plus" className="h-6 w-6" />
{readOnly ? 'Keine Buchungen' : 'Zeit für diesen Tag buchen'}
</button>
)}
</div>
</div>
);
}
return (
<div className="grid grid-cols-7 gap-2 xl:gap-3">
{days.map((date, i) => {
const list = byDay(date);
const minutes = list.reduce((s, e) => s + e.minutes, 0);
const weekend = i >= 5;
const isToday = date === today;
return (
<div
key={date}
className={`animate-rise group flex min-h-[340px] min-w-0 flex-col rounded-2xl border ${
isToday ? 'border-brand-300 bg-brand-50/50' : weekend ? 'border-ink-200 bg-ink-50/60' : 'border-ink-200 bg-surface'
} shadow-card`}
style={{ '--i': i } as React.CSSProperties}
>
<div className="flex items-start justify-between gap-1 border-b border-ink-100 px-3 pb-2 pt-3">
<div className="min-w-0">
<p className={`text-[11px] font-extrabold uppercase tracking-wider ${isToday ? 'text-brand-600' : 'text-ink-400'}`}>{WEEKDAYS_SHORT[i]}</p>
<p className={`text-2xl font-extrabold leading-none ${isToday ? 'text-brand-700' : 'text-ink-900'}`}>{fromIso(date).getDate()}.</p>
</div>
<div className="text-right">
<p className="tabular text-sm font-extrabold text-ink-800">{minutes ? formatDuration(minutes) : ''}</p>
{!weekend && <DayMeter minutes={minutes} target={dailyTarget} />}
</div>
</div>
<div className="flex flex-1 flex-col gap-2 p-2">
{list.map((entry, j) => (
<EntryCard key={entry.id} entry={entry} index={j} onClick={() => onEdit(entry)} readOnly={readOnly} />
))}
{!readOnly && (
<button
onClick={() => onAdd(date)}
className={`mt-auto flex items-center justify-center gap-1 rounded-xl border border-dashed border-ink-200 py-2 text-xs font-bold text-ink-400 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 ${
list.length ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : ''
}`}
>
<Icon name="plus" className="h-3.5 w-3.5" strokeWidth={2.4} />
Buchen
</button>
)}
</div>
</div>
);
})}
</div>
);
};
const DayMeter: React.FC<{ minutes: number; target: number }> = ({ minutes, target }) => {
const grown = useGrow(minutes);
const p = target ? Math.min(100, (minutes / target) * 100) : 0;
return (
<div className="mt-1 h-1 w-12 overflow-hidden rounded-full bg-ink-100" title={`${Math.round(p)} % vom Tagessoll`}>
<div className={`h-full rounded-full transition-[width] duration-700 ${p >= 100 ? 'bg-brand-500' : 'bg-brand-300'}`} style={{ width: grown ? `${p}%` : 0 }} />
</div>
);
};
export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: () => void; readOnly: boolean; large?: boolean }> = ({
entry,
index,
onClick,
readOnly,
large,
}) => {
const color = colorFor(entry.customer_id, entry.customer_color);
return (
<button
onClick={onClick}
disabled={readOnly && !large}
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
style={{ '--i': index } as React.CSSProperties}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-2">
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
</div>
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
{!entry.billable && <span className="mt-1 inline-block rounded-full bg-ink-100 px-1.5 text-[9px] font-bold uppercase tracking-wide text-ink-500">non-billable</span>}
</button>
);
};