Files
perinco-Pulse/src/components/booking/YearView.tsx
T
schakal00783andClaude Opus 5 ef676a95f4 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>
2026-09-17 22:52:20 +02:00

96 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { MONTHS, addDays, formatDuration, fromIso, mondayOf, todayIso, weekdayIndex } from '../../lib/format';
import { useGrow } from '../../lib/hooks';
import { BudgetBar, Card } from '../ui/controls';
import type { TimeEntry } from '../../types';
/** Jahresansicht: Monatskacheln mit Soll/Ist und eine Heatmap aller Tage. */
export const YearView: React.FC<{ year: string; entries: TimeEntry[]; weeklyTarget: number; onOpenMonth: (month: string) => void }> = ({
year,
entries,
weeklyTarget,
onOpenMonth,
}) => {
const today = todayIso();
const perDay = new Map<string, number>();
for (const e of entries) perDay.set(e.entry_date, (perDay.get(e.entry_date) || 0) + e.minutes);
const grown = useGrow(year);
const months = MONTHS.map((name, i) => {
const key = `${year}-${String(i + 1).padStart(2, '0')}`;
const list = entries.filter((e) => e.entry_date.startsWith(key));
const minutes = list.reduce((s, e) => s + e.minutes, 0);
const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
let workdays = 0;
for (let d = `${key}-01`; d.startsWith(key); d = addDays(d, 1)) {
if (d > today) break;
if (weekdayIndex(d) < 5) workdays++;
}
const target = (weeklyTarget / 5) * workdays * 60;
return { key, name, minutes, billable, target };
});
const max = Math.max(1, ...months.map((m) => m.minutes));
// Heatmap: Wochen als Spalten, Mo-So als Zeilen.
const first = mondayOf(`${year}-01-01`);
const weeks: string[][] = [];
for (let d = first; d <= `${year}-12-31`; d = addDays(d, 7)) {
weeks.push(Array.from({ length: 7 }, (_, i) => addDays(d, i)));
}
const level = (minutes: number) => (minutes === 0 ? 0 : minutes < 180 ? 1 : minutes < 360 ? 2 : minutes < 480 ? 3 : 4);
const LEVELS = ['var(--color-ink-100)', 'var(--color-brand-200)', 'var(--color-brand-300)', 'var(--color-brand-500)', 'var(--color-brand-700)'];
return (
<div className="space-y-4">
<Card className="animate-rise overflow-x-auto p-4">
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">Buchungsdichte {year}</p>
<div className="flex gap-[3px]" style={{ minWidth: weeks.length * 14 }}>
{weeks.map((week, w) => (
<div key={week[0]} className="flex flex-col gap-[3px]">
{week.map((date) => {
const minutes = perDay.get(date) || 0;
const out = !date.startsWith(year);
return (
<span
key={date}
title={`${fromIso(date).toLocaleDateString('de-DE')}: ${formatDuration(minutes)} h`}
className="h-[11px] w-[11px] rounded-[3px] transition-opacity duration-500"
style={{ background: out ? 'transparent' : LEVELS[level(minutes)], opacity: grown ? 1 : 0, transitionDelay: `${w * 8}ms` }}
/>
);
})}
</div>
))}
</div>
<div className="mt-3 flex items-center justify-end gap-1 text-[10px] font-bold text-ink-400">
weniger
{LEVELS.map((c) => (
<span key={c} className="h-[11px] w-[11px] rounded-[3px]" style={{ background: c }} />
))}
mehr
</div>
</Card>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{months.map((m, i) => (
<button
key={m.key}
onClick={() => onOpenMonth(m.key)}
className="animate-rise rounded-2xl border border-ink-200 bg-surface p-4 text-left shadow-card transition hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift"
style={{ '--i': i } as React.CSSProperties}
>
<p className={`text-xs font-extrabold uppercase tracking-wider ${m.key === today.slice(0, 7) ? 'text-brand-600' : 'text-ink-400'}`}>{m.name}</p>
<p className="tabular mt-1 text-2xl font-extrabold text-ink-900">{formatDuration(m.minutes)}</p>
<div className="mt-3 flex h-12 items-end gap-1">
<div className="flex-1 rounded-t-md bg-brand-500 transition-[height] duration-700" style={{ height: grown ? `${(m.billable / max) * 100}%` : 0 }} title="billable" />
<div className="flex-1 rounded-t-md bg-brand-200 transition-[height] duration-700" style={{ height: grown ? `${((m.minutes - m.billable) / max) * 100}%` : 0 }} title="non-billable" />
</div>
<BudgetBar className="mt-3" height="h-1.5" percent={m.target ? (m.minutes / m.target) * 100 : null} goal />
<p className="tabular mt-1 text-[10px] font-bold text-ink-400">Soll {m.target ? formatDuration(m.target) : '–'} h</p>
</button>
))}
</div>
</div>
);
};