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