Hauptmenue: Gutschriften/Lieferscheine-Gesamtuebersicht (portal-scoped)

Neuer Menuepunkt 'Gutschriften' -> Seite /credit-notes mit Tabelle
aller Belege (Beleg-Nr, Art, Kunde, Vertrag, Betrag, Datum, PDF),
Suche + Pagination.

Neuer Endpoint GET /credit-notes (NICHT staff-only wie die uebrigen
Credit-Note-Endpoints): Staff sieht alle Belege aller Kunden, Portal-
Kunden nur eigene + vertretene (Vollmacht via hasAuthorization).
customerIds kommt aus dem JWT, nicht aus Query/Body -> nicht
manipulierbar. Fuer Portal wird receiptPath aus der Response entfernt
(Belege bleiben staff-only). Route requirePermission contracts:read.

Verifiziert: Staff -> alle Belege; Portal-scoped -> nur eigene, korrekt
zugeordnet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 10:23:53 +02:00
co-authored by Claude Opus 4.8
parent bbe1aed230
commit 48e65be91c
9 changed files with 281 additions and 2 deletions
+2
View File
@@ -14,6 +14,7 @@ import CustomerList from './pages/customers/CustomerList';
import CustomerDetail from './pages/customers/CustomerDetail';
import CustomerForm from './pages/customers/CustomerForm';
import ContractList from './pages/contracts/ContractList';
import CreditNotesOverview from './pages/CreditNotesOverview';
import ContractDetail from './pages/contracts/ContractDetail';
import ContractForm from './pages/contracts/ContractForm';
import ContractCockpit from './pages/contracts/ContractCockpit';
@@ -200,6 +201,7 @@ function App() {
{/* Contracts */}
<Route path="contracts" element={<PortalConsentGate><ContractList /></PortalConsentGate>} />
<Route path="credit-notes" element={<PortalConsentGate><CreditNotesOverview /></PortalConsentGate>} />
<Route path="contracts/cockpit" element={<ContractCockpit />} />
<Route path="contracts/new" element={<ContractForm />} />
<Route path="contracts/:id" element={<PortalConsentGate><ContractDetail /></PortalConsentGate>} />
@@ -16,6 +16,7 @@ import {
Shield,
FileCheck,
UserCircle,
Receipt,
Gauge,
} from 'lucide-react';
@@ -39,6 +40,7 @@ export default function Sidebar() {
{ to: '/contracts', icon: FileText, label: 'Verträge', show: hasPermission('contracts:read'), end: true },
{ to: '/contracts/cockpit', icon: AlertCircle, label: 'Vertrags-Cockpit', show: hasPermission('contracts:read') && !isCustomer },
{ to: '/tasks', icon: isCustomer ? MessageSquare : ClipboardList, label: isCustomer ? 'Support-Anfragen' : 'Aufgaben', show: hasPermission('contracts:read') },
{ to: '/credit-notes', icon: Receipt, label: 'Gutschriften', show: hasPermission('contracts:read'), end: true },
{ to: '/my-meters', icon: Gauge, label: 'Zählerstände', show: isCustomerPortal },
{ to: '/privacy', icon: Shield, label: 'Datenschutz', show: isCustomerPortal },
{ to: '/authorizations', icon: FileCheck, label: 'Vollmachten', show: isCustomerPortal && hasAuthorizations },
+151
View File
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Receipt, Search, FileText, ChevronLeft, ChevronRight } from 'lucide-react';
import { creditNoteApi } from '../services/api';
import { fileUrl } from '../utils/fileUrl';
import { formatDate } from '../utils/dateFormat';
import Badge from '../components/ui/Badge';
import Input from '../components/ui/Input';
import { useAuth } from '../context/AuthContext';
import type { CreditNoteListItem } from '../types';
function euro(n: number, currency = 'EUR') {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n || 0);
}
function isLieferschein(cn: CreditNoteListItem) {
return cn.type === 'SACHWERT' && cn.amountGross === 0;
}
function belegNummer(cn: CreditNoteListItem) {
return (isLieferschein(cn) ? cn.deliveryNoteNumber : cn.number) ?? `#${cn.id}`;
}
function customerName(c: CreditNoteListItem['contract']['customer']) {
return c.companyName || `${c.firstName} ${c.lastName}`;
}
export default function CreditNotesOverview() {
const { hasPermission, isCustomer } = useAuth();
const canSeeCustomers = hasPermission('customers:read') && !isCustomer;
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['credit-notes-overview', search, page],
queryFn: () => creditNoteApi.listAll({ search: search || undefined, page, limit: 50 }),
});
const items = data?.data?.items ?? [];
const pagination = data?.data?.pagination;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Receipt className="w-6 h-6 text-blue-600" />
Gutschriften &amp; Lieferscheine
</h1>
</div>
<div className="bg-white rounded-lg shadow mb-4 p-4">
<div className="relative max-w-md">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="Suche: Nummer, Kunde, Vertrag, Sachwert …"
className="pl-9"
/>
</div>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-500">Laden </div>
) : items.length === 0 ? (
<div className="p-8 text-center text-gray-500">Keine Belege gefunden.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-4 py-2 font-medium">Beleg-Nr.</th>
<th className="px-4 py-2 font-medium">Art</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Vertrag</th>
<th className="px-4 py-2 font-medium text-right">Betrag</th>
<th className="px-4 py-2 font-medium">Datum</th>
<th className="px-4 py-2 font-medium">PDF</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((cn) => {
const ls = isLieferschein(cn);
return (
<tr key={cn.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-mono">{belegNummer(cn)}</td>
<td className="px-4 py-2">
<Badge variant={ls ? 'default' : 'info'}>{ls ? 'Lieferschein' : 'Gutschrift'}</Badge>
</td>
<td className="px-4 py-2">
{canSeeCustomers ? (
<Link to={`/customers/${cn.contract.customer.id}`} className="text-blue-600 hover:underline">
{customerName(cn.contract.customer)}
</Link>
) : (
customerName(cn.contract.customer)
)}
<span className="text-gray-400 ml-1 font-mono text-xs">{cn.contract.customer.customerNumber}</span>
</td>
<td className="px-4 py-2">
<Link to={`/contracts/${cn.contract.id}`} className="text-blue-600 hover:underline font-mono">
{cn.contract.contractNumber}
</Link>
</td>
<td className="px-4 py-2 text-right">
{ls ? <span className="text-gray-500 italic">ohne Betrag</span> : <strong>{euro(cn.amountGross, cn.currency)}</strong>}
</td>
<td className="px-4 py-2 whitespace-nowrap">{formatDate(cn.creditDate)}</td>
<td className="px-4 py-2">
{cn.pdfPath ? (
<a href={fileUrl(cn.pdfPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 inline-flex items-center gap-1">
<FileText className="w-4 h-4" /> PDF
</a>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>{pagination.total} Belege · Seite {pagination.page} / {pagination.totalPages}</span>
<div className="flex gap-2">
<button
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(p - 1, 1))}
className="inline-flex items-center gap-1 px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50"
>
<ChevronLeft className="w-4 h-4" /> Zurück
</button>
<button
disabled={page >= pagination.totalPages}
onClick={() => setPage((p) => p + 1)}
className="inline-flex items-center gap-1 px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-50"
>
Weiter <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
}
+5 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, CustomerReferrals, CreditNote, CreditNoteDefaults, CreditNoteNumberRange, CompanyProfile, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, CustomerReferrals, CreditNote, CreditNoteListItem, CreditNoteDefaults, CreditNoteNumberRange, CompanyProfile, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
// ============================================================================
// In-Memory-Token-Store
@@ -277,6 +277,10 @@ export const companyProfileApi = {
// Gutschriften (Subventionen am Vertrag)
export const creditNoteApi = {
listAll: async (params?: { page?: number; limit?: number; search?: string }) => {
const res = await api.get<ApiResponse<{ items: CreditNoteListItem[]; pagination: { page: number; limit: number; total: number; totalPages: number } }>>('/credit-notes', { params });
return res.data;
},
listByContract: async (contractId: number) => {
const res = await api.get<ApiResponse<CreditNote[]>>(`/contracts/${contractId}/credit-notes`);
return res.data;
+15
View File
@@ -99,6 +99,21 @@ export interface CreditNotePayoutBankCard {
description?: string | null;
}
export interface CreditNoteListItem extends CreditNote {
contract: {
id: number;
contractNumber: string;
type: string;
customer: {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
companyName?: string | null;
};
};
}
export interface CreditNoteDefaults {
customerType: CreditNoteCustomerType;
vatRelevant: boolean;