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