From 48e65be91cf3a0566710dd59d238f0005f140e1d Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 12 Aug 2026 10:23:53 +0200 Subject: [PATCH] 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) --- .../src/controllers/creditNote.controller.ts | 38 +++++ backend/src/routes/creditNote.routes.ts | 4 + backend/src/services/creditNote.service.ts | 55 ++++++- docs/todo.md | 10 ++ frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/pages/CreditNotesOverview.tsx | 151 ++++++++++++++++++ frontend/src/services/api.ts | 6 +- frontend/src/types/index.ts | 15 ++ 9 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/CreditNotesOverview.tsx diff --git a/backend/src/controllers/creditNote.controller.ts b/backend/src/controllers/creditNote.controller.ts index 82774a40..e60681e1 100644 --- a/backend/src/controllers/creditNote.controller.ts +++ b/backend/src/controllers/creditNote.controller.ts @@ -6,6 +6,7 @@ import * as creditNoteService from '../services/creditNote.service.js'; import { effectiveNumber } from '../services/creditNote.service.js'; import * as numberRangeService from '../services/creditNoteNumberRange.service.js'; import * as deliveryRangeService from '../services/deliveryNoteNumberRange.service.js'; +import * as authorizationService from '../services/authorization.service.js'; import { generateCreditNotePdf } from '../services/creditNotePdf.service.js'; // Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne @@ -42,6 +43,43 @@ function handleError(res: Response, error: unknown, fallback: string) { } as ApiResponse); } +// ---- Gesamtübersicht (Hauptmenü) ---- +// Anders als die übrigen Credit-Note-Endpunkte NICHT staff-only: Portal-Kunden +// dürfen ihre eigenen (+ vertretene) Belege sehen. Scoping über customerIds +// aus dem JWT – eine im Body/Query mitgeschickte customerId hat keinen Effekt. +export async function listAll(req: AuthRequest, res: Response): Promise { + try { + // Portal-User: nur eigene + vertretene Kunden MIT Vollmacht. + let customerIds: number[] | undefined; + if (req.user?.isCustomerPortal && req.user.customerId) { + customerIds = [req.user.customerId]; + const representedIds: number[] = (req.user as any).representedCustomerIds || []; + for (const repCustId of representedIds) { + if (await authorizationService.hasAuthorization(repCustId, req.user.customerId)) { + customerIds.push(repCustId); + } + } + } + + const page = parseInt((req.query.page as string) || '1') || 1; + const limit = Math.min(parseInt((req.query.limit as string) || '50') || 50, 200); + const search = typeof req.query.search === 'string' ? req.query.search : undefined; + + const result = await creditNoteService.getAllCreditNotes({ customerIds, page, limit, search }); + + // Portal-Kunden dürfen keine Überweisungsbelege laden → receiptPath aus der + // Response entfernen (Beleg-Download bleibt ohnehin staff-only). + const isPortal = !!req.user?.isCustomerPortal; + const items = isPortal + ? result.items.map((cn) => ({ ...cn, receiptPath: null })) + : result.items; + + res.json({ success: true, data: { items, pagination: result.pagination } } as ApiResponse); + } catch (error) { + handleError(res, error, 'Fehler beim Laden der Belegübersicht'); + } +} + // ---- Gutschriften pro Vertrag ---- export async function listByContract(req: AuthRequest, res: Response): Promise { diff --git a/backend/src/routes/creditNote.routes.ts b/backend/src/routes/creditNote.routes.ts index 500531f7..48e26589 100644 --- a/backend/src/routes/creditNote.routes.ts +++ b/backend/src/routes/creditNote.routes.ts @@ -4,6 +4,10 @@ import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); +// Gesamtübersicht aller Belege (Hauptmenü) – portal-scoped (nicht staff-only). +// VOR /:id, damit die Wurzel nicht als ID interpretiert wird. +router.get('/', authenticate, requirePermission('contracts:read'), creditNoteController.listAll); + // Nummernkreis-Verwaltung (Einstellungen). VOR /:id, damit "number-range" // nicht als ID interpretiert wird. router.get('/number-range', authenticate, requirePermission('settings:read'), creditNoteController.getNumberRange); diff --git a/backend/src/services/creditNote.service.ts b/backend/src/services/creditNote.service.ts index 60fbfe68..89d990b3 100644 --- a/backend/src/services/creditNote.service.ts +++ b/backend/src/services/creditNote.service.ts @@ -7,7 +7,7 @@ import { ApiError } from '../utils/apiError.js'; import { assignNextNumber } from './creditNoteNumberRange.service.js'; import { assignNextNumber as assignNextDeliveryNoteNumber } from './deliveryNoteNumberRange.service.js'; import { deleteUploadByRelativePath } from '../utils/fileCleanup.js'; -import { CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client'; +import { Prisma, CreditNoteType, CreditNoteCustomerType, CreditNoteAmountBasis } from '@prisma/client'; const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100; @@ -180,6 +180,59 @@ export async function getCreditNotesByContract(contractId: number) { }); } +// Gesamtübersicht aller Belege (Gutschriften + Lieferscheine). `customerIds` +// scoped die Liste (Portal: eigene + vertretene Kunden); ohne = alle (Staff). +export async function getAllCreditNotes(opts: { + customerIds?: number[]; + page?: number; + limit?: number; + search?: string; +}) { + const { customerIds, page = 1, limit = 50, search } = opts; + const skip = (Math.max(page, 1) - 1) * limit; + + const where: Prisma.CreditNoteWhereInput = {}; + if (customerIds) { + where.contract = { customerId: { in: customerIds } }; + } + if (search && search.trim()) { + const s = search.trim(); + where.OR = [ + { number: { contains: s } }, + { deliveryNoteNumber: { contains: s } }, + { sachwertDescription: { contains: s } }, + { contract: { contractNumber: { contains: s } } }, + { contract: { customer: { customerNumber: { contains: s } } } }, + { contract: { customer: { lastName: { contains: s } } } }, + { contract: { customer: { companyName: { contains: s } } } }, + ]; + } + + const [items, total] = await Promise.all([ + prisma.creditNote.findMany({ + where, + include: { + contract: { + select: { + id: true, + contractNumber: true, + type: true, + customer: { + select: { id: true, customerNumber: true, firstName: true, lastName: true, companyName: true }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + prisma.creditNote.count({ where }), + ]); + + return { items, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } }; +} + export async function getCreditNoteById(id: number) { return prisma.creditNote.findUnique({ where: { id } }); } diff --git a/docs/todo.md b/docs/todo.md index 7388f499..11229897 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,16 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **📋 Hauptmenü: Gutschriften/Lieferscheine-Gesamtübersicht** (2026-08-12) + - Neuer Menüpunkt „Gutschriften" (Sidebar, `show: contracts:read`) → Seite + `/credit-notes` mit Tabelle aller Belege (Beleg-Nr., Art, Kunde, Vertrag, + Betrag, Datum, PDF), Suche + Pagination. + - **Scoping:** neuer Endpoint `GET /credit-notes` (NICHT staff-only wie die + übrigen 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). + Für Portal wird `receiptPath` aus der Response entfernt (Belege bleiben + staff-only). Verifiziert (Staff alle, Portal nur eigene). - [x] **🚫 Gutschrift nur mit Empfängeradresse (Rechnung > Liefer)** (2026-08-12) - Beim Klick auf „Gutschrift anlegen" wird geprüft, ob der Vertrag eine Empfängeradresse hat: **Rechnungsadresse hat Vorrang, sonst Lieferadresse**. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11beccdc..122554f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 */} } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2e29a710..91e01efb 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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 }, diff --git a/frontend/src/pages/CreditNotesOverview.tsx b/frontend/src/pages/CreditNotesOverview.tsx new file mode 100644 index 00000000..a3fd7101 --- /dev/null +++ b/frontend/src/pages/CreditNotesOverview.tsx @@ -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 ( +
+
+

