added place to telecommunication, added contract documents, added invoice to other contracts

This commit is contained in:
2026-03-25 16:55:48 +01:00
parent eaa94e766a
commit 3dd4f7b656
30 changed files with 3424 additions and 90 deletions
+214 -2
View File
@@ -13,11 +13,11 @@ import Badge from '../../components/ui/Badge';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import FileUpload from '../../components/ui/FileUpload';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield } from 'lucide-react';
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText } from 'lucide-react';
import { calculateConsumption, calculateCosts, calculateMultiMeterConsumption } from '../../utils/energyCalculations';
import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter } from '../../types';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
@@ -2576,6 +2576,7 @@ export default function ContractDetail() {
invoices={c.energyDetails.invoices || []}
contractId={contractId}
canEdit={hasPermission('contracts:update') && !isCustomer}
showInvoiceWarnings={true}
/>
</Card>
)}
@@ -2614,6 +2615,24 @@ export default function ContractDetail() {
</dd>
</div>
)}
{c.internetDetails.propertyType && (
<div>
<dt className="text-sm text-gray-500">Objekttyp</dt>
<dd>{c.internetDetails.propertyType}</dd>
</div>
)}
{c.internetDetails.propertyLocation && (
<div>
<dt className="text-sm text-gray-500">Lage</dt>
<dd>{c.internetDetails.propertyLocation}</dd>
</div>
)}
{c.internetDetails.connectionLocation && (
<div>
<dt className="text-sm text-gray-500">Anschluss-Lage</dt>
<dd>{c.internetDetails.connectionLocation}</dd>
</div>
)}
{c.internetDetails.installationDate && (
<div>
<dt className="text-sm text-gray-500">Installation</dt>
@@ -2960,6 +2979,25 @@ export default function ContractDetail() {
isCustomerPortal={isCustomerPortal}
/>
{/* Rechnungen (bei allen Vertragstypen, außer Energie - die haben ihre eigene Section) */}
{!['ELECTRICITY', 'GAS'].includes(c.type) && !isCustomerPortal && (
<Card className="mb-6">
<InvoicesSection
invoices={c.invoices || []}
contractId={contractId}
canEdit={hasPermission('contracts:update')}
/>
</Card>
)}
{/* Vertragsdokumente */}
{!isCustomerPortal && (
<ContractDocumentsSection
contractId={contractId}
canEdit={hasPermission('contracts:update')}
/>
)}
{/* Zugeordnete E-Mails */}
{!isCustomerPortal && hasPermission('contracts:read') && c.customerId && (
<ContractEmailsSection
@@ -3057,3 +3095,177 @@ export default function ContractDetail() {
</div>
);
}
// ==================== VERTRAGSDOKUMENTE ====================
const DOCUMENT_TYPES = [
'Auftragsformular',
'Auftragsbestätigung',
'Lieferbestätigung',
'Vertragsunterlagen',
'Vollmacht',
'Widerrufsbelehrung',
'Preisblatt',
'Sonstiges',
];
function ContractDocumentsSection({
contractId,
canEdit,
}: {
contractId: number;
canEdit: boolean;
}) {
const queryClient = useQueryClient();
const [showUpload, setShowUpload] = useState(false);
const [uploadType, setUploadType] = useState(DOCUMENT_TYPES[0]);
const [uploadNotes, setUploadNotes] = useState('');
const { data: docsData } = useQuery({
queryKey: ['contract-documents', contractId],
queryFn: () => contractApi.getDocuments(contractId),
});
const uploadMutation = useMutation({
mutationFn: ({ file, documentType, notes }: { file: File; documentType: string; notes?: string }) =>
contractApi.uploadDocument(contractId, file, documentType, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-documents', contractId] });
setShowUpload(false);
setUploadNotes('');
},
});
const deleteMutation = useMutation({
mutationFn: (documentId: number) => contractApi.deleteDocument(contractId, documentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-documents', contractId] });
},
});
const documents: ContractDocument[] = docsData?.data || [];
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadMutation.mutate({ file, documentType: uploadType, notes: uploadNotes || undefined });
}
};
return (
<Card className="mb-6" title={
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5" />
<span>Vertragsdokumente</span>
<span className="text-sm font-normal text-gray-500">({documents.length})</span>
</div>
{canEdit && (
<Button variant="ghost" size="sm" onClick={() => setShowUpload(!showUpload)}>
<Plus className="w-4 h-4" />
</Button>
)}
</div>
}>
{/* Upload-Bereich */}
{showUpload && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumenttyp</label>
<select
value={uploadType}
onChange={(e) => setUploadType(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{DOCUMENT_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Notiz (optional)</label>
<input
type="text"
value={uploadNotes}
onChange={(e) => setUploadNotes(e.target.value)}
placeholder="z.B. Unterschrieben am 15.03.2026"
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="flex items-center gap-3">
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer text-sm">
<Plus className="w-4 h-4" />
Datei wählen (PDF, JPG, PNG)
<input type="file" accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={handleFileSelect} />
</label>
<Button variant="secondary" size="sm" onClick={() => setShowUpload(false)}>Abbrechen</Button>
{uploadMutation.isPending && <span className="text-sm text-gray-500">Hochladen...</span>}
</div>
{uploadMutation.isError && (
<p className="text-xs text-red-600 mt-2">Fehler beim Hochladen</p>
)}
</div>
)}
{/* Dokumentliste */}
{documents.length === 0 ? (
<p className="text-sm text-gray-500">Keine Dokumente vorhanden.</p>
) : (
<div className="space-y-2">
{documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-gray-400" />
<div>
<div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
{doc.documentType}
</span>
<a
href={`/api${doc.documentPath}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
{doc.originalName}
</a>
</div>
<div className="flex items-center gap-3 mt-0.5 text-xs text-gray-500">
<span>{formatDate(doc.createdAt)}</span>
{doc.uploadedBy && <span>von {doc.uploadedBy}</span>}
{doc.notes && <span> {doc.notes}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<a
href={`/api${doc.documentPath}`}
download
className="text-gray-400 hover:text-blue-600"
title="Herunterladen"
>
<Download className="w-4 h-4" />
</a>
{canEdit && (
<button
onClick={() => {
if (confirm(`Dokument "${doc.originalName}" wirklich löschen?`)) {
deleteMutation.mutate(doc.id);
}
}}
className="text-gray-400 hover:text-red-600"
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
)}
</Card>
);
}