opencrm/frontend/src/components/email/SaveAttachmentModal.tsx

296 lines
9.7 KiB
TypeScript

import { useState } from 'react';
import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
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;
};
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 queryClient = useQueryClient();
// Ziele laden
const { data: targetsData, isLoading, error } = useQuery({
queryKey: ['attachment-targets', emailId],
queryFn: () => cachedEmailApi.getAttachmentTargets(emailId),
enabled: isOpen,
});
const targets = targetsData?.data;
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 handleClose = () => {
setSelectedTarget(null);
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 */}
{targets && (
<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>
)}
{/* Warning if replacing */}
{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>
<Button
onClick={() => saveMutation.mutate()}
disabled={!selectedTarget || saveMutation.isPending}
>
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</Button>
</div>
</div>
</Modal>
);
}