save attachment from email in customer data and - or contracts

This commit is contained in:
2026-02-03 23:58:00 +01:00
parent 9a014c100b
commit 97b4670643
8 changed files with 1037 additions and 11 deletions
+23 -1
View File
@@ -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>
);
}
+46
View File
@@ -293,6 +293,37 @@ export interface SendEmailParams {
contractId?: number; // Vertrag dem die gesendete E-Mail zugeordnet wird
}
// Anhang-Speicher-Ziele
export interface AttachmentTargetSlot {
key: string;
label: string;
field: string;
hasDocument: boolean;
currentPath?: string;
}
export interface AttachmentEntityWithSlots {
id: number;
label: string;
slots: AttachmentTargetSlot[];
}
export interface AttachmentTargetsResponse {
customer: {
id: number;
name: string;
type: 'PRIVATE' | 'BUSINESS';
slots: AttachmentTargetSlot[];
};
identityDocuments: AttachmentEntityWithSlots[];
bankCards: AttachmentEntityWithSlots[];
contract?: {
id: number;
contractNumber: string;
slots: AttachmentTargetSlot[];
};
}
export const stressfreiEmailApi = {
getByCustomer: async (customerId: number, includeInactive = false) => {
const res = await api.get<ApiResponse<StressfreiEmail[]>>(`/customers/${customerId}/stressfrei-emails`, { params: { includeInactive } });
@@ -460,6 +491,21 @@ export const cachedEmailApi = {
const res = await api.delete<ApiResponse<void>>(`/emails/${emailId}/permanent`);
return res.data;
},
// ==================== ANHANG-SPEICHERUNG ====================
// Verfügbare Dokumenten-Ziele für Anhänge abrufen
getAttachmentTargets: async (emailId: number) => {
const res = await api.get<ApiResponse<AttachmentTargetsResponse>>(`/emails/${emailId}/attachment-targets`);
return res.data;
},
// Anhang in Dokumentenfeld speichern
saveAttachmentTo: async (emailId: number, filename: string, params: { entityType: string; entityId?: number; targetKey: string }) => {
const encodedFilename = encodeURIComponent(filename);
const res = await api.post<ApiResponse<{ path: string; filename: string; originalName: string; size: number }>>(
`/emails/${emailId}/attachments/${encodedFilename}/save-to`,
params
);
return res.data;
},
};
// Contracts