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:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
192
frontend/src/pages/InvoiceManagementPage.tsx
Normal file
192
frontend/src/pages/InvoiceManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2198
frontend/src/pages/InvoiceTemplatePage.tsx
Normal file
2198
frontend/src/pages/InvoiceTemplatePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user