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>
152 lines
6.6 KiB
TypeScript
152 lines
6.6 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|