Files
opencrm/frontend/src/components/contracts/InvoicesSection.tsx
T

423 lines
14 KiB
TypeScript

import { useState, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDate } from '../../utils/dateFormat';
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 (optional - für Energie-Verträge)
invoices: Invoice[];
contractId: number;
canEdit: boolean;
showInvoiceWarnings?: boolean; // Warnungen für fehlende Schluss-/Zwischenrechnung (nur Energie)
}
export default function InvoicesSection({
ecdId,
invoices,
showInvoiceWarnings = false,
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) => ecdId ? invoiceApi.deleteInvoice(ecdId, invoiceId) : invoiceApi.deleteInvoice(0, invoiceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract'] });
},
});
// 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 (nur bei Energie-Verträgen) */}
{showInvoiceWarnings && 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>
) : showInvoiceWarnings && 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>
) : showInvoiceWarnings && 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: {formatDate(sortedInvoices[0].invoiceDate)} - {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">
{formatDate(invoice.invoiceDate)}
</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 addInvoiceFn = async (data: { invoiceDate: string; invoiceType: string; notes?: string }) => {
if (ecdId) {
return invoiceApi.addInvoice(ecdId, data as any);
}
return invoiceApi.addInvoiceByContract(contractId, data as any);
};
const createMutation = useMutation({
mutationFn: async (file: File) => {
const result = await addInvoiceFn({
invoiceDate: formData.invoiceDate,
invoiceType: formData.invoiceType,
notes: formData.notes || undefined,
});
// 2. Upload file
if (result.data?.id) {
await invoiceApi.uploadDocument(result.data.id, file);
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const createWithoutFileMutation = useMutation({
mutationFn: async () => {
return addInvoiceFn({
invoiceDate: formData.invoiceDate,
invoiceType: formData.invoiceType,
notes: formData.notes || undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const updateMutation = useMutation({
mutationFn: async (file: File | null) => {
// 1. Invoice aktualisieren
const result = await invoiceApi.updateInvoice(ecdId || 0, invoice!.id, {
invoiceDate: formData.invoiceDate,
invoiceType: formData.invoiceType,
notes: formData.notes || undefined,
});
// 2. Upload file if provided
if (file) {
await invoiceApi.uploadDocument(invoice!.id, file);
}
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (isEditing) {
// Edit-Modus: Dokument ist Pflicht, außer bei NOT_AVAILABLE oder wenn schon vorhanden
if (formData.invoiceType !== 'NOT_AVAILABLE' && !invoice?.documentPath && !selectedFile) {
setError('Bitte laden Sie ein Dokument hoch');
return;
}
updateMutation.mutate(selectedFile);
} else {
// Add-Modus: Dokument ist Pflicht, außer bei NOT_AVAILABLE
if (formData.invoiceType === 'NOT_AVAILABLE') {
createWithoutFileMutation.mutate();
} else if (!selectedFile) {
setError('Bitte laden Sie ein Dokument hoch');
return;
} else {
createMutation.mutate(selectedFile);
}
}
};
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 || createWithoutFileMutation.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>
);
}