added invoices and status in cockpit, created info button for contract status types
This commit is contained in:
+1
File diff suppressed because one or more lines are too long
+710
File diff suppressed because one or more lines are too long
-705
File diff suppressed because one or more lines are too long
-1
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-CzqYCocn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-OfL2GqlZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-BZmzqt4I.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BKXieHhr.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, FileText, Download, AlertTriangle, Check, Eye } from 'lucide-react';
|
||||
import Modal from '../ui/Modal';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import Select from '../ui/Select';
|
||||
import Badge from '../ui/Badge';
|
||||
import { invoiceApi } from '../../services/api';
|
||||
import type { Invoice, InvoiceType } from '../../types';
|
||||
|
||||
const invoiceTypeLabels: Record<InvoiceType, string> = {
|
||||
INTERIM: 'Zwischenrechnung',
|
||||
FINAL: 'Schlussrechnung',
|
||||
NOT_AVAILABLE: 'Nicht verfügbar',
|
||||
};
|
||||
|
||||
interface InvoicesSectionProps {
|
||||
ecdId: number; // energyContractDetailsId
|
||||
invoices: Invoice[];
|
||||
contractId: number;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export default function InvoicesSection({
|
||||
ecdId,
|
||||
invoices,
|
||||
contractId,
|
||||
canEdit,
|
||||
}: InvoicesSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingInvoice, setEditingInvoice] = useState<Invoice | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceId: number) => invoiceApi.deleteInvoice(ecdId, invoiceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||
},
|
||||
});
|
||||
|
||||
// Sort invoices by date (newest first)
|
||||
const sortedInvoices = [...invoices].sort(
|
||||
(a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime()
|
||||
);
|
||||
|
||||
const hasFinalInvoice = invoices.some(i => i.invoiceType === 'FINAL');
|
||||
const hasNotAvailable = invoices.some(i => i.invoiceType === 'NOT_AVAILABLE');
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-500" />
|
||||
<h4 className="text-sm font-medium text-gray-700">Rechnungen</h4>
|
||||
<Badge variant="default">{invoices.length}</Badge>
|
||||
{/* Status-Indicator */}
|
||||
{hasFinalInvoice ? (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-green-100 text-green-800">
|
||||
<Check className="w-3 h-3" />
|
||||
Schlussrechnung
|
||||
</span>
|
||||
) : hasNotAvailable ? (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-800">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Nicht verfügbar
|
||||
</span>
|
||||
) : invoices.length > 0 ? (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-orange-100 text-orange-800">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Schlussrechnung fehlt
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{invoices.length > 0 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsed view - show latest invoice */}
|
||||
{!isExpanded && sortedInvoices.length > 0 && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Letzte: {new Date(sortedInvoices[0].invoiceDate).toLocaleDateString('de-DE')} - {invoiceTypeLabels[sortedInvoices[0].invoiceType]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded view */}
|
||||
{isExpanded && sortedInvoices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sortedInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{new Date(invoice.invoiceDate).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{invoiceTypeLabels[invoice.invoiceType]}
|
||||
</div>
|
||||
</div>
|
||||
{invoice.documentPath && (
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/api${invoice.documentPath}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||
title="Anzeigen"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href={`/api${invoice.documentPath}`}
|
||||
download
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{invoice.notes && (
|
||||
<span className="text-xs text-gray-400 italic">{invoice.notes}</span>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => setEditingInvoice(invoice)}
|
||||
className="text-gray-500 hover:text-blue-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Rechnung wirklich löschen?')) {
|
||||
deleteInvoiceMutation.mutate(invoice.id);
|
||||
}
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && sortedInvoices.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic">Keine Rechnungen vorhanden.</p>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Invoice Modal */}
|
||||
{(showAddModal || editingInvoice) && (
|
||||
<InvoiceModal
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setEditingInvoice(null);
|
||||
}}
|
||||
ecdId={ecdId}
|
||||
contractId={contractId}
|
||||
invoice={editingInvoice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Invoice Modal Component
|
||||
function InvoiceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
ecdId,
|
||||
contractId,
|
||||
invoice,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ecdId: number;
|
||||
contractId: number;
|
||||
invoice?: Invoice | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!invoice;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
invoiceDate: invoice?.invoiceDate
|
||||
? new Date(invoice.invoiceDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0],
|
||||
invoiceType: invoice?.invoiceType || 'INTERIM' as InvoiceType,
|
||||
notes: invoice?.notes || '',
|
||||
});
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
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');
|
||||
}
|
||||
|
||||
// 1. Invoice erstellen
|
||||
const result = await invoiceApi.addInvoice(ecdId, {
|
||||
invoiceDate: formData.invoiceDate,
|
||||
invoiceType: formData.invoiceType,
|
||||
notes: formData.notes || undefined,
|
||||
});
|
||||
|
||||
// 2. Upload file if selected
|
||||
if (selectedFile && result.data?.id) {
|
||||
await invoiceApi.uploadDocument(result.data.id, selectedFile);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = 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');
|
||||
}
|
||||
|
||||
// 1. Invoice aktualisieren
|
||||
const result = await invoiceApi.updateInvoice(ecdId, invoice!.id, {
|
||||
invoiceDate: formData.invoiceDate,
|
||||
invoiceType: formData.invoiceType,
|
||||
notes: formData.notes || undefined,
|
||||
});
|
||||
|
||||
// 2. Upload file if selected
|
||||
if (selectedFile) {
|
||||
await invoiceApi.uploadDocument(invoice!.id, selectedFile);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', contractId.toString()] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate();
|
||||
} else {
|
||||
createMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
setError('Nur PDF-Dateien sind erlaubt');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('Datei ist zu groß (max. 10 MB)');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Rechnung bearbeiten' : 'Rechnung hinzufügen'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Rechnungsdatum"
|
||||
type="date"
|
||||
value={formData.invoiceDate}
|
||||
onChange={(e) => setFormData({ ...formData, invoiceDate: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Rechnungstyp"
|
||||
value={formData.invoiceType}
|
||||
onChange={(e) => setFormData({ ...formData, invoiceType: e.target.value as InvoiceType })}
|
||||
options={[
|
||||
{ value: 'INTERIM', label: 'Zwischenrechnung' },
|
||||
{ value: 'FINAL', label: 'Schlussrechnung' },
|
||||
{ value: 'NOT_AVAILABLE', label: 'Nicht verfügbar' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{formData.invoiceType !== 'NOT_AVAILABLE' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dokument (PDF) *
|
||||
</label>
|
||||
{invoice?.documentPath && !selectedFile && (
|
||||
<div className="mb-2 text-sm text-green-600 flex items-center gap-1">
|
||||
<Check className="w-4 h-4" />
|
||||
Dokument vorhanden
|
||||
</div>
|
||||
)}
|
||||
{selectedFile && (
|
||||
<div className="mb-2 text-sm text-blue-600 flex items-center gap-1">
|
||||
<FileText className="w-4 h-4" />
|
||||
{selectedFile.name}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".pdf"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{invoice?.documentPath || selectedFile ? 'Ersetzen' : 'PDF hochladen'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.invoiceType === 'NOT_AVAILABLE' && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
|
||||
Bei diesem Typ wird kein Dokument benötigt. Die Rechnung wird als "nicht mehr zu bekommen" markiert.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Notizen (optional)"
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Optionale Anmerkungen..."
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Wird gespeichert...' : isEditing ? 'Speichern' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react';
|
||||
import Modal from '../ui/Modal';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import Select from '../ui/Select';
|
||||
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { InvoiceType } from '../../types';
|
||||
|
||||
interface SaveAttachmentModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -22,6 +25,8 @@ type SelectedTarget = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SaveMode = 'document' | 'invoice';
|
||||
|
||||
export default function SaveAttachmentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -31,6 +36,12 @@ export default function SaveAttachmentModal({
|
||||
}: SaveAttachmentModalProps) {
|
||||
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['customer']));
|
||||
const [saveMode, setSaveMode] = useState<SaveMode>('document');
|
||||
const [invoiceData, setInvoiceData] = useState({
|
||||
invoiceDate: new Date().toISOString().split('T')[0],
|
||||
invoiceType: 'INTERIM' as InvoiceType,
|
||||
notes: '',
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Ziele laden
|
||||
@@ -42,6 +53,9 @@ export default function SaveAttachmentModal({
|
||||
|
||||
const targets = targetsData?.data;
|
||||
|
||||
// Prüfen ob es ein Energievertrag ist
|
||||
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
||||
@@ -73,8 +87,40 @@ export default function SaveAttachmentModal({
|
||||
},
|
||||
});
|
||||
|
||||
const saveInvoiceMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
return cachedEmailApi.saveAttachmentAsInvoice(emailId, attachmentFilename, {
|
||||
invoiceDate: invoiceData.invoiceDate,
|
||||
invoiceType: invoiceData.invoiceType,
|
||||
notes: invoiceData.notes || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Anhang als Rechnung gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
|
||||
if (targets?.contract?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
handleClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'Fehler beim Speichern der Rechnung');
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedTarget(null);
|
||||
setSaveMode('document');
|
||||
setInvoiceData({
|
||||
invoiceDate: new Date().toISOString().split('T')[0],
|
||||
invoiceType: 'INTERIM',
|
||||
notes: '',
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -215,59 +261,127 @@ export default function SaveAttachmentModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Targets */}
|
||||
{targets && (
|
||||
<div className="space-y-3 max-h-96 overflow-auto">
|
||||
{/* Kunde */}
|
||||
{renderSection(
|
||||
`Kunde: ${targets.customer.name}`,
|
||||
'customer',
|
||||
<User className="w-4 h-4 text-blue-600" />,
|
||||
renderSlots(targets.customer.slots, 'customer'),
|
||||
targets.customer.slots.length === 0
|
||||
)}
|
||||
|
||||
{/* Ausweise */}
|
||||
{renderSection(
|
||||
'Ausweisdokumente',
|
||||
'identityDocuments',
|
||||
<IdCard className="w-4 h-4 text-green-600" />,
|
||||
targets.identityDocuments.map((doc) =>
|
||||
renderEntityWithSlots(doc, 'identityDocument')
|
||||
),
|
||||
targets.identityDocuments.length === 0
|
||||
)}
|
||||
|
||||
{/* Bankkarten */}
|
||||
{renderSection(
|
||||
'Bankkarten',
|
||||
'bankCards',
|
||||
<CreditCard className="w-4 h-4 text-purple-600" />,
|
||||
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
|
||||
targets.bankCards.length === 0
|
||||
)}
|
||||
|
||||
{/* Vertrag */}
|
||||
{targets.contract && renderSection(
|
||||
`Vertrag: ${targets.contract.contractNumber}`,
|
||||
'contract',
|
||||
<FileText className="w-4 h-4 text-orange-600" />,
|
||||
renderSlots(targets.contract.slots, 'contract'),
|
||||
targets.contract.slots.length === 0
|
||||
)}
|
||||
|
||||
{!targets.contract && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
|
||||
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
|
||||
Vertragsdokumente als Ziel auswählen zu können.
|
||||
<>
|
||||
{/* Mode Toggle für Energieverträge */}
|
||||
{isEnergyContract && (
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setSaveMode('document')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
saveMode === 'document'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Als Dokument
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSaveMode('invoice')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
saveMode === 'invoice'
|
||||
? 'bg-white text-green-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Receipt className="w-4 h-4" />
|
||||
Als Rechnung
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Mode */}
|
||||
{saveMode === 'document' && (
|
||||
<div className="space-y-3 max-h-96 overflow-auto">
|
||||
{/* Kunde */}
|
||||
{renderSection(
|
||||
`Kunde: ${targets.customer.name}`,
|
||||
'customer',
|
||||
<User className="w-4 h-4 text-blue-600" />,
|
||||
renderSlots(targets.customer.slots, 'customer'),
|
||||
targets.customer.slots.length === 0
|
||||
)}
|
||||
|
||||
{/* Ausweise */}
|
||||
{renderSection(
|
||||
'Ausweisdokumente',
|
||||
'identityDocuments',
|
||||
<IdCard className="w-4 h-4 text-green-600" />,
|
||||
targets.identityDocuments.map((doc) =>
|
||||
renderEntityWithSlots(doc, 'identityDocument')
|
||||
),
|
||||
targets.identityDocuments.length === 0
|
||||
)}
|
||||
|
||||
{/* Bankkarten */}
|
||||
{renderSection(
|
||||
'Bankkarten',
|
||||
'bankCards',
|
||||
<CreditCard className="w-4 h-4 text-purple-600" />,
|
||||
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
|
||||
targets.bankCards.length === 0
|
||||
)}
|
||||
|
||||
{/* Vertrag */}
|
||||
{targets.contract && renderSection(
|
||||
`Vertrag: ${targets.contract.contractNumber}`,
|
||||
'contract',
|
||||
<FileText className="w-4 h-4 text-orange-600" />,
|
||||
renderSlots(targets.contract.slots, 'contract'),
|
||||
targets.contract.slots.length === 0
|
||||
)}
|
||||
|
||||
{!targets.contract && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
|
||||
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
|
||||
Vertragsdokumente als Ziel auswählen zu können.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Mode */}
|
||||
{saveMode === 'invoice' && isEnergyContract && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-green-50 rounded-lg">
|
||||
<p className="text-sm text-green-700">
|
||||
Der Anhang wird als Rechnung für den Vertrag <strong>{targets.contract?.contractNumber}</strong> gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Rechnungsdatum"
|
||||
type="date"
|
||||
value={invoiceData.invoiceDate}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceDate: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Rechnungstyp"
|
||||
value={invoiceData.invoiceType}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceType: e.target.value as InvoiceType })}
|
||||
options={[
|
||||
{ value: 'INTERIM', label: 'Zwischenrechnung' },
|
||||
{ value: 'FINAL', label: 'Schlussrechnung' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Notizen (optional)"
|
||||
value={invoiceData.notes}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, notes: e.target.value })}
|
||||
placeholder="Optionale Anmerkungen..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warning if replacing */}
|
||||
{selectedTarget?.hasDocument && (
|
||||
{saveMode === 'document' && selectedTarget?.hasDocument && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
@@ -282,12 +396,21 @@ export default function SaveAttachmentModal({
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!selectedTarget || saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</Button>
|
||||
{saveMode === 'document' ? (
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!selectedTarget || saveMutation.isPending || saveInvoiceMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => saveInvoiceMutation.mutate()}
|
||||
disabled={!invoiceData.invoiceDate || saveMutation.isPending || saveInvoiceMutation.isPending}
|
||||
>
|
||||
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt } from 'lucide-react';
|
||||
import Modal from '../ui/Modal';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import Select from '../ui/Select';
|
||||
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { InvoiceType } from '../../types';
|
||||
|
||||
interface SaveEmailAsPdfModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -21,6 +24,8 @@ type SelectedTarget = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SaveMode = 'document' | 'invoice';
|
||||
|
||||
export default function SaveEmailAsPdfModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -29,6 +34,12 @@ export default function SaveEmailAsPdfModal({
|
||||
}: SaveEmailAsPdfModalProps) {
|
||||
const [selectedTarget, setSelectedTarget] = useState<SelectedTarget | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['customer']));
|
||||
const [saveMode, setSaveMode] = useState<SaveMode>('document');
|
||||
const [invoiceData, setInvoiceData] = useState({
|
||||
invoiceDate: new Date().toISOString().split('T')[0],
|
||||
invoiceType: 'INTERIM' as InvoiceType,
|
||||
notes: '',
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Ziele laden (gleiche wie bei Anhängen)
|
||||
@@ -40,6 +51,9 @@ export default function SaveEmailAsPdfModal({
|
||||
|
||||
const targets = targetsData?.data;
|
||||
|
||||
// Prüfen ob es ein Energievertrag ist
|
||||
const isEnergyContract = targets?.contract?.type === 'ELECTRICITY' || targets?.contract?.type === 'GAS';
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
||||
@@ -71,8 +85,40 @@ export default function SaveEmailAsPdfModal({
|
||||
},
|
||||
});
|
||||
|
||||
const saveInvoiceMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
return cachedEmailApi.saveEmailAsInvoice(emailId, {
|
||||
invoiceDate: invoiceData.invoiceDate,
|
||||
invoiceType: invoiceData.invoiceType,
|
||||
notes: invoiceData.notes || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('E-Mail als Rechnung gespeichert');
|
||||
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||
|
||||
if (targets?.contract?.id) {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
handleClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'Fehler beim Speichern der Rechnung');
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedTarget(null);
|
||||
setSaveMode('document');
|
||||
setInvoiceData({
|
||||
invoiceDate: new Date().toISOString().split('T')[0],
|
||||
invoiceType: 'INTERIM',
|
||||
notes: '',
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -189,6 +235,8 @@ export default function SaveEmailAsPdfModal({
|
||||
);
|
||||
};
|
||||
|
||||
const isPending = saveMutation.isPending || saveInvoiceMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="E-Mail als PDF speichern" size="lg">
|
||||
<div className="space-y-4">
|
||||
@@ -213,59 +261,127 @@ export default function SaveEmailAsPdfModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Targets */}
|
||||
{targets && (
|
||||
<div className="space-y-3 max-h-96 overflow-auto">
|
||||
{/* Kunde */}
|
||||
{renderSection(
|
||||
`Kunde: ${targets.customer.name}`,
|
||||
'customer',
|
||||
<User className="w-4 h-4 text-blue-600" />,
|
||||
renderSlots(targets.customer.slots, 'customer'),
|
||||
targets.customer.slots.length === 0
|
||||
)}
|
||||
|
||||
{/* Ausweise */}
|
||||
{renderSection(
|
||||
'Ausweisdokumente',
|
||||
'identityDocuments',
|
||||
<IdCard className="w-4 h-4 text-green-600" />,
|
||||
targets.identityDocuments.map((doc) =>
|
||||
renderEntityWithSlots(doc, 'identityDocument')
|
||||
),
|
||||
targets.identityDocuments.length === 0
|
||||
)}
|
||||
|
||||
{/* Bankkarten */}
|
||||
{renderSection(
|
||||
'Bankkarten',
|
||||
'bankCards',
|
||||
<CreditCard className="w-4 h-4 text-purple-600" />,
|
||||
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
|
||||
targets.bankCards.length === 0
|
||||
)}
|
||||
|
||||
{/* Vertrag */}
|
||||
{targets.contract && renderSection(
|
||||
`Vertrag: ${targets.contract.contractNumber}`,
|
||||
'contract',
|
||||
<FileText className="w-4 h-4 text-orange-600" />,
|
||||
renderSlots(targets.contract.slots, 'contract'),
|
||||
targets.contract.slots.length === 0
|
||||
)}
|
||||
|
||||
{!targets.contract && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
|
||||
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
|
||||
Vertragsdokumente als Ziel auswählen zu können.
|
||||
<>
|
||||
{/* Mode Toggle für Energieverträge */}
|
||||
{isEnergyContract && (
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setSaveMode('document')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
saveMode === 'document'
|
||||
? 'bg-white text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Als Dokument
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSaveMode('invoice')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
saveMode === 'invoice'
|
||||
? 'bg-white text-green-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Receipt className="w-4 h-4" />
|
||||
Als Rechnung
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Mode */}
|
||||
{saveMode === 'document' && (
|
||||
<div className="space-y-3 max-h-96 overflow-auto">
|
||||
{/* Kunde */}
|
||||
{renderSection(
|
||||
`Kunde: ${targets.customer.name}`,
|
||||
'customer',
|
||||
<User className="w-4 h-4 text-blue-600" />,
|
||||
renderSlots(targets.customer.slots, 'customer'),
|
||||
targets.customer.slots.length === 0
|
||||
)}
|
||||
|
||||
{/* Ausweise */}
|
||||
{renderSection(
|
||||
'Ausweisdokumente',
|
||||
'identityDocuments',
|
||||
<IdCard className="w-4 h-4 text-green-600" />,
|
||||
targets.identityDocuments.map((doc) =>
|
||||
renderEntityWithSlots(doc, 'identityDocument')
|
||||
),
|
||||
targets.identityDocuments.length === 0
|
||||
)}
|
||||
|
||||
{/* Bankkarten */}
|
||||
{renderSection(
|
||||
'Bankkarten',
|
||||
'bankCards',
|
||||
<CreditCard className="w-4 h-4 text-purple-600" />,
|
||||
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
|
||||
targets.bankCards.length === 0
|
||||
)}
|
||||
|
||||
{/* Vertrag */}
|
||||
{targets.contract && renderSection(
|
||||
`Vertrag: ${targets.contract.contractNumber}`,
|
||||
'contract',
|
||||
<FileText className="w-4 h-4 text-orange-600" />,
|
||||
renderSlots(targets.contract.slots, 'contract'),
|
||||
targets.contract.slots.length === 0
|
||||
)}
|
||||
|
||||
{!targets.contract && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
|
||||
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
|
||||
Vertragsdokumente als Ziel auswählen zu können.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Mode */}
|
||||
{saveMode === 'invoice' && isEnergyContract && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-green-50 rounded-lg">
|
||||
<p className="text-sm text-green-700">
|
||||
Die E-Mail wird als Rechnung für den Vertrag <strong>{targets.contract?.contractNumber}</strong> gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Rechnungsdatum"
|
||||
type="date"
|
||||
value={invoiceData.invoiceDate}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceDate: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Rechnungstyp"
|
||||
value={invoiceData.invoiceType}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, invoiceType: e.target.value as InvoiceType })}
|
||||
options={[
|
||||
{ value: 'INTERIM', label: 'Zwischenrechnung' },
|
||||
{ value: 'FINAL', label: 'Schlussrechnung' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Notizen (optional)"
|
||||
value={invoiceData.notes}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, notes: e.target.value })}
|
||||
placeholder="Optionale Anmerkungen..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warning if replacing */}
|
||||
{selectedTarget?.hasDocument && (
|
||||
{saveMode === 'document' && selectedTarget?.hasDocument && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
@@ -280,12 +396,21 @@ export default function SaveEmailAsPdfModal({
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!selectedTarget || saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
|
||||
</Button>
|
||||
{saveMode === 'document' ? (
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!selectedTarget || isPending}
|
||||
>
|
||||
{isPending ? 'Wird erstellt...' : 'Als PDF speichern'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => saveInvoiceMutation.mutate()}
|
||||
disabled={!invoiceData.invoiceDate || isPending}
|
||||
>
|
||||
{isPending ? 'Wird erstellt...' : 'Als Rechnung speichern'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
|
||||
import { ContractEmailsSection } from '../../components/email';
|
||||
import { ContractDetailModal } from '../../components/contracts';
|
||||
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Button from '../../components/ui/Button';
|
||||
@@ -11,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 } from 'lucide-react';
|
||||
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 { calculateConsumption, calculateCosts } from '../../utils/energyCalculations';
|
||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask } from '../../types';
|
||||
@@ -45,6 +46,42 @@ const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' |
|
||||
DEACTIVATED: 'default',
|
||||
};
|
||||
|
||||
// Status-Erklärungen für Info-Modal
|
||||
const statusDescriptions = [
|
||||
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||
];
|
||||
|
||||
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||
<div key={status} className="flex items-start gap-2">
|
||||
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||
<span className="text-sm text-gray-600">{description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prüft ob die Laufzeit als "unbefristet" gilt (≤ 4 Wochen / 1 Monat / 30 Tage)
|
||||
function isUnlimitedDuration(durationCode: string): boolean {
|
||||
const match = durationCode.match(/^(\d+)([TMWJ])$/);
|
||||
@@ -1203,6 +1240,9 @@ export default function ContractDetail() {
|
||||
// Bestätigungsdialog für Folgevertrag
|
||||
const [showFollowUpConfirm, setShowFollowUpConfirm] = useState(false);
|
||||
|
||||
// Status-Info Modal
|
||||
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract', id],
|
||||
queryFn: () => contractApi.getById(contractId),
|
||||
@@ -1399,6 +1439,13 @@ export default function ContractDetail() {
|
||||
<h1 className="text-2xl font-bold">{c.contractNumber}</h1>
|
||||
<Badge>{typeLabels[c.type]}</Badge>
|
||||
<Badge variant={statusVariants[c.status]}>{statusLabels[c.status]}</Badge>
|
||||
<button
|
||||
onClick={() => setShowStatusInfo(true)}
|
||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Status-Erklärung"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{c.customer && (
|
||||
<p className="text-gray-500 ml-10">
|
||||
@@ -2151,6 +2198,14 @@ export default function ContractDetail() {
|
||||
bonus={c.energyDetails.bonus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rechnungen */}
|
||||
<InvoicesSection
|
||||
ecdId={c.energyDetails.id}
|
||||
invoices={c.energyDetails.invoices || []}
|
||||
contractId={contractId}
|
||||
canEdit={hasPermission('contracts:update') && !isCustomer}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -2589,6 +2644,9 @@ export default function ContractDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Status-Info Modal */}
|
||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import Button from '../../components/ui/Button';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import type { ContractType } from '../../types';
|
||||
import { Plus, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||
import { Plus, Trash2, Eye, EyeOff, Info, X } from 'lucide-react';
|
||||
|
||||
// Contract types are now loaded dynamically from the database
|
||||
|
||||
@@ -21,6 +21,42 @@ const statusOptions = [
|
||||
{ value: 'DEACTIVATED', label: 'Deaktiviert' },
|
||||
];
|
||||
|
||||
// Status-Erklärungen für Info-Modal
|
||||
const statusDescriptions = [
|
||||
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||
];
|
||||
|
||||
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||
<div key={status} className="flex items-start gap-2">
|
||||
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||
<span className="text-sm text-gray-600">{description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContractForm() {
|
||||
const { id } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -143,6 +179,9 @@ export default function ContractForm() {
|
||||
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
||||
const [showSimPuks, setShowSimPuks] = useState<Record<number, boolean>>({});
|
||||
|
||||
// Status-Info Modal
|
||||
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||
|
||||
// For new contracts, mark as "loaded" immediately so provider change detection works
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
@@ -635,11 +674,23 @@ export default function ContractForm() {
|
||||
options={typeOptions}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Status"
|
||||
{...register('status')}
|
||||
options={statusOptions}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Status</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowStatusInfo(true)}
|
||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Status-Erklärung"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Select
|
||||
{...register('status')}
|
||||
options={statusOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Vertriebsplattform"
|
||||
@@ -1322,6 +1373,9 @@ export default function ContractForm() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Status-Info Modal */}
|
||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import CopyButton from '../../components/ui/CopyButton';
|
||||
import { Plus, Search, Eye, Edit, Trash2, User, Users, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Plus, Search, Eye, Edit, Trash2, User, Users, ChevronDown, ChevronRight, Info, X } from 'lucide-react';
|
||||
import type { Contract, ContractType, ContractStatus } from '../../types';
|
||||
|
||||
const typeLabels: Record<ContractType, string> = {
|
||||
@@ -41,6 +41,42 @@ const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' |
|
||||
DEACTIVATED: 'default',
|
||||
};
|
||||
|
||||
// Status-Erklärungen für Info-Modal
|
||||
const statusDescriptions = [
|
||||
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||
];
|
||||
|
||||
function StatusInfoModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/20" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||
<div key={status} className="flex items-start gap-2">
|
||||
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||
<span className="text-sm text-gray-600">{description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContractList() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -54,6 +90,9 @@ export default function ContractList() {
|
||||
// State für aufgeklappte Verträge (Baumstruktur)
|
||||
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
|
||||
|
||||
// Status-Info Modal
|
||||
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||
|
||||
const { hasPermission, isCustomer, isCustomerPortal, user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -358,7 +397,18 @@ export default function ContractList() {
|
||||
)}
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Typ</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Anbieter / Tarif</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
Status
|
||||
<button
|
||||
onClick={() => setShowStatusInfo(true)}
|
||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Status-Erklärung"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Beginn</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
@@ -472,6 +522,9 @@ export default function ContractList() {
|
||||
<div className="text-center py-8 text-gray-500">Keine Verträge gefunden.</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status-Info Modal */}
|
||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Modal from '../../components/ui/Modal';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Select from '../../components/ui/Select';
|
||||
import FileUpload from '../../components/ui/FileUpload';
|
||||
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
|
||||
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary } from '../../types';
|
||||
|
||||
@@ -1496,6 +1496,7 @@ function ContractsTab({
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedContracts, setExpandedContracts] = useState<Set<number>>(new Set());
|
||||
const [showStatusInfo, setShowStatusInfo] = useState(false);
|
||||
|
||||
// Lade Vertragsbaum statt flacher Liste
|
||||
const { data: treeData, isLoading } = useQuery({
|
||||
@@ -1537,6 +1538,15 @@ function ContractsTab({
|
||||
DEACTIVATED: 'default',
|
||||
};
|
||||
|
||||
const statusDescriptions = [
|
||||
{ status: 'DRAFT', label: 'Entwurf', description: 'Vertrag wird noch vorbereitet', color: 'text-gray-600' },
|
||||
{ status: 'PENDING', label: 'Ausstehend', description: 'Wartet auf Aktivierung', color: 'text-yellow-600' },
|
||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||
];
|
||||
|
||||
const toggleExpand = (contractId: number) => {
|
||||
setExpandedContracts(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -1597,6 +1607,15 @@ function ContractsTab({
|
||||
</span>
|
||||
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
|
||||
<Badge variant={statusVariants[contract.status] || 'default'}>{contract.status}</Badge>
|
||||
{depth === 0 && !isPredecessor && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowStatusInfo(true); }}
|
||||
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Status-Erklärung"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPredecessor && (
|
||||
<span className="text-xs text-gray-500 ml-2">(Vorgänger)</span>
|
||||
@@ -1693,6 +1712,29 @@ function ContractsTab({
|
||||
) : (
|
||||
<p className="text-gray-500">Keine Verträge vorhanden.</p>
|
||||
)}
|
||||
|
||||
{/* Status Info Modal */}
|
||||
{showStatusInfo && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/20" onClick={() => setShowStatusInfo(false)} />
|
||||
<div className="relative bg-white rounded-lg shadow-xl p-4 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Vertragsstatus-Übersicht</h3>
|
||||
<button onClick={() => setShowStatusInfo(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{statusDescriptions.map(({ status, label, description, color }) => (
|
||||
<div key={status} className="flex items-start gap-2">
|
||||
<span className={`font-medium text-sm min-w-[90px] ${color}`}>{label}</span>
|
||||
<span className="text-sm text-gray-600">{description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Role, PortalSettings, CustomerRepresentative, CustomerSummary } from '../types';
|
||||
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary } from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -208,6 +208,40 @@ export const meterApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Invoice API
|
||||
export const invoiceApi = {
|
||||
getInvoices: async (ecdId: number) => {
|
||||
const res = await api.get<ApiResponse<Invoice[]>>(`/energy-details/${ecdId}/invoices`);
|
||||
return res.data;
|
||||
},
|
||||
addInvoice: async (ecdId: number, data: Partial<Invoice>) => {
|
||||
const res = await api.post<ApiResponse<Invoice>>(`/energy-details/${ecdId}/invoices`, data);
|
||||
return res.data;
|
||||
},
|
||||
updateInvoice: async (ecdId: number, invoiceId: number, data: Partial<Invoice>) => {
|
||||
const res = await api.put<ApiResponse<Invoice>>(`/energy-details/${ecdId}/invoices/${invoiceId}`, data);
|
||||
return res.data;
|
||||
},
|
||||
deleteInvoice: async (ecdId: number, invoiceId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/energy-details/${ecdId}/invoices/${invoiceId}`);
|
||||
return res.data;
|
||||
},
|
||||
uploadDocument: async (invoiceId: number, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('document', file);
|
||||
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(
|
||||
`/upload/invoices/${invoiceId}`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
deleteDocument: async (invoiceId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/upload/invoices/${invoiceId}`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Stressfrei-Wechseln E-Mail-Adressen
|
||||
export interface StressfreiEmail {
|
||||
id: number;
|
||||
@@ -320,6 +354,8 @@ export interface AttachmentTargetsResponse {
|
||||
contract?: {
|
||||
id: number;
|
||||
contractNumber: string;
|
||||
type: string;
|
||||
energyDetailsId?: number;
|
||||
slots: AttachmentTargetSlot[];
|
||||
};
|
||||
}
|
||||
@@ -514,6 +550,23 @@ export const cachedEmailApi = {
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
// E-Mail als Rechnung speichern (für Energieverträge)
|
||||
saveEmailAsInvoice: async (emailId: number, params: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||
const res = await api.post<ApiResponse<{ id: number; invoiceDate: string; invoiceType: string; documentPath: string }>>(
|
||||
`/emails/${emailId}/save-as-invoice`,
|
||||
params
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
// Anhang als Rechnung speichern (für Energieverträge)
|
||||
saveAttachmentAsInvoice: async (emailId: number, filename: string, params: { invoiceDate: string; invoiceType: string; notes?: string }) => {
|
||||
const encodedFilename = encodeURIComponent(filename);
|
||||
const res = await api.post<ApiResponse<{ id: number; invoiceDate: string; invoiceType: string; documentPath: string }>>(
|
||||
`/emails/${emailId}/attachments/${encodedFilename}/save-as-invoice`,
|
||||
params
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Contracts - Vertragsbaum für Kundenansicht
|
||||
|
||||
@@ -152,6 +152,19 @@ export interface MeterReading {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type InvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
|
||||
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
energyContractDetailsId: number;
|
||||
invoiceDate: string;
|
||||
invoiceType: InvoiceType;
|
||||
documentPath?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ContractTaskStatus = 'OPEN' | 'COMPLETED';
|
||||
|
||||
export interface ContractTaskSubtask {
|
||||
@@ -344,6 +357,7 @@ export interface EnergyContractDetails {
|
||||
bonus?: number;
|
||||
previousProviderName?: string;
|
||||
previousCustomerNumber?: string;
|
||||
invoices?: Invoice[]; // Rechnungen
|
||||
}
|
||||
|
||||
export interface InternetContractDetails {
|
||||
|
||||
Reference in New Issue
Block a user