958752ecc9
Der SaveAttachmentModal hat jetzt drei Modi (wenn E-Mail einem Vertrag zugeordnet ist): 1. Als Dokument – in feste Slots (Kündigungsschreiben etc.), unverändert 2. Als Vertragsdokument – NEU: flexible ContractDocument-Tabelle mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + optionalen Notizen 3. Als Rechnung – jetzt für ALLE Vertragstypen (vorher nur Strom/Gas) Backend: - Neuer Endpoint POST /api/emails/:id/attachments/:filename/save-as-contract-document - saveAttachmentAsInvoice + saveEmailAsInvoice: ELECTRICITY/GAS-Einschränkung entfernt, nutzt jetzt addInvoiceByContract als Fallback für Nicht-Energie-Verträge Frontend: - cachedEmailApi.saveAttachmentAsContractDocument hinzugefügt - SaveAttachmentModal: neuer Mode 'contractDocument' mit Typ+Notizen - Mode-Toggle zeigt jetzt alle drei Optionen wenn Vertrag zugeordnet Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
529 lines
19 KiB
TypeScript
529 lines
19 KiB
TypeScript
import { useState } from 'react';
|
|
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight, Receipt, FolderOpen } 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';
|
|
|
|
const CONTRACT_DOCUMENT_TYPES = [
|
|
'Auftragsformular',
|
|
'Auftragsbestätigung',
|
|
'Lieferbestätigung',
|
|
'Vertragsunterlagen',
|
|
'Vollmacht',
|
|
'Widerrufsbelehrung',
|
|
'Preisblatt',
|
|
'Sonstiges',
|
|
];
|
|
|
|
interface SaveAttachmentModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
emailId: number;
|
|
attachmentFilename: string;
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
type SelectedTarget = {
|
|
entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract';
|
|
entityId?: number;
|
|
targetKey: string;
|
|
hasDocument: boolean;
|
|
label: string;
|
|
};
|
|
|
|
type SaveMode = 'document' | 'invoice' | 'contractDocument';
|
|
|
|
export default function SaveAttachmentModal({
|
|
isOpen,
|
|
onClose,
|
|
emailId,
|
|
attachmentFilename,
|
|
onSuccess,
|
|
}: 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 [contractDocumentData, setContractDocumentData] = useState({
|
|
documentType: CONTRACT_DOCUMENT_TYPES[0],
|
|
notes: '',
|
|
});
|
|
const queryClient = useQueryClient();
|
|
|
|
// Ziele laden
|
|
const { data: targetsData, isLoading, error } = useQuery({
|
|
queryKey: ['attachment-targets', emailId],
|
|
queryFn: () => cachedEmailApi.getAttachmentTargets(emailId),
|
|
enabled: isOpen,
|
|
});
|
|
|
|
const targets = targetsData?.data;
|
|
|
|
// Vertrag zugeordnet? → dann Rechnung + Vertragsdokument möglich
|
|
const hasContract = !!targets?.contract;
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: () => {
|
|
if (!selectedTarget) throw new Error('Kein Ziel ausgewählt');
|
|
return cachedEmailApi.saveAttachmentTo(emailId, attachmentFilename, {
|
|
entityType: selectedTarget.entityType,
|
|
entityId: selectedTarget.entityId,
|
|
targetKey: selectedTarget.targetKey,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Anhang gespeichert');
|
|
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
|
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
|
|
|
// Spezifische Ansichten aktualisieren (IDs als String, da URL-Params Strings sind)
|
|
if (targets?.customer?.id) {
|
|
queryClient.invalidateQueries({ queryKey: ['customer', targets.customer.id.toString()] });
|
|
}
|
|
if (targets?.contract?.id) {
|
|
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
|
|
}
|
|
|
|
onSuccess?.();
|
|
handleClose();
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(error.message || 'Fehler beim Speichern');
|
|
},
|
|
});
|
|
|
|
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 saveContractDocumentMutation = useMutation({
|
|
mutationFn: () => {
|
|
return cachedEmailApi.saveAttachmentAsContractDocument(emailId, attachmentFilename, {
|
|
documentType: contractDocumentData.documentType,
|
|
notes: contractDocumentData.notes || undefined,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Anhang als Vertragsdokument gespeichert');
|
|
queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] });
|
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
|
|
|
if (targets?.contract?.id) {
|
|
queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] });
|
|
queryClient.invalidateQueries({ queryKey: ['contract-documents', targets.contract.id] });
|
|
}
|
|
|
|
onSuccess?.();
|
|
handleClose();
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(error.message || 'Fehler beim Speichern des Vertragsdokuments');
|
|
},
|
|
});
|
|
|
|
const handleClose = () => {
|
|
setSelectedTarget(null);
|
|
setSaveMode('document');
|
|
setInvoiceData({
|
|
invoiceDate: new Date().toISOString().split('T')[0],
|
|
invoiceType: 'INTERIM',
|
|
notes: '',
|
|
});
|
|
setContractDocumentData({
|
|
documentType: CONTRACT_DOCUMENT_TYPES[0],
|
|
notes: '',
|
|
});
|
|
onClose();
|
|
};
|
|
|
|
const toggleSection = (section: string) => {
|
|
const newExpanded = new Set(expandedSections);
|
|
if (newExpanded.has(section)) {
|
|
newExpanded.delete(section);
|
|
} else {
|
|
newExpanded.add(section);
|
|
}
|
|
setExpandedSections(newExpanded);
|
|
};
|
|
|
|
const handleSelectSlot = (
|
|
entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract',
|
|
slot: AttachmentTargetSlot,
|
|
entityId?: number,
|
|
parentLabel?: string
|
|
) => {
|
|
setSelectedTarget({
|
|
entityType,
|
|
entityId,
|
|
targetKey: slot.key,
|
|
hasDocument: slot.hasDocument,
|
|
label: parentLabel ? `${parentLabel} → ${slot.label}` : slot.label,
|
|
});
|
|
};
|
|
|
|
const renderSlots = (
|
|
slots: AttachmentTargetSlot[],
|
|
entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract',
|
|
entityId?: number,
|
|
parentLabel?: string
|
|
) => {
|
|
return slots.map((slot) => {
|
|
const isSelected =
|
|
selectedTarget?.entityType === entityType &&
|
|
selectedTarget?.entityId === entityId &&
|
|
selectedTarget?.targetKey === slot.key;
|
|
|
|
return (
|
|
<div
|
|
key={slot.key}
|
|
onClick={() => handleSelectSlot(entityType, slot, entityId, parentLabel)}
|
|
className={`
|
|
flex items-center gap-3 p-3 cursor-pointer transition-colors rounded-lg ml-4
|
|
${isSelected ? 'bg-blue-100 ring-2 ring-blue-500' : 'hover:bg-gray-100'}
|
|
`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900">{slot.label}</span>
|
|
{slot.hasDocument && (
|
|
<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" />
|
|
Vorhanden
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isSelected && <Check className="w-5 h-5 text-blue-600" />}
|
|
</div>
|
|
);
|
|
});
|
|
};
|
|
|
|
const renderEntityWithSlots = (
|
|
entity: AttachmentEntityWithSlots,
|
|
entityType: 'identityDocument' | 'bankCard'
|
|
) => {
|
|
return (
|
|
<div key={entity.id} className="mb-2">
|
|
<div className="text-sm font-medium text-gray-700 px-3 py-1 bg-gray-50 rounded">
|
|
{entity.label}
|
|
</div>
|
|
{renderSlots(entity.slots, entityType, entity.id, entity.label)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderSection = (
|
|
title: string,
|
|
sectionKey: string,
|
|
icon: React.ReactNode,
|
|
children: React.ReactNode,
|
|
isEmpty: boolean = false
|
|
) => {
|
|
const isExpanded = expandedSections.has(sectionKey);
|
|
|
|
return (
|
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
|
<button
|
|
onClick={() => toggleSection(sectionKey)}
|
|
className="w-full flex items-center gap-2 p-3 bg-gray-50 hover:bg-gray-100 transition-colors"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-4 h-4 text-gray-500" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
|
)}
|
|
{icon}
|
|
<span className="font-medium text-gray-900">{title}</span>
|
|
</button>
|
|
{isExpanded && (
|
|
<div className="p-2">
|
|
{isEmpty ? (
|
|
<p className="text-sm text-gray-500 text-center py-4">Keine Einträge vorhanden</p>
|
|
) : (
|
|
children
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modal isOpen={isOpen} onClose={handleClose} title="Anhang speichern unter" size="lg">
|
|
<div className="space-y-4">
|
|
{/* Attachment Info */}
|
|
<div className="p-3 bg-gray-50 rounded-lg">
|
|
<p className="text-sm text-gray-600">
|
|
<span className="font-medium">Datei:</span> {attachmentFilename}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="p-4 bg-red-50 text-red-700 rounded-lg">
|
|
Fehler beim Laden der Dokumentziele
|
|
</div>
|
|
)}
|
|
|
|
{targets && (
|
|
<>
|
|
{/* Mode Toggle (nur wenn ein Vertrag zugeordnet ist) */}
|
|
{hasContract && (
|
|
<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-3 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('contractDocument')}
|
|
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
saveMode === 'contractDocument'
|
|
? 'bg-white text-orange-600 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
Vertragsdokument
|
|
</button>
|
|
<button
|
|
onClick={() => setSaveMode('invoice')}
|
|
className={`flex-1 flex items-center justify-center gap-2 px-3 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>
|
|
)}
|
|
|
|
{/* 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' && hasContract && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Contract Document Mode */}
|
|
{saveMode === 'contractDocument' && hasContract && (
|
|
<div className="space-y-4">
|
|
<div className="p-3 bg-orange-50 rounded-lg">
|
|
<p className="text-sm text-orange-700">
|
|
Der Anhang wird als Vertragsdokument für den Vertrag <strong>{targets.contract?.contractNumber}</strong> gespeichert.
|
|
</p>
|
|
</div>
|
|
|
|
<Select
|
|
label="Dokumenttyp"
|
|
value={contractDocumentData.documentType}
|
|
onChange={(e) =>
|
|
setContractDocumentData({ ...contractDocumentData, documentType: e.target.value })
|
|
}
|
|
options={CONTRACT_DOCUMENT_TYPES.map((t) => ({ value: t, label: t }))}
|
|
/>
|
|
|
|
<Input
|
|
label="Notizen (optional)"
|
|
value={contractDocumentData.notes}
|
|
onChange={(e) =>
|
|
setContractDocumentData({ ...contractDocumentData, notes: e.target.value })
|
|
}
|
|
placeholder="Optionale Anmerkungen..."
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Warning if replacing */}
|
|
{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">
|
|
<strong>Achtung:</strong> An diesem Feld ist bereits ein Dokument hinterlegt. Das
|
|
vorhandene Dokument wird durch den neuen Anhang ersetzt.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<Button variant="secondary" onClick={handleClose}>
|
|
Abbrechen
|
|
</Button>
|
|
{saveMode === 'document' && (
|
|
<Button
|
|
onClick={() => saveMutation.mutate()}
|
|
disabled={
|
|
!selectedTarget ||
|
|
saveMutation.isPending ||
|
|
saveInvoiceMutation.isPending ||
|
|
saveContractDocumentMutation.isPending
|
|
}
|
|
>
|
|
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
|
</Button>
|
|
)}
|
|
{saveMode === 'invoice' && (
|
|
<Button
|
|
onClick={() => saveInvoiceMutation.mutate()}
|
|
disabled={
|
|
!invoiceData.invoiceDate ||
|
|
saveMutation.isPending ||
|
|
saveInvoiceMutation.isPending ||
|
|
saveContractDocumentMutation.isPending
|
|
}
|
|
>
|
|
{saveInvoiceMutation.isPending ? 'Wird gespeichert...' : 'Als Rechnung speichern'}
|
|
</Button>
|
|
)}
|
|
{saveMode === 'contractDocument' && (
|
|
<Button
|
|
onClick={() => saveContractDocumentMutation.mutate()}
|
|
disabled={
|
|
!contractDocumentData.documentType ||
|
|
saveMutation.isPending ||
|
|
saveInvoiceMutation.isPending ||
|
|
saveContractDocumentMutation.isPending
|
|
}
|
|
>
|
|
{saveContractDocumentMutation.isPending
|
|
? 'Wird gespeichert...'
|
|
: 'Als Vertragsdokument speichern'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|