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:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+185
View File
@@ -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>
);
};
+116
View File
@@ -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>
);
};
+248
View File
@@ -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 };
+124
View File
@@ -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>
);
};
+260
View File
@@ -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 };