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",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user