feat(sprint9): Phase 5 — Berichtszentrale, sidebar reorg, dashboard enhancement
- Sidebar: reorganized into 4 collapsible groups (Betrieb, Kommunikation, Verwaltung, Compliance) - Berichtszentrale: new /reports-center page with report cards grouped by category (Finance, KCanG, DSGVO, Admin), format selector, date range pickers, Behörden-Export dialog with password protection - Dashboard: added Outstanding Payments and Monthly Income KPI cards, Upcoming Events widget, Latest Announcements widget, conditional alert cards - Pricing: fixed mobile overflow at 375px viewport on comparison table - Frontend service: new compliance-reports.ts with React Query hooks for report generation, authority export, and download - i18n: added reportsCenter.* and dashboard widget keys to de.json and en.json
This commit is contained in:
@@ -66,7 +66,15 @@
|
||||
"today": "Heute",
|
||||
"trend": "+{value}% ggü. Vormonat",
|
||||
"quotaUsed": "{value}% verbraucht",
|
||||
"distributionCount": "{count} Ausgaben, {grams}g"
|
||||
"distributionCount": "{count} Ausgaben, {grams}g",
|
||||
"outstandingPayments": "Offene Zahlungen",
|
||||
"monthlyIncome": "Monatliches Einkommen",
|
||||
"thisMonth": "Diesen Monat",
|
||||
"strainsAvailable": "Sorten verfügbar",
|
||||
"upcomingEvents": "Nächste Termine",
|
||||
"latestAnnouncements": "Neueste Beiträge",
|
||||
"rsvps": "Zusagen",
|
||||
"viewAll": "Alle anzeigen"
|
||||
},
|
||||
"members": {
|
||||
"title": "Mitgliederverwaltung",
|
||||
@@ -1004,5 +1012,100 @@
|
||||
"positions": "Positionen",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv"
|
||||
},
|
||||
"reportsCenter": {
|
||||
"title": "Berichtszentrale",
|
||||
"subtitle": "Alle gesetzlichen und internen Berichte an einem Ort generieren und verwalten.",
|
||||
"generate": "Generieren",
|
||||
"cancel": "Abbrechen",
|
||||
"authorityExport": {
|
||||
"title": "Behörden-Export",
|
||||
"description": "Gebündelter Export aller behördlich relevanten Dokumente für ein Kalenderjahr.",
|
||||
"button": "Behörden-Export starten",
|
||||
"dialogTitle": "Behörden-Export erstellen",
|
||||
"dialogDescription": "Erstellt ein passwortgeschütztes Archiv mit allen compliance-relevanten Berichten für das gewählte Jahr.",
|
||||
"warning": "Dieser Export enthält sensible Daten und wird im Audit-Protokoll erfasst. Bitte nur bei berechtigter Anforderung durchführen.",
|
||||
"year": "Berichtsjahr",
|
||||
"password": "Passwort für Archiv",
|
||||
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
||||
"confirm": "Export erstellen"
|
||||
},
|
||||
"categories": {
|
||||
"finance": "Finanzen",
|
||||
"kcang": "KCanG-Compliance",
|
||||
"dsgvo": "DSGVO",
|
||||
"admin": "Verwaltung"
|
||||
},
|
||||
"reports": {
|
||||
"EUER": {
|
||||
"name": "EÜR",
|
||||
"description": "Einnahmenüberschussrechnung für den gewählten Zeitraum"
|
||||
},
|
||||
"KASSENBUCH_EXPORT": {
|
||||
"name": "Kassenbuch-Export",
|
||||
"description": "Vollständige Kassenbuchführung als PDF oder CSV"
|
||||
},
|
||||
"BEITRAGSBESCHEINIGUNG": {
|
||||
"name": "Beitragsbescheinigung",
|
||||
"description": "Bescheinigung über gezahlte Mitgliedsbeiträge"
|
||||
},
|
||||
"JAHRESBERICHT_BEHOERDE": {
|
||||
"name": "Jahresbericht Behörde",
|
||||
"description": "Gesetzlich vorgeschriebener Bericht an die zuständige Behörde"
|
||||
},
|
||||
"AUSGABEPROTOKOLL": {
|
||||
"name": "Ausgabeprotokoll",
|
||||
"description": "Protokoll aller Ausgaben im Zeitraum mit Mengen und Empfängern"
|
||||
},
|
||||
"VERNICHTUNGSPROTOKOLL": {
|
||||
"name": "Vernichtungsprotokoll",
|
||||
"description": "Dokumentation der ordnungsgemäßen Vernichtung von Cannabis"
|
||||
},
|
||||
"TRANSPORTZERTIFIKAT": {
|
||||
"name": "Transportzertifikat",
|
||||
"description": "Zertifikat für den genehmigten Transport von Cannabis"
|
||||
},
|
||||
"BESTANDSFUEHRUNG": {
|
||||
"name": "Bestandsführung",
|
||||
"description": "Aktueller Lagerbestand mit allen Ein- und Ausgängen"
|
||||
},
|
||||
"VERARBEITUNGSVERZEICHNIS": {
|
||||
"name": "Verarbeitungsverzeichnis",
|
||||
"description": "Verzeichnis aller Verarbeitungstätigkeiten gem. Art. 30 DSGVO"
|
||||
},
|
||||
"TOM": {
|
||||
"name": "TOM",
|
||||
"description": "Technische und organisatorische Maßnahmen gem. Art. 32 DSGVO"
|
||||
},
|
||||
"DSFA": {
|
||||
"name": "DSFA",
|
||||
"description": "Datenschutz-Folgenabschätzung gem. Art. 35 DSGVO"
|
||||
},
|
||||
"LOESCHKONZEPT": {
|
||||
"name": "Löschkonzept",
|
||||
"description": "Konzept zur fristgerechten Datenlöschung"
|
||||
},
|
||||
"DATENPANNEN_MELDUNG": {
|
||||
"name": "Datenpannen-Meldung",
|
||||
"description": "Vorlage zur Meldung einer Datenschutzverletzung"
|
||||
},
|
||||
"MITGLIEDERLISTE_REGISTER": {
|
||||
"name": "Mitgliederliste Register",
|
||||
"description": "Offizielle Mitgliederliste für das Vereinsregister"
|
||||
},
|
||||
"VORSTANDSAENDERUNG": {
|
||||
"name": "Vorstandsänderung",
|
||||
"description": "Meldung einer Vorstandsänderung ans Vereinsregister"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Generierte Berichte",
|
||||
"empty": "Noch keine Berichte generiert. Wähle oben einen Bericht aus, um zu beginnen.",
|
||||
"report": "Bericht",
|
||||
"format": "Format",
|
||||
"date": "Datum",
|
||||
"user": "Erstellt von",
|
||||
"size": "Größe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,15 @@
|
||||
"today": "Today",
|
||||
"trend": "+{value}% vs last month",
|
||||
"quotaUsed": "{value}% used",
|
||||
"distributionCount": "{count} distributions, {grams}g"
|
||||
"distributionCount": "{count} distributions, {grams}g",
|
||||
"outstandingPayments": "Outstanding Payments",
|
||||
"monthlyIncome": "Monthly Income",
|
||||
"thisMonth": "This month",
|
||||
"strainsAvailable": "strains available",
|
||||
"upcomingEvents": "Upcoming Events",
|
||||
"latestAnnouncements": "Latest Announcements",
|
||||
"rsvps": "RSVPs",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"members": {
|
||||
"title": "Member Management",
|
||||
@@ -1004,5 +1012,100 @@
|
||||
"positions": "Positions",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"reportsCenter": {
|
||||
"title": "Reports Center",
|
||||
"subtitle": "Generate and manage all regulatory and internal reports in one place.",
|
||||
"generate": "Generate",
|
||||
"cancel": "Cancel",
|
||||
"authorityExport": {
|
||||
"title": "Authority Export",
|
||||
"description": "Bundled export of all authority-relevant documents for a calendar year.",
|
||||
"button": "Start Authority Export",
|
||||
"dialogTitle": "Create Authority Export",
|
||||
"dialogDescription": "Creates a password-protected archive with all compliance-relevant reports for the selected year.",
|
||||
"warning": "This export contains sensitive data and will be logged in the audit trail. Only proceed with a legitimate request.",
|
||||
"year": "Report Year",
|
||||
"password": "Archive Password",
|
||||
"passwordPlaceholder": "Enter secure password",
|
||||
"confirm": "Create Export"
|
||||
},
|
||||
"categories": {
|
||||
"finance": "Finance",
|
||||
"kcang": "KCanG Compliance",
|
||||
"dsgvo": "GDPR",
|
||||
"admin": "Administration"
|
||||
},
|
||||
"reports": {
|
||||
"EUER": {
|
||||
"name": "Income Statement",
|
||||
"description": "Simplified income statement for the selected period"
|
||||
},
|
||||
"KASSENBUCH_EXPORT": {
|
||||
"name": "Cash Book Export",
|
||||
"description": "Complete cash book as PDF or CSV"
|
||||
},
|
||||
"BEITRAGSBESCHEINIGUNG": {
|
||||
"name": "Membership Fee Certificate",
|
||||
"description": "Certificate of paid membership fees"
|
||||
},
|
||||
"JAHRESBERICHT_BEHOERDE": {
|
||||
"name": "Annual Authority Report",
|
||||
"description": "Legally required annual report to the responsible authority"
|
||||
},
|
||||
"AUSGABEPROTOKOLL": {
|
||||
"name": "Distribution Log",
|
||||
"description": "Log of all distributions in the period with amounts and recipients"
|
||||
},
|
||||
"VERNICHTUNGSPROTOKOLL": {
|
||||
"name": "Destruction Protocol",
|
||||
"description": "Documentation of proper cannabis destruction"
|
||||
},
|
||||
"TRANSPORTZERTIFIKAT": {
|
||||
"name": "Transport Certificate",
|
||||
"description": "Certificate for approved cannabis transport"
|
||||
},
|
||||
"BESTANDSFUEHRUNG": {
|
||||
"name": "Inventory Report",
|
||||
"description": "Current stock with all inflows and outflows"
|
||||
},
|
||||
"VERARBEITUNGSVERZEICHNIS": {
|
||||
"name": "Processing Register",
|
||||
"description": "Register of all processing activities per Art. 30 GDPR"
|
||||
},
|
||||
"TOM": {
|
||||
"name": "TOM",
|
||||
"description": "Technical and organizational measures per Art. 32 GDPR"
|
||||
},
|
||||
"DSFA": {
|
||||
"name": "DPIA",
|
||||
"description": "Data Protection Impact Assessment per Art. 35 GDPR"
|
||||
},
|
||||
"LOESCHKONZEPT": {
|
||||
"name": "Deletion Policy",
|
||||
"description": "Policy for timely data deletion"
|
||||
},
|
||||
"DATENPANNEN_MELDUNG": {
|
||||
"name": "Data Breach Report",
|
||||
"description": "Template for reporting a data protection violation"
|
||||
},
|
||||
"MITGLIEDERLISTE_REGISTER": {
|
||||
"name": "Member Registry List",
|
||||
"description": "Official member list for the association register"
|
||||
},
|
||||
"VORSTANDSAENDERUNG": {
|
||||
"name": "Board Change Notification",
|
||||
"description": "Notification of board change to the association register"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Generated Reports",
|
||||
"empty": "No reports generated yet. Select a report above to get started.",
|
||||
"report": "Report",
|
||||
"format": "Format",
|
||||
"date": "Date",
|
||||
"user": "Created by",
|
||||
"size": "Size"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,19 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
import { Leaf, Package, Plus, TrendingUp, UserPlus, Users } from "lucide-react"
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CreditCard,
|
||||
Leaf,
|
||||
Megaphone,
|
||||
Package,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
UserPlus,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
mockClubStats,
|
||||
@@ -27,6 +39,58 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { CardSkeleton, TableSkeleton } from "@/components/ui/data-skeleton"
|
||||
|
||||
// Mock data for new widgets (backend fallback)
|
||||
const mockOutstandingPayments = { count: 3, totalCents: 14700 }
|
||||
const mockMonthlyIncome = { totalCents: 234500 }
|
||||
const mockUpcomingEvents = [
|
||||
{
|
||||
id: "e1",
|
||||
title: "Mitgliederversammlung",
|
||||
date: "2026-06-20T18:00:00Z",
|
||||
rsvpCount: 28,
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
title: "Grow-Workshop",
|
||||
date: "2026-06-22T14:00:00Z",
|
||||
rsvpCount: 12,
|
||||
},
|
||||
{
|
||||
id: "e3",
|
||||
title: "Sommerfest",
|
||||
date: "2026-06-28T16:00:00Z",
|
||||
rsvpCount: 35,
|
||||
},
|
||||
]
|
||||
const mockLatestAnnouncements = [
|
||||
{
|
||||
id: "a1",
|
||||
title: "Neue Öffnungszeiten ab Juli",
|
||||
createdAt: "2026-06-14T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
title: "Sorte 'Blue Dream' wieder verfügbar",
|
||||
createdAt: "2026-06-13T15:30:00Z",
|
||||
},
|
||||
]
|
||||
const mockAlerts: {
|
||||
type: string
|
||||
message: string
|
||||
severity: "red" | "yellow" | "blue"
|
||||
}[] = [
|
||||
{
|
||||
type: "overdue_payments",
|
||||
message: "3 überfällige Zahlungen",
|
||||
severity: "red",
|
||||
},
|
||||
{
|
||||
type: "compliance_deadline",
|
||||
message: "Jahresbericht fällig in 5 Tagen",
|
||||
severity: "yellow",
|
||||
},
|
||||
]
|
||||
|
||||
export default function DashboardPage() {
|
||||
const t = useTranslations("dashboard")
|
||||
|
||||
@@ -45,16 +109,18 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
{/* KPI Cards */}
|
||||
{/* KPI Cards — 6 total */}
|
||||
{statsLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{/* Active Members */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
@@ -106,7 +172,7 @@ export default function DashboardPage() {
|
||||
{t("grams")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mockStockByStrain.length} Sorten verfügbar
|
||||
{mockStockByStrain.length} {t("strainsAvailable")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -130,6 +196,49 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Outstanding Payments — NEW */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("outstandingPayments")}
|
||||
</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{mockOutstandingPayments.count}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(mockOutstandingPayments.totalCents / 100).toLocaleString(
|
||||
"de-DE",
|
||||
{
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Monthly Income — NEW */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t("monthlyIncome")}
|
||||
</CardTitle>
|
||||
<Wallet className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{(mockMonthlyIncome.totalCents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("thisMonth")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -161,100 +270,182 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom section: Table + Chart */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Alerts — conditional */}
|
||||
{mockAlerts.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{mockAlerts.map((alert, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
className={
|
||||
alert.severity === "red"
|
||||
? "border-red-200 dark:border-red-900"
|
||||
: alert.severity === "yellow"
|
||||
? "border-amber-200 dark:border-amber-900"
|
||||
: "border-blue-200 dark:border-blue-900"
|
||||
}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<AlertCircle
|
||||
className={`h-5 w-5 shrink-0 ${
|
||||
alert.severity === "red"
|
||||
? "text-red-500"
|
||||
: alert.severity === "yellow"
|
||||
? "text-amber-500"
|
||||
: "text-blue-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">{alert.message}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Middle section: Recent Distributions + Upcoming Events + Announcements */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Recent Distributions Table */}
|
||||
<Card>
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("recentDistributions")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{distributionsLoading ? (
|
||||
<TableSkeleton rows={5} columns={5} />
|
||||
<TableSkeleton rows={5} columns={3} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 font-medium">{t("date")}</th>
|
||||
<th className="pb-2 font-medium">{t("member")}</th>
|
||||
<th className="pb-2 font-medium">{t("strain")}</th>
|
||||
<th className="pb-2 font-medium">{t("amount")}</th>
|
||||
<th className="pb-2 font-medium">{t("staff")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentDistributions.map((dist) => (
|
||||
<tr key={dist.id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
{new Date(dist.recordedAt).toLocaleTimeString(
|
||||
"de-DE",
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">{dist.memberName}</td>
|
||||
<td className="py-2">{dist.strainName}</td>
|
||||
<td className="py-2">{dist.amountGrams}g</td>
|
||||
<td className="py-2">{dist.recordedBy}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
{recentDistributions.slice(0, 5).map((dist) => (
|
||||
<div
|
||||
key={dist.id}
|
||||
className="flex items-center justify-between border-b pb-2 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{dist.memberName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dist.strainName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">
|
||||
{dist.amountGrams}g
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stock Level Chart */}
|
||||
<Card>
|
||||
{/* Upcoming Events — NEW */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("stockByStrain")}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>{t("upcomingEvents")}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 60 }}
|
||||
<div className="space-y-3">
|
||||
{mockUpcomingEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-center justify-between border-b pb-2 last:border-0"
|
||||
>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
tick={{ fontSize: 12 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
color: "hsl(var(--card-foreground))",
|
||||
}}
|
||||
formatter={(value) => [`${value}g`, "Bestand"]}
|
||||
/>
|
||||
<Bar dataKey="grams" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
className="fill-green-600 dark:fill-green-500"
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{event.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(event.date).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{event.rsvpCount} {t("rsvps")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Latest Announcements — NEW */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Megaphone className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>{t("latestAnnouncements")}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mockLatestAnnouncements.map((post) => (
|
||||
<div
|
||||
key={post.id}
|
||||
className="flex flex-col border-b pb-2 last:border-0"
|
||||
>
|
||||
<span className="text-sm font-medium">{post.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(post.createdAt).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="w-full" asChild>
|
||||
<Link href="/info-board">{t("viewAll")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Bottom section: Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("stockByStrain")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 20, left: 10, bottom: 60 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
tick={{ fontSize: 12 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
color: "hsl(var(--card-foreground))",
|
||||
}}
|
||||
formatter={(value) => [`${value}g`, "Bestand"]}
|
||||
/>
|
||||
<Bar dataKey="grams" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
className="fill-green-600 dark:fill-green-500"
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
downloadReport,
|
||||
useAuthorityExport,
|
||||
useGenerateReport,
|
||||
useGeneratedReports,
|
||||
} from "@/services/compliance-reports"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
ChartBar,
|
||||
Download,
|
||||
FileText,
|
||||
Info,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { GeneratedReport } from "@/services/compliance-reports"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
// Report definitions grouped by category
|
||||
const reportCategories = [
|
||||
{
|
||||
id: "finance",
|
||||
icon: ChartBar,
|
||||
reports: [
|
||||
{ type: "EUER", formats: ["PDF", "CSV"], requiresDateRange: true },
|
||||
{
|
||||
type: "KASSENBUCH_EXPORT",
|
||||
formats: ["PDF", "CSV"],
|
||||
requiresDateRange: true,
|
||||
},
|
||||
{
|
||||
type: "BEITRAGSBESCHEINIGUNG",
|
||||
formats: ["PDF"],
|
||||
requiresDateRange: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kcang",
|
||||
icon: Shield,
|
||||
reports: [
|
||||
{
|
||||
type: "JAHRESBERICHT_BEHOERDE",
|
||||
formats: ["PDF"],
|
||||
requiresDateRange: true,
|
||||
},
|
||||
{
|
||||
type: "AUSGABEPROTOKOLL",
|
||||
formats: ["PDF", "CSV"],
|
||||
requiresDateRange: true,
|
||||
},
|
||||
{
|
||||
type: "VERNICHTUNGSPROTOKOLL",
|
||||
formats: ["PDF"],
|
||||
requiresDateRange: true,
|
||||
},
|
||||
{
|
||||
type: "TRANSPORTZERTIFIKAT",
|
||||
formats: ["PDF"],
|
||||
requiresDateRange: false,
|
||||
},
|
||||
{
|
||||
type: "BESTANDSFUEHRUNG",
|
||||
formats: ["PDF", "CSV", "JSON"],
|
||||
requiresDateRange: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dsgvo",
|
||||
icon: ShieldAlert,
|
||||
reports: [
|
||||
{
|
||||
type: "VERARBEITUNGSVERZEICHNIS",
|
||||
formats: ["PDF"],
|
||||
requiresDateRange: false,
|
||||
},
|
||||
{ type: "TOM", formats: ["PDF"], requiresDateRange: false },
|
||||
{ type: "DSFA", formats: ["PDF"], requiresDateRange: false },
|
||||
{ type: "LOESCHKONZEPT", formats: ["PDF"], requiresDateRange: false },
|
||||
{
|
||||
type: "DATENPANNEN_MELDUNG",
|
||||
formats: ["PDF"],
|
||||
requiresDateRange: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
icon: FileText,
|
||||
reports: [
|
||||
{
|
||||
type: "MITGLIEDERLISTE_REGISTER",
|
||||
formats: ["PDF", "CSV"],
|
||||
requiresDateRange: false,
|
||||
},
|
||||
{
|
||||
type: "VORSTANDSAENDERUNG",
|
||||
formats: ["PDF"],
|
||||
requiresDateRange: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Mock data for generated reports history
|
||||
const mockGeneratedReports: GeneratedReport[] = [
|
||||
{
|
||||
id: "gr-001",
|
||||
type: "AUSGABEPROTOKOLL",
|
||||
typeName: "Ausgabeprotokoll",
|
||||
format: "PDF",
|
||||
status: "COMPLETED",
|
||||
generatedAt: "2026-06-14T10:30:00Z",
|
||||
generatedBy: "Patrick Plate",
|
||||
fileSize: 245000,
|
||||
},
|
||||
{
|
||||
id: "gr-002",
|
||||
type: "BESTANDSFUEHRUNG",
|
||||
typeName: "Bestandsführung",
|
||||
format: "CSV",
|
||||
status: "COMPLETED",
|
||||
generatedAt: "2026-06-13T15:20:00Z",
|
||||
generatedBy: "Anna Schmidt",
|
||||
fileSize: 12400,
|
||||
},
|
||||
{
|
||||
id: "gr-003",
|
||||
type: "MITGLIEDERLISTE_REGISTER",
|
||||
typeName: "Mitgliederliste Register",
|
||||
format: "PDF",
|
||||
status: "COMPLETED",
|
||||
generatedAt: "2026-06-10T09:00:00Z",
|
||||
generatedBy: "Patrick Plate",
|
||||
fileSize: 89000,
|
||||
},
|
||||
]
|
||||
|
||||
export default function ReportsCenterPage() {
|
||||
const t = useTranslations("reportsCenter")
|
||||
const [selectedFormats, setSelectedFormats] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [dateRanges, setDateRanges] = useState<
|
||||
Record<string, { from: string; to: string }>
|
||||
>({})
|
||||
const [authorityYear, setAuthorityYear] = useState(
|
||||
new Date().getFullYear().toString()
|
||||
)
|
||||
const [authorityPassword, setAuthorityPassword] = useState("")
|
||||
const [authorityDialogOpen, setAuthorityDialogOpen] = useState(false)
|
||||
|
||||
const { data: generatedReports } = useGeneratedReports(10)
|
||||
const generateReport = useGenerateReport()
|
||||
const authorityExport = useAuthorityExport()
|
||||
|
||||
const reports = generatedReports ?? mockGeneratedReports
|
||||
|
||||
const handleGenerate = (reportType: string, formats: string[]) => {
|
||||
const format = (selectedFormats[reportType] || formats[0]) as
|
||||
| "PDF"
|
||||
| "CSV"
|
||||
| "JSON"
|
||||
const dateRange = dateRanges[reportType]
|
||||
|
||||
generateReport.mutate({
|
||||
type: reportType,
|
||||
format,
|
||||
dateFrom: dateRange?.from,
|
||||
dateTo: dateRange?.to,
|
||||
})
|
||||
}
|
||||
|
||||
const handleAuthorityExport = () => {
|
||||
authorityExport.mutate({
|
||||
year: parseInt(authorityYear),
|
||||
password: authorityPassword,
|
||||
reason: "Behördenanfrage",
|
||||
})
|
||||
setAuthorityDialogOpen(false)
|
||||
setAuthorityPassword("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Behörden-Export Hero */}
|
||||
<Card className="border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/30">
|
||||
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldAlert className="mt-0.5 h-6 w-6 shrink-0 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-red-900 dark:text-red-100">
|
||||
{t("authorityExport.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
{t("authorityExport.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={authorityDialogOpen}
|
||||
onOpenChange={setAuthorityDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="shrink-0 gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("authorityExport.button")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("authorityExport.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("authorityExport.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{t("authorityExport.warning")}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="authority-year">
|
||||
{t("authorityExport.year")}
|
||||
</Label>
|
||||
<select
|
||||
id="authority-year"
|
||||
value={authorityYear}
|
||||
onChange={(e) => setAuthorityYear(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{[0, 1, 2].map((offset) => {
|
||||
const year = new Date().getFullYear() - offset
|
||||
return (
|
||||
<option key={year} value={year.toString()}>
|
||||
{year}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="authority-password">
|
||||
{t("authorityExport.password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="authority-password"
|
||||
type="password"
|
||||
value={authorityPassword}
|
||||
onChange={(e) => setAuthorityPassword(e.target.value)}
|
||||
placeholder={t("authorityExport.passwordPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAuthorityDialogOpen(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleAuthorityExport}
|
||||
disabled={!authorityPassword}
|
||||
>
|
||||
{t("authorityExport.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Categories */}
|
||||
{reportCategories.map((category) => {
|
||||
const Icon = category.icon
|
||||
return (
|
||||
<div key={category.id} className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t(`categories.${category.id}`)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{category.reports.map((report) => (
|
||||
<Card key={report.type} className="flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t(`reports.${report.type}.name`)}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`reports.${report.type}.description`)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto flex flex-col gap-3">
|
||||
{/* Format badges */}
|
||||
<div className="flex gap-1.5">
|
||||
{report.formats.map((fmt) => (
|
||||
<Badge
|
||||
key={fmt}
|
||||
variant={
|
||||
(selectedFormats[report.type] ||
|
||||
report.formats[0]) === fmt
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() =>
|
||||
setSelectedFormats((prev) => ({
|
||||
...prev,
|
||||
[report.type]: fmt,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{fmt}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Date range picker */}
|
||||
{report.requiresDateRange && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-xs"
|
||||
value={dateRanges[report.type]?.from || ""}
|
||||
onChange={(e) =>
|
||||
setDateRanges((prev) => ({
|
||||
...prev,
|
||||
[report.type]: {
|
||||
...prev[report.type],
|
||||
from: e.target.value,
|
||||
to: prev[report.type]?.to || "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-xs"
|
||||
value={dateRanges[report.type]?.to || ""}
|
||||
onChange={(e) =>
|
||||
setDateRanges((prev) => ({
|
||||
...prev,
|
||||
[report.type]: {
|
||||
...prev[report.type],
|
||||
from: prev[report.type]?.from || "",
|
||||
to: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate button */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleGenerate(report.type, report.formats)
|
||||
}
|
||||
disabled={generateReport.isPending}
|
||||
>
|
||||
{t("generate")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Generated Reports History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
{t("history.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reports.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<Info className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("history.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 font-medium">{t("history.report")}</th>
|
||||
<th className="pb-2 font-medium">{t("history.format")}</th>
|
||||
<th className="pb-2 font-medium">{t("history.date")}</th>
|
||||
<th className="pb-2 font-medium">{t("history.user")}</th>
|
||||
<th className="pb-2 font-medium">{t("history.size")}</th>
|
||||
<th className="pb-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id} className="border-b last:border-0">
|
||||
<td className="py-2 font-medium">{report.typeName}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{report.format}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{new Date(report.generatedAt).toLocaleDateString(
|
||||
"de-DE"
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">{report.generatedBy}</td>
|
||||
<td className="py-2">
|
||||
{report.fileSize
|
||||
? `${(report.fileSize / 1024).toFixed(0)} KB`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => downloadReport(report.id)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -159,11 +159,11 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
{/* Feature Comparison Table */}
|
||||
<div className="container mx-auto px-4 mb-20">
|
||||
<div className="container mx-auto px-4 mb-20 overflow-hidden">
|
||||
<h2 className="text-2xl font-bold text-center mb-8">
|
||||
{t("comparisonTitle")}
|
||||
</h2>
|
||||
<div className="max-w-4xl mx-auto overflow-x-auto">
|
||||
<div className="max-w-4xl mx-auto overflow-x-auto -mx-4 px-4 sm:mx-auto sm:px-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NavigationType } from "@/types"
|
||||
|
||||
export const navigationsData: NavigationType[] = [
|
||||
{
|
||||
title: "Main",
|
||||
title: "Betrieb",
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
@@ -29,16 +29,31 @@ export const navigationsData: NavigationType[] = [
|
||||
href: "/grow",
|
||||
iconName: "Sprout",
|
||||
},
|
||||
{
|
||||
title: "Berichte",
|
||||
href: "/reports",
|
||||
iconName: "FileText",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Kommunikation",
|
||||
items: [
|
||||
{
|
||||
title: "Schwarzes Brett",
|
||||
href: "/info-board",
|
||||
iconName: "Megaphone",
|
||||
},
|
||||
{
|
||||
title: "Kalender",
|
||||
href: "/calendar",
|
||||
iconName: "Calendar",
|
||||
},
|
||||
{
|
||||
title: "Forum",
|
||||
href: "/forum",
|
||||
iconName: "MessageSquare",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Verwaltung",
|
||||
items: [
|
||||
{
|
||||
title: "Finanzen",
|
||||
href: "/finance",
|
||||
@@ -59,16 +74,6 @@ export const navigationsData: NavigationType[] = [
|
||||
href: "/board",
|
||||
iconName: "Shield",
|
||||
},
|
||||
{
|
||||
title: "Kalender",
|
||||
href: "/calendar",
|
||||
iconName: "Calendar",
|
||||
},
|
||||
{
|
||||
title: "Forum",
|
||||
href: "/forum",
|
||||
iconName: "MessageSquare",
|
||||
},
|
||||
{
|
||||
title: "Personal",
|
||||
href: "/settings/staff",
|
||||
@@ -79,11 +84,21 @@ export const navigationsData: NavigationType[] = [
|
||||
{
|
||||
title: "Compliance",
|
||||
items: [
|
||||
{
|
||||
title: "Berichtszentrale",
|
||||
href: "/reports-center",
|
||||
iconName: "ChartBar",
|
||||
},
|
||||
{
|
||||
title: "Protokoll",
|
||||
href: "/audit-log",
|
||||
iconName: "ScrollText",
|
||||
},
|
||||
{
|
||||
title: "Berichte",
|
||||
href: "/reports",
|
||||
iconName: "FileText",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient, apiDownload } from "@/lib/api-client"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface ReportTypeInfo {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
category: "FINANCE" | "KCANG_COMPLIANCE" | "DSGVO" | "ADMINISTRATION"
|
||||
formats: ("PDF" | "CSV" | "JSON")[]
|
||||
requiresDateRange: boolean
|
||||
requiresPassword: boolean
|
||||
}
|
||||
|
||||
export interface GenerateReportParams {
|
||||
type: string
|
||||
format: "PDF" | "CSV" | "JSON"
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
year?: number
|
||||
additionalParams?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface GeneratedReport {
|
||||
id: string
|
||||
type: string
|
||||
typeName: string
|
||||
format: string
|
||||
status: "PENDING" | "COMPLETED" | "FAILED"
|
||||
generatedAt: string
|
||||
generatedBy: string
|
||||
fileSize?: number
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
export interface AuthorityExportParams {
|
||||
year: number
|
||||
password: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface AuthorityExportResult {
|
||||
id: string
|
||||
year: number
|
||||
generatedAt: string
|
||||
reports: { type: string; name: string; format: string }[]
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export interface ComplianceDeadline {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
dueDate: string
|
||||
area: string
|
||||
status: "PENDING" | "OVERDUE" | "COMPLETED"
|
||||
daysRemaining: number
|
||||
}
|
||||
|
||||
// --- Query Hooks ---
|
||||
|
||||
export function useReportTypes() {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "types"],
|
||||
queryFn: () => apiClient<ReportTypeInfo[]>("/reports/types"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useGeneratedReports(limit?: number) {
|
||||
return useQuery({
|
||||
queryKey: ["reports", "generated", limit],
|
||||
queryFn: () =>
|
||||
apiClient<GeneratedReport[]>("/reports/generated", {
|
||||
params: { limit: limit?.toString() },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useComplianceDeadlines() {
|
||||
return useQuery({
|
||||
queryKey: ["compliance", "deadlines"],
|
||||
queryFn: () => apiClient<ComplianceDeadline[]>("/compliance/deadlines"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useGenerateReport() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: GenerateReportParams) =>
|
||||
apiClient<GeneratedReport>("/reports/generate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["reports", "generated"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAuthorityExport() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: AuthorityExportParams) =>
|
||||
apiClient<AuthorityExportResult>("/reports/authority-export", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["reports", "generated"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Download Functions ---
|
||||
|
||||
export async function downloadReport(reportId: string) {
|
||||
const { blob, filename } = await apiDownload(
|
||||
`/reports/generated/${reportId}/download`
|
||||
)
|
||||
triggerDownload(blob, filename || `report-${reportId}`)
|
||||
}
|
||||
|
||||
export async function downloadAuthorityExport(exportId: string) {
|
||||
const { blob, filename } = await apiDownload(
|
||||
`/reports/authority-export/${exportId}/download`
|
||||
)
|
||||
triggerDownload(blob, filename || `behoerden-export-${exportId}.zip`)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
Reference in New Issue
Block a user