423 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|