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,185 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Spinner, inputClass } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { BILLABLE, CountUp, Legend, NON_BILLABLE, StackedBars } from '../ui/charts';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ExportMenu } from './ExportMenu';
|
||||
import { ReportKpi, ReportRow } from './ProjectReport';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
|
||||
import { useLoader } from '../../lib/hooks';
|
||||
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { TYPE_TONE } from '../projects/Projects';
|
||||
import { PROJECT_TYPE_LABELS } from '../../types';
|
||||
|
||||
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
|
||||
const { navigate } = useRouter();
|
||||
const customers = useLoader(() => customersApi.list(), []);
|
||||
const report = useLoader(() => (id ? reportsApi.customer(id) : Promise.resolve(null)), [id]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/kunde/${e.target.value}` : 'reports/kunde')}>
|
||||
<option value="">Kunde wählen…</option>
|
||||
{(customers.data ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!id ? (
|
||||
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
|
||||
) : report.error ? (
|
||||
<ErrorBox message={report.error} onRetry={report.reload} />
|
||||
) : !report.data ? (
|
||||
<Spinner label="Lade Kundenreport…" />
|
||||
) : (
|
||||
<Body key={id} data={report.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
||||
const { settings } = useAuth();
|
||||
const { navigate } = useRouter();
|
||||
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
|
||||
const [months, setMonths] = useState(12);
|
||||
const c = data.customer;
|
||||
const t = data.totals;
|
||||
const color = colorFor(c.id, c.color);
|
||||
const month = todayIso().slice(0, 7);
|
||||
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
|
||||
|
||||
const exportOptions = [
|
||||
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Vorlage für den Kunden, alle Berater', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', customer_id: c.id })}`, filename: `${c.short_name}-${month}.pdf` },
|
||||
{ label: 'Excel – Stundennachweis mit Beträgen', hint: 'Für die Rechnungsstellung', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', customer_id: c.id, amounts: 1 })}`, filename: `${c.short_name}-${month}.xlsx` },
|
||||
{ label: 'PDF – Projekte (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
|
||||
{ label: 'CSV – alle Buchungen (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="animate-rise flex flex-wrap items-center gap-4">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl text-lg font-extrabold text-white shadow-card" style={{ background: color }}>
|
||||
{c.short_name.slice(0, 2)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
|
||||
<p className="text-sm text-ink-500">
|
||||
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
|
||||
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
|
||||
</p>
|
||||
</div>
|
||||
<ExportMenu options={exportOptions} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<ReportKpi index={1} title="Gesamtstunden">
|
||||
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
|
||||
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
||||
</ReportKpi>
|
||||
<ReportKpi index={2} title="Umsatz erbracht">
|
||||
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
|
||||
</ReportKpi>
|
||||
<ReportKpi index={3} title="Umsatz offen">
|
||||
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<ReportRow label="in laufenden Töpfen" value={`${data.projects.reduce((s, p) => s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} />
|
||||
</ReportKpi>
|
||||
<ReportKpi index={4} title="Budget gesamt">
|
||||
<CountUp value={t.budget_value} format={(v) => formatEuro(v, 0)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<BudgetBar className="mt-3" percent={t.budget_value ? (t.earned / t.budget_value) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||
</ReportKpi>
|
||||
</div>
|
||||
|
||||
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 5 } as React.CSSProperties}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-base text-ink-900">Stunden je Monat</h3>
|
||||
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
|
||||
</div>
|
||||
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
|
||||
<option value={6}>6 Monate</option>
|
||||
<option value={12}>12 Monate</option>
|
||||
<option value={24}>24 Monate</option>
|
||||
</select>
|
||||
</div>
|
||||
{series.length ? (
|
||||
<StackedBars
|
||||
scale={60}
|
||||
points={series.map((m) => ({ key: m.month, label: `${MONTHS[Number(m.month.slice(5)) - 1].slice(0, 3)} ${m.month.slice(2, 4)}`, a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
|
||||
format={(v) => `${formatHours(v, 1)} h`}
|
||||
axisFormat={(v) => formatNumber(v / 60, 0)}
|
||||
height={220}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-10 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="animate-rise overflow-hidden" style={{ '--i': 6 } as React.CSSProperties}>
|
||||
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
|
||||
{(
|
||||
[
|
||||
['projects', 'Projekte'],
|
||||
['team', 'Team'],
|
||||
['tasks', 'Tasks'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button key={key} onClick={() => setTab(key)} className={`relative px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
|
||||
{label}
|
||||
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'team' && <ReportTable rows={data.team} kind="team" label="Name" />}
|
||||
{tab === 'tasks' && <ReportTable rows={data.tasks} kind="tasks" label="Task" />}
|
||||
{tab === 'projects' && (
|
||||
<div className="divide-y divide-ink-100">
|
||||
{data.projects.length === 0 && <p className="p-6 text-center text-sm text-ink-400">Keine Projekte.</p>}
|
||||
{data.projects.map(({ project: p, totals, pots }, i) => (
|
||||
<button key={p.id} onClick={() => navigate(`reports/projekt/${p.id}`)} className="animate-rise block w-full p-4 text-left hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="tabular text-[11px] font-bold text-ink-400">{p.code}</p>
|
||||
<p className="flex items-center gap-2 font-extrabold text-ink-900">
|
||||
{p.display_name}
|
||||
<Icon name="chevronRight" className="h-4 w-4 text-ink-300" />
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||
{!p.is_active && <Badge>Archiv</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabular mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
|
||||
<ReportRow label="Stunden" value={formatHours(totals.minutes)} />
|
||||
<ReportRow label="Budget" value={p.budget_hours == null ? '∞' : `${formatNumber(p.budget_hours, 0)} h`} />
|
||||
<ReportRow label="Erbracht" value={formatEuro(p.earned, 0)} />
|
||||
<ReportRow label="Offen" value={formatEuro(p.open, 0)} />
|
||||
</div>
|
||||
<BudgetBar className="mt-2" percent={p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||
{pots.length > 1 && (
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pots.map((pot) => (
|
||||
<div key={pot.id} className="rounded-xl border border-ink-200 p-2.5">
|
||||
<p className="flex justify-between gap-2 text-xs font-bold text-ink-700">
|
||||
<span className="truncate">{pot.label}</span>
|
||||
<span className="tabular text-ink-500">{formatHours(pot.used_minutes, 1)} h</span>
|
||||
</p>
|
||||
<BudgetBar className="mt-1.5" height="h-1.5" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { download } from '../../lib/api';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
export interface ExportOption {
|
||||
label: string;
|
||||
hint?: string;
|
||||
path: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const WIDTH = 264;
|
||||
|
||||
/**
|
||||
* Aufklappmenue "Export".
|
||||
*
|
||||
* Die Liste haengt per Portal am <body> und wird fest am Knopf positioniert.
|
||||
* Ohne das legen sich die Karten darunter darueber: ihre Einblend-Animation
|
||||
* macht aus jeder Karte einen eigenen Stapelkontext, gegen den ein z-index
|
||||
* innerhalb der Kopfzeile nicht ankommt.
|
||||
*/
|
||||
export const ExportMenu: React.FC<{ options: ExportOption[]; label?: string }> = ({ options, label = 'Export' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const { addToast } = useToast();
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Nach links ausgerichtet an der Knopfkante, notfalls am Fensterrand geklemmt.
|
||||
const left = Math.max(8, Math.min(rect.right - WIDTH, window.innerWidth - WIDTH - 8));
|
||||
setPos({ top: rect.bottom + 8, left });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
place();
|
||||
const close = (event: MouseEvent) => {
|
||||
if (buttonRef.current?.contains(event.target as Node) || menuRef.current?.contains(event.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && setOpen(false);
|
||||
document.addEventListener('mousedown', close);
|
||||
window.addEventListener('keydown', onKey);
|
||||
// Beim Scrollen mitwandern (der Inhalt scrollt in einem eigenen Bereich).
|
||||
window.addEventListener('resize', place);
|
||||
document.addEventListener('scroll', place, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('resize', place);
|
||||
document.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
const run = async (option: ExportOption) => {
|
||||
setBusy(option.label);
|
||||
try {
|
||||
await download(option.path, option.filename);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Export fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={buttonRef} className="relative">
|
||||
<Button size="sm" variant="secondary" icon="download" onClick={() => setOpen(!open)} aria-expanded={open} aria-haspopup="menu">
|
||||
{label}
|
||||
<Icon name="chevronDown" className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} strokeWidth={2.2} />
|
||||
</Button>
|
||||
{open &&
|
||||
pos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{ top: pos.top, left: pos.left, width: WIDTH }}
|
||||
className="animate-pop fixed z-[300] max-h-[70dvh] overflow-y-auto rounded-2xl border border-ink-200 bg-surface p-1.5 shadow-lift"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.label}
|
||||
role="menuitem"
|
||||
onClick={() => run(o)}
|
||||
disabled={Boolean(busy)}
|
||||
className="flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left hover:bg-ink-50 disabled:opacity-60"
|
||||
>
|
||||
{busy === o.label ? (
|
||||
<span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||
) : (
|
||||
<Icon
|
||||
name={o.label.includes('PDF') ? 'document' : o.label.includes('Excel') ? 'grid' : 'list'}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-ink-400"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-bold text-ink-800">{o.label}</span>
|
||||
{o.hint && <span className="block text-[11px] text-ink-400">{o.hint}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { CountUp, ProgressChart } from '../ui/charts';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ExportMenu } from './ExportMenu';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { projectsApi, qs, reportsApi, type ProjectDetail } from '../../lib/api';
|
||||
import { useLoader } from '../../lib/hooks';
|
||||
import { formatDate, formatEuro, formatHours, formatNumber, mondayOf, todayIso } from '../../lib/format';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { TYPE_TONE } from '../projects/Projects';
|
||||
import { PROJECT_TYPE_LABELS } from '../../types';
|
||||
|
||||
export const ProjectReport: React.FC<{ id: number | null }> = ({ id }) => {
|
||||
const { navigate } = useRouter();
|
||||
const projects = useLoader(() => projectsApi.list(), []);
|
||||
const report = useLoader(() => (id ? reportsApi.project(id) : Promise.resolve(null)), [id]);
|
||||
|
||||
const options = useMemo(() => [...(projects.data ?? [])].sort((a, b) => a.display_name.localeCompare(b.display_name, 'de')), [projects.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/projekt/${e.target.value}` : 'reports/projekt')}>
|
||||
<option value="">Projekt wählen…</option>
|
||||
{options.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name} · {p.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!id ? (
|
||||
<EmptyState icon="folder" title="Projekt auswählen" text="Verlauf, Budget, Tasks, Team und Budgettöpfe eines Projekts." />
|
||||
) : report.error ? (
|
||||
<ErrorBox message={report.error} onRetry={report.reload} />
|
||||
) : !report.data ? (
|
||||
<Spinner label="Lade Projektreport…" />
|
||||
) : (
|
||||
<ProjectReportBody key={id} detail={report.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectReportBody: React.FC<{ detail: ProjectDetail; compact?: boolean }> = ({ detail, compact }) => {
|
||||
const { settings } = useAuth();
|
||||
const { navigate } = useRouter();
|
||||
const [chart, setChart] = useState<'progress' | 'weekly'>('progress');
|
||||
const [tab, setTab] = useState<'tasks' | 'team' | 'pots'>('tasks');
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
const p = detail.project;
|
||||
const t = detail.totals;
|
||||
const budgetHours = p.budget_hours;
|
||||
const remaining = budgetHours != null ? Math.max(0, budgetHours - p.budget_used_minutes / 60) : null;
|
||||
const remainingPct = budgetHours ? Math.round(((remaining ?? 0) / budgetHours) * 100) : null;
|
||||
const thisWeek = mondayOf(todayIso());
|
||||
const virtualRate = p.type === 'fixed' && p.used_minutes ? (p.budget_value ?? 0) / (p.used_minutes / 60) : null;
|
||||
const month = todayIso().slice(0, 7);
|
||||
|
||||
const exportOptions = [
|
||||
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Alle Mitarbeiter, aktueller Monat', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', project_id: p.id })}`, filename: `${p.code}.pdf` },
|
||||
{ label: 'Excel – Stundennachweis (Monat)', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', project_id: p.id, amounts: 1 })}`, filename: `${p.code}.xlsx` },
|
||||
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${p.code}.csv` },
|
||||
{ label: 'PDF – Tasks gesamt', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'tasks', format: 'pdf', include_fixed: 1 })}`, filename: `${p.code}-tasks.pdf` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!compact && (
|
||||
<div className="animate-rise">
|
||||
<button onClick={() => navigate(`reports/kunde/${p.customer_id}`)} className="text-sm font-bold text-ink-500 hover:text-brand-700">
|
||||
{p.customer.name}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<h2 className="text-2xl leading-tight text-ink-900 sm:text-3xl">
|
||||
<span className="tabular text-ink-400">[{p.code}]</span> {p.display_name}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => navigate(`projekte/${p.id}`)} className="flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 hover:border-brand-300 hover:text-brand-700">
|
||||
<Icon name="edit" className="h-3.5 w-3.5" /> Projekt öffnen
|
||||
</button>
|
||||
<ExportMenu options={exportOptions} label="Aktionen" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-ink-500">
|
||||
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="calendar" className="h-4 w-4" />
|
||||
Start: {formatDate(p.start_date)}
|
||||
{p.end_date && ` · Ende: ${formatDate(p.end_date)}`}
|
||||
</span>
|
||||
</div>
|
||||
{p.notes && (
|
||||
<div className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||
<p className={showNotes ? 'whitespace-pre-line' : 'line-clamp-2'}>{p.notes}</p>
|
||||
<button onClick={() => setShowNotes(!showNotes)} className="text-xs font-bold text-sky-700 underline">
|
||||
{showNotes ? 'Weniger' : 'Mehr anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 1 } as React.CSSProperties}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={chart}
|
||||
onChange={setChart}
|
||||
options={[
|
||||
{ value: 'progress', label: 'Projektverlauf' },
|
||||
{ value: 'weekly', label: 'Stunden pro Woche' },
|
||||
]}
|
||||
/>
|
||||
<span className="text-xs text-ink-400">Stunden · Start {formatDate(p.start_date)}</span>
|
||||
</div>
|
||||
<ProgressChart
|
||||
mode={chart}
|
||||
budget={budgetHours}
|
||||
format={(v) => `${formatNumber(v, 0)} h`}
|
||||
points={detail.weekly.map((w) => ({
|
||||
label: w.label,
|
||||
value: w.cumulative_minutes == null ? null : w.cumulative_minutes / 60,
|
||||
bar: w.billable_minutes / 60,
|
||||
barB: (w.minutes - w.billable_minutes) / 60,
|
||||
current: w.week_start === thisWeek,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<Kpi index={2} title="Gesamtstunden">
|
||||
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<Row label="Billable" value={formatHours(t.billable_minutes)} />
|
||||
<Row label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
||||
</Kpi>
|
||||
<Kpi index={3} title={`Budget verbleibend${remainingPct != null ? ` (${remainingPct} %)` : ''}`}>
|
||||
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining, 2)}</p>
|
||||
<Row label="Gesamtbudget" value={budgetHours == null ? 'unbegrenzt' : `${formatNumber(budgetHours, 2)} h`} />
|
||||
<BudgetBar className="mt-2" percent={budgetHours ? (p.budget_used_minutes / 60 / budgetHours) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||
</Kpi>
|
||||
<Kpi index={4} title={p.type === 'fixed' ? 'Virtueller Stundensatz' : 'Budget in Tagen'}>
|
||||
{p.type === 'fixed' ? (
|
||||
<>
|
||||
<p className="tabular text-3xl font-extrabold text-ink-900">{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}</p>
|
||||
<Row label="Festpreis ÷ Stunden" value="nur intern" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining / settings.hours_per_day, 1)}</p>
|
||||
<Row label="Tage frei von" value={budgetHours == null ? '–' : formatNumber(budgetHours / settings.hours_per_day, 1)} />
|
||||
</>
|
||||
)}
|
||||
</Kpi>
|
||||
<Kpi index={5} title="Umsatz erbracht">
|
||||
<CountUp value={p.earned ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<Row label="Budget gesamt" value={formatEuro(p.budget_value, 2)} />
|
||||
</Kpi>
|
||||
<Kpi index={6} title="Umsatz offen">
|
||||
<CountUp value={p.open ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||
<Row label="Töpfe aktiv" value={`${p.pots.filter((x) => x.status === 'active').length} von ${p.pots.length}`} />
|
||||
</Kpi>
|
||||
</div>
|
||||
|
||||
<Card className="animate-rise overflow-hidden" style={{ '--i': 7 } as React.CSSProperties}>
|
||||
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
|
||||
{(
|
||||
[
|
||||
['tasks', 'Tasks'],
|
||||
['team', 'Team'],
|
||||
['pots', 'Budgettöpfe'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button key={key} onClick={() => setTab(key)} className={`relative px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
|
||||
{label}
|
||||
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'tasks' && <ReportTable rows={detail.tasks} kind="tasks" label="Task" />}
|
||||
{tab === 'team' && <ReportTable rows={detail.team} kind="team" label="Name" />}
|
||||
{tab === 'pots' && <PotsBreakdown detail={detail} />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Gesamtuebersicht aller Toepfe und darunter jeder Topf einzeln. */
|
||||
const PotsBreakdown: React.FC<{ detail: ProjectDetail }> = ({ detail }) => {
|
||||
const { settings } = useAuth();
|
||||
const p = detail.project;
|
||||
const totalBudget = p.pots.reduce((s, x) => s + (x.budget_hours ?? 0), 0);
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="rounded-2xl bg-ink-50 p-4">
|
||||
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt · {p.pots.length} Töpfe</p>
|
||||
<div className="tabular mt-1 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<Row label="Gebucht" value={`${formatHours(p.used_minutes)} h`} />
|
||||
<Row label="Budget" value={totalBudget ? `${formatNumber(totalBudget, 2)} h` : 'unbegrenzt'} />
|
||||
<Row label="Erbracht" value={formatEuro(p.earned, 2)} />
|
||||
<Row label="Offen" value={formatEuro(p.open, 2)} />
|
||||
</div>
|
||||
<BudgetBar className="mt-3" percent={totalBudget ? (p.budget_used_minutes / 60 / totalBudget) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||
</div>
|
||||
{detail.pots.map((pot, i) => (
|
||||
<div key={pot.id} className="animate-rise rounded-2xl border border-ink-200" style={{ '--i': i } as React.CSSProperties}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 p-4">
|
||||
<div>
|
||||
<p className="font-extrabold text-ink-900">{pot.label}</p>
|
||||
<p className="text-xs text-ink-400">
|
||||
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)} · {pot.status === 'active' ? 'aktiv' : pot.status === 'expired' ? 'abgelaufen' : pot.status === 'exhausted' ? 'aufgebraucht' : 'geplant'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="tabular flex flex-wrap gap-4 text-xs">
|
||||
<Row label="Gebucht" value={`${formatHours(pot.used_minutes)} h`} />
|
||||
<Row label="Frei" value={pot.remaining_hours == null ? '∞' : `${formatNumber(pot.remaining_hours, 2)} h`} />
|
||||
<Row label="Erbracht" value={formatEuro(pot.earned, 2)} />
|
||||
<Row label="Offen" value={formatEuro(pot.open, 2)} />
|
||||
</div>
|
||||
</div>
|
||||
<BudgetBar className="mx-4 mb-3" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
|
||||
<div className="grid border-t border-ink-100 lg:grid-cols-2">
|
||||
<ReportTable rows={pot.tasks} kind="tasks" label="Task" totals={false} />
|
||||
<ReportTable rows={pot.team} kind="team" label="Team" totals={false} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Kpi: React.FC<{ title: string; index: number; children: React.ReactNode }> = ({ title, index, children }) => (
|
||||
<Card className="animate-rise p-4" style={{ '--i': index } as React.CSSProperties}>
|
||||
<p className="mb-1 text-sm font-semibold text-ink-500">{title}</p>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const Row: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<p className="tabular mt-1 flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-ink-500">{label}</span>
|
||||
<b className="text-ink-900">{value}</b>
|
||||
</p>
|
||||
);
|
||||
|
||||
export { Row as ReportRow, Kpi as ReportKpi };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { ExportMenu } from './ExportMenu';
|
||||
import { CustomerReport } from './CustomerReport';
|
||||
import { ProjectReport } from './ProjectReport';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { customersApi, qs, reportsApi, usersApi } from '../../lib/api';
|
||||
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||
import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { atLeast, type Customer, type DirectoryUser } from '../../types';
|
||||
|
||||
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||||
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
|
||||
|
||||
function periodRange(period: Period, anchor: string, custom: { from: string; to: string }) {
|
||||
if (period === 'week') {
|
||||
const from = mondayOf(anchor);
|
||||
return { from, to: addDays(from, 6), label: `KW ${isoWeek(from)} · ${formatDate(from)} – ${formatDate(addDays(from, 6))}` };
|
||||
}
|
||||
if (period === 'month') return { from: monthStart(anchor), to: monthEnd(anchor), label: `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` };
|
||||
if (period === 'quarter') {
|
||||
const q = Math.floor((Number(anchor.slice(5, 7)) - 1) / 3);
|
||||
const from = `${anchor.slice(0, 4)}-${String(q * 3 + 1).padStart(2, '0')}-01`;
|
||||
return { from, to: monthEnd(addMonths(from, 2)), label: `Q${q + 1} ${anchor.slice(0, 4)}` };
|
||||
}
|
||||
if (period === 'year') return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31`, label: anchor.slice(0, 4) };
|
||||
return { ...custom, label: `${formatDate(custom.from)} – ${formatDate(custom.to)}` };
|
||||
}
|
||||
|
||||
export const Reports: React.FC = () => {
|
||||
const { route, navigate } = useRouter();
|
||||
const [kind, id] = route.params;
|
||||
const view = kind === 'kunde' ? 'customer' : kind === 'projekt' ? 'project' : 'time';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<div className="no-scrollbar mb-4 flex items-center gap-1 overflow-x-auto overflow-y-hidden border-b border-ink-200">
|
||||
{(
|
||||
[
|
||||
['time', 'Zeitreport', 'reports'],
|
||||
['customer', 'Kundenreport', 'reports/kunde'],
|
||||
['project', 'Projektreport', 'reports/projekt'],
|
||||
] as const
|
||||
).map(([key, label, path]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => navigate(path)}
|
||||
className={`relative whitespace-nowrap px-4 py-2.5 text-sm font-bold transition ${view === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}
|
||||
>
|
||||
{label}
|
||||
{view === key && <span className="animate-pop absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-brand-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === 'time' && <TimeReport />}
|
||||
{view === 'customer' && <CustomerReport id={id ? Number(id) : null} />}
|
||||
{view === 'project' && <ProjectReport id={id ? Number(id) : null} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeReport: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { navigate } = useRouter();
|
||||
const [period, setPeriod] = usePersistentState<Period>('pulse_report_period', 'month');
|
||||
const [anchor, setAnchor] = useState(todayIso());
|
||||
const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() });
|
||||
const [tab, setTab] = usePersistentState<Tab>('pulse_report_tab', 'users');
|
||||
const [customerId, setCustomerId] = useState<number | ''>('');
|
||||
const [team, setTeam] = useState<number | ''>('');
|
||||
const [includeFixed, setIncludeFixed] = useState(false);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [leads, setLeads] = useState<DirectoryUser[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list().then(setCustomers).catch(() => undefined);
|
||||
usersApi
|
||||
.directory()
|
||||
.then((list) => setLeads(list.filter((u) => (u.role === 'lead' || u.role === 'management') && u.is_active)))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const range = periodRange(period, anchor, custom);
|
||||
const report = useLoader(
|
||||
() => reportsApi.time({ from: range.from, to: range.to, customer_id: customerId || null, team: team || null, include_fixed: includeFixed }),
|
||||
[range.from, range.to, customerId, team, includeFixed]
|
||||
);
|
||||
|
||||
const shift = (step: number) => {
|
||||
if (period === 'week') setAnchor(addDays(anchor, 7 * step));
|
||||
if (period === 'month') setAnchor(addMonths(anchor, step));
|
||||
if (period === 'quarter') setAnchor(addMonths(anchor, 3 * step));
|
||||
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`);
|
||||
};
|
||||
|
||||
const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined };
|
||||
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
|
||||
const exportOptions = useMemo(
|
||||
() => [
|
||||
{ label: `PDF – ${groupName}`, hint: 'Mit Logo, Kopf und Fuß', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'pdf' })}`, filename: 'Zeitreport.pdf' },
|
||||
{ label: `Excel – ${groupName}`, hint: 'Mit Summenformeln', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'xlsx' })}`, filename: 'Zeitreport.xlsx' },
|
||||
{ label: `CSV – ${groupName}`, path: `/exports/report${qs({ ...exportParams, group: tab, format: 'csv' })}`, filename: 'Zeitreport.csv' },
|
||||
{ label: 'Excel – alle Buchungen', hint: 'Detailliste für die Rechnung', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'xlsx' })}`, filename: 'Zeitreport-Detail.xlsx' },
|
||||
{ label: 'PDF – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'pdf' })}`, filename: 'Zeitreport-Detail.pdf' },
|
||||
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tab, range.from, range.to, customerId, team, includeFixed]
|
||||
);
|
||||
|
||||
const data = report.data;
|
||||
const s = data?.summary;
|
||||
const share = s && s.minutes ? (s.billable_minutes / s.minutes) * 100 : 0;
|
||||
const capacity = data?.users.reduce((sum, u) => sum + (u.capacity_minutes ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{atLeast(user, 'management') && leads.length > 0 && (
|
||||
<select className={`${inputClass} !w-auto max-w-[220px]`} value={team} onChange={(e) => setTeam(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">Alle Teams</option>
|
||||
{leads.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
Team {l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">Alle Kunden</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
|
||||
<option value="week">Woche</option>
|
||||
<option value="month">Monat</option>
|
||||
<option value="quarter">Quartal</option>
|
||||
<option value="year">Jahr</option>
|
||||
<option value="custom">Zeitraum</option>
|
||||
</select>
|
||||
{period === 'custom' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} onChange={(e) => setCustom({ ...custom, from: e.target.value })} />
|
||||
<span className="text-ink-400">–</span>
|
||||
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} onChange={(e) => setCustom({ ...custom, to: e.target.value })} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
|
||||
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Zurück">
|
||||
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
|
||||
</button>
|
||||
<span className="tabular flex items-center gap-2 border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-700">
|
||||
<Icon name="calendar" className="h-4 w-4 text-ink-400" />
|
||||
{range.label}
|
||||
</span>
|
||||
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Weiter">
|
||||
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
|
||||
{!data ? (
|
||||
<Spinner label="Lade Report…" />
|
||||
) : (
|
||||
<>
|
||||
<Card className={`animate-rise grid gap-6 p-5 transition-opacity sm:p-6 lg:grid-cols-[1fr_1.2fr_1.3fr_1fr] ${report.loading ? 'opacity-60' : ''}`}>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink-500">Gesamtstunden</p>
|
||||
<CountUp value={s!.minutes / 60} format={(v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />
|
||||
<p className="mt-1 text-xs text-ink-400">{s!.entries} Buchungen</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Donut percent={share} size={96} stroke={13} />
|
||||
<div className="flex-1 space-y-1.5 text-sm">
|
||||
<p className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2 font-semibold text-ink-600">
|
||||
<span className="h-3 w-3 rounded-sm" style={{ background: BILLABLE }} /> Billable
|
||||
</span>
|
||||
<b className="tabular text-ink-900">{formatHours(s!.billable_minutes)}</b>
|
||||
</p>
|
||||
<p className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2 font-semibold text-ink-600">
|
||||
<span className="h-3 w-3 rounded-sm" style={{ background: NON_BILLABLE }} /> Non-billable
|
||||
</span>
|
||||
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink-500">Billable Betrag</p>
|
||||
<CountUp value={s!.amount} format={(v) => formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-semibold text-ink-700">
|
||||
<input type="checkbox" checked={includeFixed} onChange={(e) => setIncludeFixed(e.target.checked)} className="h-4 w-4 accent-[var(--color-brand-500)]" />
|
||||
Festpreisprojekte einbeziehen
|
||||
</label>
|
||||
<p className="mt-1 text-[11px] leading-snug text-ink-400">
|
||||
Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
|
||||
<CountUp value={capacity ? (s!.minutes / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
|
||||
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
|
||||
{!includeFixed && s!.fixed_amount > 0 && <p className="mt-2 text-[11px] text-ink-400">+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten</p>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="animate-rise overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink-100 px-4 pt-2">
|
||||
<div className="no-scrollbar flex gap-1 overflow-x-auto overflow-y-hidden">
|
||||
{(
|
||||
[
|
||||
['clients', 'Kunden'],
|
||||
['projects', 'Projekte'],
|
||||
['tasks', 'Tasks'],
|
||||
['users', 'Mitarbeiter'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button key={key} onClick={() => setTab(key)} className={`relative whitespace-nowrap px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
|
||||
{label}
|
||||
<span className="ml-1 text-[11px] text-ink-400">{data[key].length}</span>
|
||||
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<ExportMenu options={exportOptions} />
|
||||
<IconButton icon="printer" label="Drucken" onClick={() => window.print()} className="hidden border border-ink-200 sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
<ReportTable
|
||||
key={tab}
|
||||
rows={data[tab]}
|
||||
kind={tab}
|
||||
label={{ clients: 'Kunde', projects: 'Projekt', tasks: 'Task', users: 'Name' }[tab]}
|
||||
onRowClick={
|
||||
tab === 'clients'
|
||||
? (row) => navigate(`reports/kunde/${row.customer_id}`)
|
||||
: tab === 'projects'
|
||||
? (row) => navigate(`reports/projekt/${row.project_id}`)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { periodRange };
|
||||
Reference in New Issue
Block a user