snooze vor expired, contracts, display snoozed contracts if an item is missing, un snooze implemented, fixed invoice upload bug
This commit is contained in:
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+715
File diff suppressed because one or more lines are too long
-710
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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'}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user