feat: Add invoice management menu and template editor for admin

- Add 'Rechnung' menu with sub-items 'Verwalten' and 'Template' in admin sidebar
- Create InvoiceTemplatePage with drag-and-drop editor for invoice templates
  - Includes invoice-specific elements (header, customer data, issuer info,
    invoice items, totals, payment terms, bank details)
  - Supports PDF preview and download
  - API integration for saving/loading templates (/admin/invoice-template)
- Create InvoiceManagementPage as placeholder for invoice overview
- Add routes for /admin/rechnung/verwalten and /admin/rechnung/template
- Update page titles in AppShell for new routes
This commit is contained in:
2026-03-16 20:30:45 +01:00
parent cbabe13162
commit eb0f921464
4 changed files with 2408 additions and 0 deletions

View File

@@ -15,6 +15,8 @@ import SearchFarmerPage from "./pages/SearchFarmerPage";
import SearchCalendarPage from "./pages/SearchCalendarPage";
import UserManagementPage from "./pages/UserManagementPage";
import ReportTemplatePage from "./pages/ReportTemplatePage";
import InvoiceTemplatePage from "./pages/InvoiceTemplatePage";
import InvoiceManagementPage from "./pages/InvoiceManagementPage";
function ProtectedRoutes() {
const { user, ready } = useSession();
@@ -45,6 +47,8 @@ function ProtectedRoutes() {
<Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} />
<Route path="/admin/antibiogramm" element={<AdministrationPage />} />
<Route path="/admin/rechnung/verwalten" element={<InvoiceManagementPage />} />
<Route path="/admin/rechnung/template" element={<InvoiceTemplatePage />} />
<Route path="/search" element={<Navigate to="/search/probe" replace />} />
<Route path="/search/probe" element={<SearchPage />} />
<Route path="/search/landwirt" element={<SearchFarmerPage />} />

View File

@@ -7,6 +7,8 @@ const PAGE_TITLES: Record<string, string> = {
"/samples/new": "Neuanlage einer Probe",
"/portal": "MUH-Portal",
"/report-template": "Bericht",
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
"/admin/rechnung/template": "Rechnungsvorlage",
};
function resolvePageTitle(pathname: string, isAdmin: boolean) {
@@ -58,6 +60,18 @@ export default function AppShell() {
</NavLink>
</div>
</div>
<div className="nav-group">
<div className="nav-group__label">Rechnung</div>
<div className="nav-subnav">
<NavLink to="/admin/rechnung/verwalten" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Verwalten
</NavLink>
<NavLink to="/admin/rechnung/template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Template
</NavLink>
</div>
</div>
</>
) : (
<>

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import { apiGet } from "../lib/api";
interface InvoiceSummary {
id: string;
invoiceNumber: string;
customerName: string;
invoiceDate: string;
dueDate: string;
totalAmount: number;
status: "DRAFT" | "SENT" | "PAID" | "OVERDUE" | "CANCELLED";
}
interface InvoiceOverview {
invoices: InvoiceSummary[];
}
const STATUS_LABELS: Record<InvoiceSummary["status"], string> = {
DRAFT: "Entwurf",
SENT: "Versendet",
PAID: "Bezahlt",
OVERDUE: "Überfällig",
CANCELLED: "Storniert",
};
const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
DRAFT: "status-badge--draft",
SENT: "status-badge--sent",
PAID: "status-badge--success",
OVERDUE: "status-badge--error",
CANCELLED: "status-badge--neutral",
};
export default function InvoiceManagementPage() {
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
apiGet<InvoiceOverview>("/admin/invoices")
.then((response) => {
if (!cancelled) {
setInvoices(response.invoices);
}
})
.catch((err) => {
if (!cancelled) {
// Für den Moment zeigen wir einfach eine leere Liste an
// bis das Backend implementiert ist
setInvoices([]);
setError(null);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
}).format(new Date(dateString));
};
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Rechnungsverwaltung</p>
<h3>Übersicht aller Rechnungen</h3>
<p className="muted-text">
Hier können Sie alle erstellten Rechnungen einsehen, deren Status verfolgen
und geplante Rechnungen verwalten.
</p>
</div>
{error ? <div className="alert alert--error">{error}</div> : null}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Rechnungen</p>
<h3>Rechnungsliste</h3>
</div>
<div className="page-actions">
<button type="button" className="accent-button" disabled>
Neue Rechnung
</button>
</div>
</div>
{loading ? (
<div className="empty-state">Rechnungen werden geladen...</div>
) : invoices.length === 0 ? (
<div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text">
Die Rechnungsverwaltung wird in Kürze verfügbar sein.
</p>
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Rechnungsnr.</th>
<th>Kunde</th>
<th>Rechnungsdatum</th>
<th>Fällig am</th>
<th>Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td>{invoice.invoiceNumber}</td>
<td>{invoice.customerName}</td>
<td>{formatDate(invoice.invoiceDate)}</td>
<td>{formatDate(invoice.dueDate)}</td>
<td>{formatCurrency(invoice.totalAmount)}</td>
<td>
<span className={`status-badge ${STATUS_CLASSES[invoice.status]}`}>
{STATUS_LABELS[invoice.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Zusammenfassung</p>
<h3>Statistik</h3>
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-card__value">{invoices.length}</span>
<span className="stat-card__label">Gesamtrechnungen</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "PAID").length}
</span>
<span className="stat-card__label">Bezahlt</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "OVERDUE").length}
</span>
<span className="stat-card__label">Überfällig</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{formatCurrency(
invoices
.filter((i) => i.status === "PAID")
.reduce((sum, i) => sum + i.totalAmount, 0)
)}
</span>
<span className="stat-card__label">Gesamtumsatz</span>
</div>
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff