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> {
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
if (tree === 'true' && customerId) {
const treeData = await contractService.getContractTreeForCustomer(
parseInt(customerId as string)
parseInt(customerId as string),
includeDeactivated === 'true',
);
res.json({ success: true, data: treeData } as ApiResponse);
return;
+11 -3
View File
@@ -1222,12 +1222,20 @@ export interface ContractTreeNode {
* Wurzelknoten = Verträge ohne Nachfolger (aktuellste Verträge)
* Vorgänger werden rekursiv eingebettet.
*/
export async function getContractTreeForCustomer(customerId: number): Promise<ContractTreeNode[]> {
// Alle Verträge des Kunden laden (außer DEACTIVATED)
export async function getContractTreeForCustomer(
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({
where: {
customerId,
status: { not: ContractStatus.DEACTIVATED },
...(includeDeactivated ? {} : { status: { not: ContractStatus.DEACTIVATED } }),
},
select: {
id: true,
+16
View File
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ 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**
- Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen
des Folgevertrags an der `@unique`-Regel auf `Contract.previousContractId`
@@ -1691,11 +1691,13 @@ function ContractsTab({
const queryClient = useQueryClient();
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
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({
queryKey: ['contract-tree', customerId],
queryFn: () => contractApi.getTreeForCustomer(customerId),
queryKey: ['contract-tree', customerId, showDeactivated],
queryFn: () => contractApi.getTreeForCustomer(customerId, showDeactivated),
});
const contractTree = treeData?.data || [];
@@ -1898,16 +1900,25 @@ function ContractsTab({
return (
<div>
{hasPermission('contracts:create') && (
<div className="mb-4">
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
{hasPermission('contracts:create') ? (
<Link to={`/contracts/new?customerId=${customerId}`}>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
Vertrag anlegen
</Button>
</Link>
</div>
)}
) : <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>
{contractTree.length > 0 ? (
<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 });
return res.data;
},
getTreeForCustomer: async (customerId: number) => {
const res = await api.get<ApiResponse<ContractTreeNode[]>>('/contracts', { params: { customerId, tree: 'true' } });
getTreeForCustomer: async (customerId: number, includeDeactivated = false) => {
const res = await api.get<ApiResponse<ContractTreeNode[]>>('/contracts', {
params: { customerId, tree: 'true', ...(includeDeactivated ? { includeDeactivated: 'true' } : {}) },
});
return res.data;
},
getById: async (id: number) => {