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,124 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Avatar } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { SplitBar } from '../ui/charts';
|
||||
import { colorFor, formatEuro, formatHours } from '../../lib/format';
|
||||
import type { GroupRow } from '../../lib/api';
|
||||
|
||||
type SortKey = 'name' | 'minutes' | 'billable_minutes' | 'amount' | 'utilization';
|
||||
|
||||
/**
|
||||
* Tabelle wie im Harvest-Report: Name, Stunden mit Doppelbalken, Auslastung,
|
||||
* abrechenbare Stunden mit Anteil, Betrag. Spaltenkoepfe sortieren.
|
||||
*/
|
||||
export const ReportTable: React.FC<{
|
||||
rows: GroupRow[];
|
||||
label: string;
|
||||
kind: 'clients' | 'projects' | 'tasks' | 'users' | 'team';
|
||||
showAmounts?: boolean;
|
||||
onRowClick?: (row: GroupRow) => void;
|
||||
totals?: boolean;
|
||||
}> = ({ rows, label, kind, showAmounts = true, onRowClick, totals = true }) => {
|
||||
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: kind === 'users' ? 'name' : 'minutes', dir: kind === 'users' ? 1 : -1 });
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...rows].sort((a, b) => {
|
||||
const av = sort.key === 'name' ? a.name : (a[sort.key] ?? -1);
|
||||
const bv = sort.key === 'name' ? b.name : (b[sort.key] ?? -1);
|
||||
return (typeof av === 'string' ? av.localeCompare(bv as string, 'de') : (av as number) - (bv as number)) * sort.dir;
|
||||
}),
|
||||
[rows, sort]
|
||||
);
|
||||
const max = Math.max(1, ...rows.map((r) => r.minutes));
|
||||
const showUtil = kind === 'users';
|
||||
const sum = rows.reduce(
|
||||
(s, r) => ({ minutes: s.minutes + r.minutes, billable: s.billable + r.billable_minutes, amount: s.amount + r.amount }),
|
||||
{ minutes: 0, billable: 0, amount: 0 }
|
||||
);
|
||||
|
||||
const head = (key: SortKey, text: string, align: 'left' | 'right' = 'right', className = '') => (
|
||||
<th className={`px-3 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
|
||||
<button
|
||||
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'name' ? 1 : -1 })}
|
||||
className={`inline-flex items-center gap-1 font-extrabold uppercase tracking-wider hover:text-ink-700 ${sort.key === key ? 'text-ink-700' : ''}`}
|
||||
>
|
||||
{text}
|
||||
{sort.key === key && <Icon name={sort.dir === 1 ? 'chevronUp' : 'chevronDown'} className="h-3 w-3" strokeWidth={2.5} />}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
|
||||
if (rows.length === 0) return <p className="px-4 py-10 text-center text-sm text-ink-400">Keine Buchungen im Zeitraum.</p>;
|
||||
|
||||
return (
|
||||
<div className="scrollbar-slim overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
|
||||
{head('name', label, 'left', 'pl-4')}
|
||||
{head('minutes', 'Stunden')}
|
||||
<th className="w-[22%] px-3 py-3" />
|
||||
{showUtil && head('utilization', 'Auslastung')}
|
||||
{head('billable_minutes', 'Billable Std.')}
|
||||
{showAmounts && head('amount', 'Billable Betrag', 'right', 'pr-4')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((r, i) => {
|
||||
const share = r.minutes ? Math.round((r.billable_minutes / r.minutes) * 100) : 0;
|
||||
const clickable = Boolean(onRowClick);
|
||||
return (
|
||||
<tr
|
||||
key={String(r.key)}
|
||||
onClick={() => onRowClick?.(r)}
|
||||
className={`animate-rise border-b border-ink-100 last:border-0 ${clickable ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'}`}
|
||||
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
|
||||
>
|
||||
<td className="py-3 pl-4 pr-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{kind === 'users' || kind === 'team' ? (
|
||||
<Avatar id={r.user_id} name={r.name} color={r.color} size={28} external={r.is_external} />
|
||||
) : kind !== 'tasks' ? (
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
|
||||
) : null}
|
||||
<span className="min-w-0">
|
||||
<span className={`block truncate font-bold ${clickable ? 'text-ink-900 underline decoration-ink-200 underline-offset-4 group-hover:decoration-brand-400' : 'text-ink-800'}`}>{r.name}</span>
|
||||
{r.customer_name && <span className="block truncate text-[11px] text-ink-400">{r.customer_name}{r.code ? ` · ${r.code}` : ''}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="tabular px-3 py-3 text-right font-extrabold text-ink-900">{formatHours(r.minutes)}</td>
|
||||
<td className="px-3 py-3">
|
||||
<SplitBar a={r.billable_minutes} b={r.non_billable_minutes} max={max} delay={i * 30} />
|
||||
</td>
|
||||
{showUtil && (
|
||||
<td className={`tabular px-3 py-3 text-right font-bold ${r.utilization == null ? 'text-ink-300' : r.utilization > 105 ? 'text-red-600' : r.utilization >= 75 ? 'text-brand-700' : 'text-ink-600'}`}>
|
||||
{r.utilization == null ? '–' : `${r.utilization} %`}
|
||||
</td>
|
||||
)}
|
||||
<td className="tabular px-3 py-3 text-right text-ink-700">
|
||||
{formatHours(r.billable_minutes)} <span className="inline-block w-14 whitespace-nowrap text-ink-400">({share} %)</span>
|
||||
</td>
|
||||
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right font-extrabold text-ink-900">{formatEuro(r.amount, 2)}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
{totals && (
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-ink-200 bg-ink-50/60 font-extrabold text-ink-900">
|
||||
<td className="py-3 pl-4 pr-3">Summe</td>
|
||||
<td className="tabular px-3 py-3 text-right">{formatHours(sum.minutes)}</td>
|
||||
<td />
|
||||
{showUtil && <td />}
|
||||
<td className="tabular px-3 py-3 text-right">
|
||||
{formatHours(sum.billable)} <span className="inline-block w-14 whitespace-nowrap font-normal text-ink-400">({sum.minutes ? Math.round((sum.billable / sum.minutes) * 100) : 0} %)</span>
|
||||
</td>
|
||||
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right">{formatEuro(sum.amount, 2)}</td>}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user