Files
opencrm/frontend/src/pages/CreditNotesOverview.tsx
T
duffyduckandClaude Opus 4.8 48e65be91c 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>
2026-08-12 10:23:53 +02:00

152 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}