added place to telecommunication, added contract documents, added invoice to other contracts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,9 @@ export default function ContractForm() {
|
||||
routerSerialNumber: c.internetDetails?.routerSerialNumber || '',
|
||||
installationDate: c.internetDetails?.installationDate ? c.internetDetails.installationDate.split('T')[0] : '',
|
||||
internetUsername: c.internetDetails?.internetUsername || '',
|
||||
propertyType: c.internetDetails?.propertyType || '',
|
||||
propertyLocation: c.internetDetails?.propertyLocation || '',
|
||||
connectionLocation: c.internetDetails?.connectionLocation || '',
|
||||
homeId: c.internetDetails?.homeId || '',
|
||||
activationCode: c.internetDetails?.activationCode || '',
|
||||
// Mobile details
|
||||
@@ -531,6 +534,10 @@ export default function ContractForm() {
|
||||
// Internet-Zugangsdaten
|
||||
internetUsername: emptyToNull(data.internetUsername),
|
||||
internetPassword: data.internetPassword || undefined, // Passwort: undefined = nicht ändern
|
||||
// Objekt & Lage
|
||||
propertyType: emptyToNull(data.propertyType),
|
||||
propertyLocation: emptyToNull(data.propertyLocation),
|
||||
connectionLocation: emptyToNull(data.connectionLocation),
|
||||
// Glasfaser-spezifisch
|
||||
homeId: emptyToNull(data.homeId),
|
||||
// Vodafone DSL/Kabel spezifisch
|
||||
@@ -1027,6 +1034,65 @@ export default function ContractForm() {
|
||||
value={watch('installationDate') || ''}
|
||||
onClear={() => setValue('installationDate', '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Objekt & Lage */}
|
||||
<h4 className="text-sm font-medium text-gray-700 mt-4 mb-2">Objekt & Lage</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Select
|
||||
label="Objekttyp"
|
||||
{...register('propertyType')}
|
||||
options={[
|
||||
{ value: '', label: 'Bitte wählen...' },
|
||||
{ value: 'Mehrparteienhaus', label: 'Mehrparteienhaus' },
|
||||
{ value: 'Freistehendes Haus', label: 'Freistehendes Haus' },
|
||||
{ value: 'Doppelhaushälfte', label: 'Doppelhaushälfte' },
|
||||
{ value: 'Reihenhaus', label: 'Reihenhaus' },
|
||||
{ value: 'Wohnung', label: 'Wohnung' },
|
||||
{ value: 'Bürogebäude', label: 'Bürogebäude' },
|
||||
{ value: 'Gewerbeeinheit', label: 'Gewerbeeinheit' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Lage"
|
||||
{...register('propertyLocation')}
|
||||
options={[
|
||||
{ value: '', label: 'Bitte wählen...' },
|
||||
{ value: 'Vorderhaus', label: 'Vorderhaus' },
|
||||
{ value: 'Hinterhaus', label: 'Hinterhaus' },
|
||||
{ value: 'Links', label: 'Links' },
|
||||
{ value: 'Rechts', label: 'Rechts' },
|
||||
{ value: 'Mitte', label: 'Mitte' },
|
||||
{ value: 'Keller', label: 'Keller' },
|
||||
{ value: 'Souterrain', label: 'Souterrain' },
|
||||
{ value: 'Erdgeschoss', label: 'Erdgeschoss' },
|
||||
...[...Array(25)].map((_, i) => ({ value: `${i + 1}. OG`, label: `${i + 1}. Obergeschoss` })),
|
||||
{ value: 'Dachgeschoss', label: 'Dachgeschoss' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Lage des Anschlusses"
|
||||
{...register('connectionLocation')}
|
||||
options={[
|
||||
{ value: '', label: 'Bitte wählen...' },
|
||||
{ value: 'Flur', label: 'Flur' },
|
||||
{ value: 'Wohnzimmer', label: 'Wohnzimmer' },
|
||||
{ value: 'Schlafzimmer', label: 'Schlafzimmer' },
|
||||
{ value: 'Kinderzimmer', label: 'Kinderzimmer' },
|
||||
{ value: 'Küche', label: 'Küche' },
|
||||
{ value: 'Büro', label: 'Büro' },
|
||||
{ value: 'HWR', label: 'Hauswirtschaftsraum (HWR)' },
|
||||
{ value: 'Hausanschlussraum', label: 'Hausanschlussraum' },
|
||||
{ value: 'Abstellraum', label: 'Abstellraum' },
|
||||
{ value: 'Garage', label: 'Garage' },
|
||||
{ value: 'Serverraum', label: 'Serverraum' },
|
||||
{ value: 'Empfang', label: 'Empfang / Rezeption' },
|
||||
{ value: 'Keller', label: 'Keller' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
{/* HomeID nur bei Glasfaser */}
|
||||
{contractType === 'FIBER' && (
|
||||
<Input label="Home-ID" {...register('homeId')} />
|
||||
|
||||
Reference in New Issue
Block a user