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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCRM</title>
<script type="module" crossorigin src="/assets/index-BZmzqt4I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BKXieHhr.css">
<script type="module" crossorigin src="/assets/index-BUCLPhDH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTfzRMgT.css">
</head>
<body>
<div id="root"></div>
@@ -216,12 +216,7 @@ function InvoiceModal({
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: async () => {
// Validierung: Dokument ist Pflicht, außer bei NOT_AVAILABLE
if (formData.invoiceType !== 'NOT_AVAILABLE' && !selectedFile) {
throw new Error('Bitte laden Sie ein Dokument hoch');
}
mutationFn: async (file: File) => {
// 1. Invoice erstellen
const result = await invoiceApi.addInvoice(ecdId, {
invoiceDate: formData.invoiceDate,
@@ -229,9 +224,9 @@ function InvoiceModal({
notes: formData.notes || undefined,
});
// 2. Upload file if selected
if (selectedFile && result.data?.id) {
await invoiceApi.uploadDocument(result.data.id, selectedFile);
// 2. Upload file
if (result.data?.id) {
await invoiceApi.uploadDocument(result.data.id, file);
}
return result;
@@ -245,13 +240,26 @@ function InvoiceModal({
},
});
const updateMutation = useMutation({
const createWithoutFileMutation = useMutation({
mutationFn: async () => {
// Validierung: Dokument ist Pflicht, außer bei NOT_AVAILABLE
if (formData.invoiceType !== 'NOT_AVAILABLE' && !invoice?.documentPath && !selectedFile) {
throw new Error('Bitte laden Sie ein Dokument hoch');
}
// Für NOT_AVAILABLE Typ - kein Dokument erforderlich
return await invoiceApi.addInvoice(ecdId, {
invoiceDate: formData.invoiceDate,
invoiceType: formData.invoiceType,
notes: formData.notes || undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const updateMutation = useMutation({
mutationFn: async (file: File | null) => {
// 1. Invoice aktualisieren
const result = await invoiceApi.updateInvoice(ecdId, invoice!.id, {
invoiceDate: formData.invoiceDate,
@@ -259,9 +267,9 @@ function InvoiceModal({
notes: formData.notes || undefined,
});
// 2. Upload file if selected
if (selectedFile) {
await invoiceApi.uploadDocument(invoice!.id, selectedFile);
// 2. Upload file if provided
if (file) {
await invoiceApi.uploadDocument(invoice!.id, file);
}
return result;
@@ -280,9 +288,22 @@ function InvoiceModal({
setError(null);
if (isEditing) {
updateMutation.mutate();
// Edit-Modus: Dokument ist Pflicht, außer bei NOT_AVAILABLE oder wenn schon vorhanden
if (formData.invoiceType !== 'NOT_AVAILABLE' && !invoice?.documentPath && !selectedFile) {
setError('Bitte laden Sie ein Dokument hoch');
return;
}
updateMutation.mutate(selectedFile);
} else {
createMutation.mutate();
// Add-Modus: Dokument ist Pflicht, außer bei NOT_AVAILABLE
if (formData.invoiceType === 'NOT_AVAILABLE') {
createWithoutFileMutation.mutate();
} else if (!selectedFile) {
setError('Bitte laden Sie ein Dokument hoch');
return;
} else {
createMutation.mutate(selectedFile);
}
}
};
@@ -302,7 +323,7 @@ function InvoiceModal({
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
const isPending = createMutation.isPending || createWithoutFileMutation.isPending || updateMutation.isPending;
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Rechnung bearbeiten' : 'Rechnung hinzufügen'}>
+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"
/>
@@ -12,7 +12,7 @@ import Badge from '../../components/ui/Badge';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X } from 'lucide-react';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff } from 'lucide-react';
import { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
@@ -1243,6 +1243,9 @@ export default function ContractDetail() {
// Status-Info Modal
const [showStatusInfo, setShowStatusInfo] = useState(false);
// Un-Snooze Bestätigungsmodal
const [showUnsnoozeConfirm, setShowUnsnoozeConfirm] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
@@ -1270,6 +1273,20 @@ export default function ContractDetail() {
},
});
// Un-Snooze Mutation
const unsnoozeMutation = useMutation({
mutationFn: () => contractApi.snooze(contractId, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract', id] });
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
setShowUnsnoozeConfirm(false);
},
onError: (error) => {
console.error('Un-Snooze Fehler:', error);
alert(`Fehler beim Aufheben der Zurückstellung: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
},
});
// Mutation für Kündigungsbestätigungsdatum
const updateCancellationDateMutation = useMutation({
mutationFn: (date: string | null) => {
@@ -1446,6 +1463,22 @@ export default function ContractDetail() {
>
<Info className="w-4 h-4" />
</button>
{/* Snooze-Hinweis wenn nextReviewDate in der Zukunft */}
{c.nextReviewDate && new Date(c.nextReviewDate) > new Date() && (
<div className="flex items-center gap-1 px-2 py-1 bg-amber-100 text-amber-800 rounded-full text-xs">
<BellOff className="w-3 h-3" />
<span>Zurückgestellt bis {new Date(c.nextReviewDate).toLocaleDateString('de-DE')}</span>
{hasPermission('contracts:update') && (
<button
onClick={() => setShowUnsnoozeConfirm(true)}
className="ml-1 p-0.5 hover:bg-amber-200 rounded"
title="Zurückstellung aufheben"
>
<X className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
{c.customer && (
<p className="text-gray-500 ml-10">
@@ -2647,6 +2680,34 @@ export default function ContractDetail() {
{/* Status-Info Modal */}
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
{/* Un-Snooze Bestätigungsmodal */}
<Modal
isOpen={showUnsnoozeConfirm}
onClose={() => setShowUnsnoozeConfirm(false)}
title="Zurückstellung aufheben?"
>
<div className="space-y-4">
<p className="text-gray-700">
Möchten Sie die Zurückstellung für diesen Vertrag wirklich aufheben?
</p>
<p className="text-sm text-gray-500">
Der Vertrag wird danach wieder im Cockpit angezeigt, wenn Fristen anstehen oder abgelaufen sind.
</p>
<div className="flex justify-end gap-3 pt-4">
<Button variant="secondary" onClick={() => setShowUnsnoozeConfirm(false)}>
Abbrechen
</Button>
<Button
variant="danger"
onClick={() => unsnoozeMutation.mutate()}
disabled={unsnoozeMutation.isPending}
>
{unsnoozeMutation.isPending ? 'Wird aufgehoben...' : 'Ja, aufheben'}
</Button>
</div>
</div>
</Modal>
</div>
);
}
@@ -946,6 +946,14 @@ export default function ContractForm() {
<Input label="Vorversorger" {...register('previousProviderName')} />
<Input label="Kundennr. beim Vorversorger" {...register('previousCustomerNumber')} />
</div>
{/* Hinweis für Zählerstände und Rechnungen */}
{isEdit && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
<strong>Hinweis:</strong> Zählerstände und Rechnungen werden in der{' '}
<span className="font-medium">Vertragsdetailansicht</span> verwaltet, nicht hier im Bearbeitungsformular.
</div>
)}
</Card>
)}
+5
View File
@@ -641,6 +641,11 @@ export const contractApi = {
const res = await api.get<ApiResponse<import('../types').CockpitResult>>('/contracts/cockpit');
return res.data;
},
// Snooze: Vertrag zurückstellen
snooze: async (id: number, data: { nextReviewDate?: string; months?: number }) => {
const res = await api.patch<ApiResponse<{ id: number; contractNumber: string; nextReviewDate: string | null }>>(`/contracts/${id}/snooze`, data);
return res.data;
},
};
// Contract Tasks (Aufgaben)
+4
View File
@@ -335,6 +335,8 @@ export interface Contract {
mobileDetails?: MobileContractDetails;
tvDetails?: TvContractDetails;
carInsuranceDetails?: CarInsuranceDetails;
// Snooze: Vertrag zurückstellen
nextReviewDate?: string;
followUpContract?: {
id: number;
contractNumber: string;
@@ -500,8 +502,10 @@ export interface CockpitSummary {
contractEnding: number;
missingCredentials: number;
missingData: number;
missingInvoices: number;
openTasks: number;
pendingContracts: number;
reviewDue: number;
};
}