snooze vor expired, contracts, display snoozed contracts if an item is missing, un snooze implemented, fixed invoice upload bug

This commit is contained in:
2026-02-08 13:08:58 +01:00
parent 839bb40f5e
commit 2ab2bb7562
39 changed files with 1369 additions and 800 deletions
+153 -12
View File
@@ -1,10 +1,12 @@
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useState, useMemo, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { contractApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Badge from '../../components/ui/Badge';
import Select from '../../components/ui/Select';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import {
AlertCircle,
AlertTriangle,
@@ -23,6 +25,9 @@ import {
Tv,
Car,
Flame,
BellOff,
RotateCcw,
Receipt,
} from 'lucide-react';
import type { CockpitContract, CockpitUrgencyLevel, ContractType } from '../../types';
@@ -77,6 +82,8 @@ const issueTypeIcons: Record<string, typeof Calendar> = {
open_tasks: ClipboardList,
pending_status: Clock,
draft_status: FileText,
review_due: RotateCcw,
missing_invoice: Receipt,
};
const categoryLabels: Record<string, string> = {
@@ -86,9 +93,11 @@ const categoryLabels: Record<string, string> = {
missingData: 'Fehlende Daten',
openTasks: 'Offene Aufgaben',
pendingContracts: 'Wartende Verträge',
missingInvoices: 'Fehlende Rechnungen',
reviewDue: 'Erneute Prüfung fällig',
};
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks';
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices';
export default function ContractCockpit() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -114,6 +123,46 @@ export default function ContractCockpit() {
staleTime: 0,
});
const queryClient = useQueryClient();
const [snoozeContractId, setSnoozeContractId] = useState<number | null>(null);
const [customDate, setCustomDate] = useState('');
const snoozeDropdownRef = useRef<HTMLDivElement>(null);
// Close snooze dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (snoozeDropdownRef.current && !snoozeDropdownRef.current.contains(event.target as Node)) {
setSnoozeContractId(null);
setCustomDate('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const snoozeMutation = useMutation({
mutationFn: ({ contractId, data }: { contractId: number; data: { months?: number; nextReviewDate?: string } }) =>
contractApi.snooze(contractId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
setSnoozeContractId(null);
setCustomDate('');
},
});
const handleSnooze = (contractId: number, months?: number) => {
if (months) {
snoozeMutation.mutate({ contractId, data: { months } });
} else if (customDate) {
snoozeMutation.mutate({ contractId, data: { nextReviewDate: customDate } });
}
};
const handleUnsnooze = (contractId: number) => {
// Snooze aufheben: Leeres Objekt senden → nextReviewDate wird auf null gesetzt
snoozeMutation.mutate({ contractId, data: {} });
};
const toggleExpanded = (contractId: number) => {
setExpandedContracts(prev => {
const next = new Set(prev);
@@ -155,6 +204,14 @@ export default function ContractCockpit() {
return contracts.filter(c =>
c.issues.some(i => ['open_tasks', 'pending_status', 'draft_status'].includes(i.type))
);
case 'review':
return contracts.filter(c =>
c.issues.some(i => i.type === 'review_due')
);
case 'invoices':
return contracts.filter(c =>
c.issues.some(i => i.type.includes('invoice'))
);
default:
return contracts;
}
@@ -243,15 +300,97 @@ export default function ContractCockpit() {
</div>
{/* Actions */}
<Link
to={`/contracts/${contract.id}`}
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
className="ml-4 p-2 hover:bg-white hover:bg-opacity-50 rounded"
onClick={(e) => e.stopPropagation()}
title="Zum Vertrag"
>
<Eye className="w-4 h-4" />
</Link>
<div className="flex items-center gap-1 ml-4">
{/* Snooze Button */}
<div className="relative" ref={snoozeContractId === contract.id ? snoozeDropdownRef : undefined}>
<button
onClick={(e) => {
e.stopPropagation();
setSnoozeContractId(snoozeContractId === contract.id ? null : contract.id);
setCustomDate('');
}}
className="p-2 hover:bg-white hover:bg-opacity-50 rounded"
title="Zurückstellen"
>
<BellOff className="w-4 h-4" />
</button>
{/* Snooze Dropdown */}
{snoozeContractId === contract.id && (
<div
className="absolute right-0 top-full mt-1 w-56 bg-white border rounded-lg shadow-lg z-50 p-3"
onClick={(e) => e.stopPropagation()}
>
<div className="text-sm font-medium mb-2">Zurückstellen</div>
<div className="space-y-1">
<button
onClick={() => handleSnooze(contract.id, 3)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 rounded"
disabled={snoozeMutation.isPending}
>
+3 Monate
</button>
<button
onClick={() => handleSnooze(contract.id, 6)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 rounded bg-blue-50 border-blue-200"
disabled={snoozeMutation.isPending}
>
+6 Monate <span className="text-xs text-gray-500">(Empfohlen)</span>
</button>
<button
onClick={() => handleSnooze(contract.id, 12)}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 rounded"
disabled={snoozeMutation.isPending}
>
+12 Monate
</button>
</div>
<div className="border-t mt-2 pt-2">
<label className="text-xs text-gray-500 block mb-1">Eigenes Datum:</label>
<div className="flex gap-2">
<Input
type="date"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
className="flex-1 text-sm"
min={new Date().toISOString().split('T')[0]}
/>
<Button
size="sm"
onClick={() => handleSnooze(contract.id)}
disabled={!customDate || snoozeMutation.isPending}
>
OK
</Button>
</div>
</div>
{/* Snooze aufheben - zeige nur wenn review_due Issue existiert */}
{contract.issues.some(i => i.type === 'review_due') && (
<div className="border-t mt-2 pt-2">
<button
onClick={() => handleUnsnooze(contract.id)}
className="w-full text-left px-3 py-2 text-sm hover:bg-red-50 text-red-600 rounded flex items-center gap-2"
disabled={snoozeMutation.isPending}
>
<RotateCcw className="w-4 h-4" />
Snooze aufheben
</button>
</div>
)}
</div>
)}
</div>
<Link
to={`/contracts/${contract.id}`}
state={{ from: 'cockpit', filter: filter !== 'all' ? filter : undefined }}
className="p-2 hover:bg-white hover:bg-opacity-50 rounded"
onClick={(e) => e.stopPropagation()}
title="Zum Vertrag"
>
<Eye className="w-4 h-4" />
</Link>
</div>
</div>
{/* Expanded: Issues */}
@@ -387,6 +526,8 @@ export default function ContractCockpit() {
{ value: 'credentials', label: `Zugangsdaten (${summary.byCategory.missingCredentials})` },
{ value: 'data', label: `Fehlende Daten (${summary.byCategory.missingData})` },
{ value: 'tasks', label: `Aufgaben/Status (${summary.byCategory.openTasks + summary.byCategory.pendingContracts})` },
{ value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` },
{ value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` },
]}
className="w-64"
/>