Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"

Der Vertragsbaum beim Kunden blendete DEACTIVATED-Verträge
komplett aus. Da der aktuellste Vertrag die Baumwurzel ist und
Vorgänger als Children hängen, verschwand eine ganze Kette aus
der Ansicht, sobald die Wurzel deaktiviert wurde – so „verschwand"
ein Vertrag scheinbar, als ein aktiver Folgevertrag gelöscht und
der Vorgänger vorher deaktiviert worden war.

getContractTreeForCustomer bekommt ein optionales
includeDeactivated-Flag (Default false = bisheriges Verhalten),
durchgereicht per Query-Param includeDeactivated=true. Toggle-
Button (Eye/EyeOff) im Kunden-Vertragstab; showDeactivated ist
Teil des Query-Keys → frischer Fetch beim Umschalten. Deaktivierte
behalten ihr graues DEACTIVATED-Badge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:45:58 +02:00
co-authored by Claude Opus 4.7
parent d0611b740a
commit d059912c1e
5 changed files with 52 additions and 14 deletions
@@ -74,12 +74,13 @@ function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
export async function getContracts(req: AuthRequest, res: Response): Promise<void> { export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
try { try {
const { customerId, type, status, search, page, limit, tree } = req.query; const { customerId, type, status, search, page, limit, tree, includeDeactivated } = req.query;
// Baumstruktur für Kundenansicht // Baumstruktur für Kundenansicht
if (tree === 'true' && customerId) { if (tree === 'true' && customerId) {
const treeData = await contractService.getContractTreeForCustomer( const treeData = await contractService.getContractTreeForCustomer(
parseInt(customerId as string) parseInt(customerId as string),
includeDeactivated === 'true',
); );
res.json({ success: true, data: treeData } as ApiResponse); res.json({ success: true, data: treeData } as ApiResponse);
return; return;
+11 -3
View File
@@ -1222,12 +1222,20 @@ export interface ContractTreeNode {
* Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge) * Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge)
* Vorgänger werden rekursiv eingebettet. * Vorgänger werden rekursiv eingebettet.
*/ */
export async function getContractTreeForCustomer(customerId: number): Promise<ContractTreeNode[]> { export async function getContractTreeForCustomer(
// Alle Verträge des Kunden laden (außer DEACTIVATED) customerId: number,
includeDeactivated = false,
): Promise<ContractTreeNode[]> {
// Alle Verträge des Kunden laden. DEACTIVATED standardmäßig ausgeblendet,
// per Toggle (includeDeactivated) aber sichtbar machen – sonst
// verschwinden ganze Ketten aus der Ansicht, sobald der aktuellste
// (Wurzel-)Vertrag deaktiviert ist. Genau dieser Fall trat auf, als ein
// aktiver Folgevertrag gelöscht wurde und der deaktivierte Vorgänger zur
// neuen (unsichtbaren) Wurzel wurde.
const allContracts = await prisma.contract.findMany({ const allContracts = await prisma.contract.findMany({
where: { where: {
customerId, customerId,
status: { not: ContractStatus.DEACTIVATED }, ...(includeDeactivated ? {} : { status: { not: ContractStatus.DEACTIVATED } }),
}, },
select: { select: {
id: true, id: true,
+16
View File
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **👁 Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"**
- Der Vertragsbaum beim Kunden (`CustomerDetail` → Tab Verträge)
blendete `DEACTIVATED`-Verträge komplett aus. Da der jeweils
aktuellste Vertrag die Baumwurzel ist und Vorgänger als Children
hängen, verschwand eine ganze Kette aus der Ansicht, sobald die
Wurzel deaktiviert wurde – genau das passierte, als ein aktiver
Folgevertrag gelöscht und der Vorgänger vorher auf „deaktiviert"
gesetzt worden war. Der Vertrag „verschwand" scheinbar, war aber
nur unsichtbar gefiltert.
- Fix: `getContractTreeForCustomer(customerId, includeDeactivated)`
mit optionalem Flag (Default false = bisheriges Verhalten). Query-
Param `includeDeactivated=true`, Toggle-Button im Kunden-Vertragstab
(`Eye`/`EyeOff`). `showDeactivated` ist Teil des Query-Keys →
frischer Fetch beim Umschalten. Deaktivierte Verträge tragen ihr
graues `DEACTIVATED`-Badge, bleiben also klar erkennbar.
- [x] **🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht** - [x] **🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht**
- Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen - Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen
des Folgevertrags an der `@unique`-Regel auf `Contract.previousContractId` des Folgevertrags an der `@unique`-Regel auf `Contract.previousContractId`
@@ -1691,11 +1691,13 @@ function ContractsTab({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set()); const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
const [showStatusInfo, setShowStatusInfo] = useState(false); const [showStatusInfo, setShowStatusInfo] = useState(false);
const [showDeactivated, setShowDeactivated] = useState(false);
// Lade Vertragsbaum statt flacher Liste // Lade Vertragsbaum statt flacher Liste. showDeactivated ist Teil des
// Query-Keys, damit der Toggle einen frischen Fetch auslöst.
const { data: treeData, isLoading } = useQuery({ const { data: treeData, isLoading } = useQuery({
queryKey: ['contract-tree', customerId], queryKey: ['contract-tree', customerId, showDeactivated],
queryFn: () => contractApi.getTreeForCustomer(customerId), queryFn: () => contractApi.getTreeForCustomer(customerId, showDeactivated),
}); });
const contractTree = treeData?.data || []; const contractTree = treeData?.data || [];
@@ -1898,16 +1900,25 @@ function ContractsTab({
return ( return (
<div> <div>
{hasPermission('contracts:create') && ( <div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
<div className="mb-4"> {hasPermission('contracts:create') ? (
<Link to={`/contracts/new?customerId=${customerId}`}> <Link to={`/contracts/new?customerId=${customerId}`}>
<Button size="sm"> <Button size="sm">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Vertrag anlegen Vertrag anlegen
</Button> </Button>
</Link> </Link>
) : <span />}
<Button
size="sm"
variant={showDeactivated ? 'secondary' : 'ghost'}
onClick={() => setShowDeactivated((v) => !v)}
title="Deaktivierte Verträge in der Liste ein-/ausblenden"
>
{showDeactivated ? <EyeOff className="w-4 h-4 mr-2" /> : <Eye className="w-4 h-4 mr-2" />}
{showDeactivated ? 'Deaktivierte ausblenden' : 'Deaktivierte anzeigen'}
</Button>
</div> </div>
)}
{contractTree.length > 0 ? ( {contractTree.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
+4 -2
View File
@@ -815,8 +815,10 @@ export const contractApi = {
const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params }); const res = await api.get<ApiResponse<Contract[]>>('/contracts', { params });
return res.data; return res.data;
}, },
getTreeForCustomer: async (customerId: number) => { getTreeForCustomer: async (customerId: number, includeDeactivated = false) => {
const res = await api.get<ApiResponse<ContractTreeNode[]>>('/contracts', { params: { customerId, tree: 'true' } }); const res = await api.get<ApiResponse<ContractTreeNode[]>>('/contracts', {
params: { customerId, tree: 'true', ...(includeDeactivated ? { includeDeactivated: 'true' } : {}) },
});
return res.data; return res.data;
}, },
getById: async (id: number) => { getById: async (id: number) => {