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:
Patrick Plate
2026-06-15 13:45:48 +02:00
parent c3722ab726
commit 87511e0485
7 changed files with 1130 additions and 99 deletions
+104 -1
View File
@@ -66,7 +66,15 @@
"today": "Heute", "today": "Heute",
"trend": "+{value}% ggü. Vormonat", "trend": "+{value}% ggü. Vormonat",
"quotaUsed": "{value}% verbraucht", "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": { "members": {
"title": "Mitgliederverwaltung", "title": "Mitgliederverwaltung",
@@ -1004,5 +1012,100 @@
"positions": "Positionen", "positions": "Positionen",
"active": "Aktiv", "active": "Aktiv",
"inactive": "Inaktiv" "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"
}
} }
} }
+104 -1
View File
@@ -66,7 +66,15 @@
"today": "Today", "today": "Today",
"trend": "+{value}% vs last month", "trend": "+{value}% vs last month",
"quotaUsed": "{value}% used", "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": { "members": {
"title": "Member Management", "title": "Member Management",
@@ -1004,5 +1012,100 @@
"positions": "Positions", "positions": "Positions",
"active": "Active", "active": "Active",
"inactive": "Inactive" "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, XAxis,
YAxis, YAxis,
} from "recharts" } 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 { import {
mockClubStats, mockClubStats,
@@ -27,6 +39,58 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { CardSkeleton, TableSkeleton } from "@/components/ui/data-skeleton" 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() { export default function DashboardPage() {
const t = useTranslations("dashboard") const t = useTranslations("dashboard")
@@ -45,16 +109,18 @@ export default function DashboardPage() {
return ( return (
<div className="flex flex-col gap-6 p-4 md:p-6"> <div className="flex flex-col gap-6 p-4 md:p-6">
{/* KPI Cards */} {/* KPI Cards — 6 total */}
{statsLoading ? ( {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 />
<CardSkeleton /> <CardSkeleton />
<CardSkeleton /> <CardSkeleton />
</div> </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 */} {/* Active Members */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -106,7 +172,7 @@ export default function DashboardPage() {
{t("grams")} {t("grams")}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{mockStockByStrain.length} Sorten verfügbar {mockStockByStrain.length} {t("strainsAvailable")}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -130,6 +196,49 @@ export default function DashboardPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </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> </div>
)} )}
@@ -161,54 +270,137 @@ export default function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Bottom section: Table + Chart */} {/* Alerts — conditional */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> {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 */} {/* Recent Distributions Table */}
<Card> <Card className="lg:col-span-1">
<CardHeader> <CardHeader>
<CardTitle>{t("recentDistributions")}</CardTitle> <CardTitle>{t("recentDistributions")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{distributionsLoading ? ( {distributionsLoading ? (
<TableSkeleton rows={5} columns={5} /> <TableSkeleton rows={5} columns={3} />
) : ( ) : (
<div className="overflow-x-auto"> <div className="space-y-3">
<table className="w-full text-sm"> {recentDistributions.slice(0, 5).map((dist) => (
<thead> <div
<tr className="border-b text-left text-muted-foreground"> key={dist.id}
<th className="pb-2 font-medium">{t("date")}</th> className="flex items-center justify-between border-b pb-2 last:border-0"
<th className="pb-2 font-medium">{t("member")}</th> >
<th className="pb-2 font-medium">{t("strain")}</th> <div className="flex flex-col">
<th className="pb-2 font-medium">{t("amount")}</th> <span className="text-sm font-medium">
<th className="pb-2 font-medium">{t("staff")}</th> {dist.memberName}
</tr> </span>
</thead> <span className="text-xs text-muted-foreground">
<tbody> {dist.strainName}
{recentDistributions.map((dist) => ( </span>
<tr key={dist.id} className="border-b last:border-0"> </div>
<td className="py-2"> <span className="text-sm font-semibold">
{new Date(dist.recordedAt).toLocaleTimeString( {dist.amountGrams}g
"de-DE", </span>
{ </div>
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> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Stock Level Chart */} {/* Upcoming Events — NEW */}
<Card className="lg:col-span-1">
<CardHeader>
<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="space-y-3">
{mockUpcomingEvents.map((event) => (
<div
key={event.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">{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> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t("stockByStrain")}</CardTitle> <CardTitle>{t("stockByStrain")}</CardTitle>
@@ -255,6 +447,5 @@ export default function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</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> </div>
{/* Feature Comparison Table */} {/* 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"> <h2 className="text-2xl font-bold text-center mb-8">
{t("comparisonTitle")} {t("comparisonTitle")}
</h2> </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"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b"> <tr className="border-b">
+30 -15
View File
@@ -2,7 +2,7 @@ import type { NavigationType } from "@/types"
export const navigationsData: NavigationType[] = [ export const navigationsData: NavigationType[] = [
{ {
title: "Main", title: "Betrieb",
items: [ items: [
{ {
title: "Dashboard", title: "Dashboard",
@@ -29,16 +29,31 @@ export const navigationsData: NavigationType[] = [
href: "/grow", href: "/grow",
iconName: "Sprout", iconName: "Sprout",
}, },
{ ],
title: "Berichte",
href: "/reports",
iconName: "FileText",
}, },
{
title: "Kommunikation",
items: [
{ {
title: "Schwarzes Brett", title: "Schwarzes Brett",
href: "/info-board", href: "/info-board",
iconName: "Megaphone", iconName: "Megaphone",
}, },
{
title: "Kalender",
href: "/calendar",
iconName: "Calendar",
},
{
title: "Forum",
href: "/forum",
iconName: "MessageSquare",
},
],
},
{
title: "Verwaltung",
items: [
{ {
title: "Finanzen", title: "Finanzen",
href: "/finance", href: "/finance",
@@ -59,16 +74,6 @@ export const navigationsData: NavigationType[] = [
href: "/board", href: "/board",
iconName: "Shield", iconName: "Shield",
}, },
{
title: "Kalender",
href: "/calendar",
iconName: "Calendar",
},
{
title: "Forum",
href: "/forum",
iconName: "MessageSquare",
},
{ {
title: "Personal", title: "Personal",
href: "/settings/staff", href: "/settings/staff",
@@ -79,11 +84,21 @@ export const navigationsData: NavigationType[] = [
{ {
title: "Compliance", title: "Compliance",
items: [ items: [
{
title: "Berichtszentrale",
href: "/reports-center",
iconName: "ChartBar",
},
{ {
title: "Protokoll", title: "Protokoll",
href: "/audit-log", href: "/audit-log",
iconName: "ScrollText", 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)
}