added invoices and status in cockpit, created info button for contract status types

This commit is contained in:
2026-02-08 01:18:12 +01:00
parent 1ad4fe0819
commit aee48a8ccb
45 changed files with 4543 additions and 863 deletions
@@ -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>
);
}
+60 -6
View File
@@ -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>
);
}
+55 -2
View File
@@ -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>
);
}
+54 -1
View File
@@ -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
+14
View File
@@ -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 {