+ + Gutschriften & Lieferscheine +

+
+ +
+
+ + { setSearch(e.target.value); setPage(1); }} + placeholder="Suche: Nummer, Kunde, Vertrag, Sachwert …" + className="pl-9" + /> +
+
+ +
+ {isLoading ? ( +
Laden …
+ ) : items.length === 0 ? ( +
Keine Belege gefunden.
+ ) : ( +
+ + + + + + + + + + + + + + {items.map((cn) => { + const ls = isLieferschein(cn); + return ( + + + + + + + + + + ); + })} + +
Beleg-Nr.ArtKundeVertragBetragDatumPDF
{belegNummer(cn)} + {ls ? 'Lieferschein' : 'Gutschrift'} + + {canSeeCustomers ? ( + + {customerName(cn.contract.customer)} + + ) : ( + customerName(cn.contract.customer) + )} + {cn.contract.customer.customerNumber} + + + {cn.contract.contractNumber} + + + {ls ? ohne Betrag : {euro(cn.amountGross, cn.currency)}} + {formatDate(cn.creditDate)} + {cn.pdfPath ? ( + + PDF + + ) : ( + + )} +
+
+ )} +
+ + {pagination && pagination.totalPages > 1 && ( +
+ {pagination.total} Belege · Seite {pagination.page} / {pagination.totalPages} +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e42b51d2..30651003 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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>('/credit-notes', { params }); + return res.data; + }, listByContract: async (contractId: number) => { const res = await api.get>(`/contracts/${contractId}/credit-notes`); return res.data; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d657368b..177c9b58 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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;