save attachment from email in customer data and - or contracts
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2 } from 'lucide-react';
|
||||
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save } from 'lucide-react';
|
||||
import { CachedEmail, cachedEmailApi } from '../../services/api';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Button from '../ui/Button';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
import SaveAttachmentModal from './SaveAttachmentModal';
|
||||
|
||||
interface EmailDetailProps {
|
||||
email: CachedEmail;
|
||||
@@ -35,6 +36,7 @@ export default function EmailDetail({
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
|
||||
const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false);
|
||||
const [saveAttachmentFilename, setSaveAttachmentFilename] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
@@ -327,6 +329,16 @@ export default function EmailDetail({
|
||||
>
|
||||
<Download className="w-4 h-4 text-gray-500" />
|
||||
</a>
|
||||
{/* Speichern-Button (nicht im Papierkorb) */}
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={() => setSaveAttachmentFilename(name)}
|
||||
className="p-1 hover:bg-blue-100 rounded transition-colors"
|
||||
title={`${name} speichern unter...`}
|
||||
>
|
||||
<Save className="w-4 h-4 text-blue-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -459,6 +471,16 @@ export default function EmailDetail({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Anhang speichern Modal */}
|
||||
{saveAttachmentFilename && (
|
||||
<SaveAttachmentModal
|
||||
isOpen={true}
|
||||
onClose={() => setSaveAttachmentFilename(null)}
|
||||
emailId={email.id}
|
||||
attachmentFilename={saveAttachmentFilename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user