save email as pdf likae attachment version 2

This commit is contained in:
2026-02-04 19:49:09 +01:00
parent 8c65fecef0
commit f33d157b9b
21 changed files with 1143 additions and 151 deletions
@@ -0,0 +1,194 @@
import { useQuery } from '@tanstack/react-query';
import { contractApi } from '../../services/api';
import Modal from '../ui/Modal';
import Badge from '../ui/Badge';
import Card from '../ui/Card';
import CopyButton from '../ui/CopyButton';
import type { ContractType, ContractStatus } from '../../types';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
GAS: 'Gas',
DSL: 'DSL',
CABLE: 'Kabelinternet',
FIBER: 'Glasfaser',
MOBILE: 'Mobilfunk',
TV: 'TV',
CAR_INSURANCE: 'KFZ-Versicherung',
};
const statusLabels: Record<ContractStatus, string> = {
DRAFT: 'Entwurf',
PENDING: 'Ausstehend',
ACTIVE: 'Aktiv',
CANCELLED: 'Gekündigt',
EXPIRED: 'Abgelaufen',
DEACTIVATED: 'Deaktiviert',
};
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
ACTIVE: 'success',
PENDING: 'warning',
CANCELLED: 'danger',
EXPIRED: 'danger',
DRAFT: 'default',
DEACTIVATED: 'default',
};
interface ContractDetailModalProps {
contractId: number;
isOpen: boolean;
onClose: () => void;
}
export default function ContractDetailModal({ contractId, isOpen, onClose }: ContractDetailModalProps) {
const { data, isLoading, error } = useQuery({
queryKey: ['contract', contractId],
queryFn: () => contractApi.getById(contractId),
enabled: isOpen,
});
const c = data?.data;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Vertragsdetails" size="xl">
{isLoading && (
<div className="text-center py-8 text-gray-500">Laden...</div>
)}
{error && (
<div className="text-center py-8 text-red-600">Fehler beim Laden des Vertrags</div>
)}
{c && (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3 pb-4 border-b">
<span className="text-xl font-bold font-mono flex items-center gap-2">
{c.contractNumber}
<CopyButton value={c.contractNumber} />
</span>
<Badge>{typeLabels[c.type as ContractType] || c.type}</Badge>
<Badge variant={statusVariants[c.status as ContractStatus] || 'default'}>
{statusLabels[c.status as ContractStatus] || c.status}
</Badge>
</div>
{/* Anbieter & Tarif */}
{(c.providerName || c.provider?.name || c.tariffName || c.tariff?.name) && (
<Card title="Anbieter & Tarif">
<div className="grid grid-cols-2 gap-4">
{(c.providerName || c.provider?.name) && (
<div>
<dt className="text-sm text-gray-500">Anbieter</dt>
<dd className="flex items-center gap-1">
{c.providerName || c.provider?.name}
<CopyButton value={c.providerName || c.provider?.name || ''} />
</dd>
</div>
)}
{(c.tariffName || c.tariff?.name) && (
<div>
<dt className="text-sm text-gray-500">Tarif</dt>
<dd className="flex items-center gap-1">
{c.tariffName || c.tariff?.name}
<CopyButton value={c.tariffName || c.tariff?.name || ''} />
</dd>
</div>
)}
{c.customerNumberAtProvider && (
<div>
<dt className="text-sm text-gray-500">Kundennummer beim Anbieter</dt>
<dd className="font-mono flex items-center gap-1">
{c.customerNumberAtProvider}
<CopyButton value={c.customerNumberAtProvider} />
</dd>
</div>
)}
</div>
</Card>
)}
{/* Laufzeit */}
<Card title="Laufzeit">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{c.startDate && (
<div>
<dt className="text-sm text-gray-500">Vertragsbeginn</dt>
<dd>{new Date(c.startDate).toLocaleDateString('de-DE')}</dd>
</div>
)}
{c.endDate && (
<div>
<dt className="text-sm text-gray-500">Vertragsende</dt>
<dd>{new Date(c.endDate).toLocaleDateString('de-DE')}</dd>
</div>
)}
{c.contractDuration && (
<div>
<dt className="text-sm text-gray-500">Laufzeit</dt>
<dd>{c.contractDuration.description}</dd>
</div>
)}
{c.cancellationPeriod && (
<div>
<dt className="text-sm text-gray-500">Kündigungsfrist</dt>
<dd>{c.cancellationPeriod.description}</dd>
</div>
)}
</div>
</Card>
{/* Portal-Zugangsdaten */}
{(c.portalUsername || c.provider?.portalUrl) && (
<Card title="Portal-Zugangsdaten">
<div className="grid grid-cols-2 gap-4">
{c.provider?.portalUrl && (
<div>
<dt className="text-sm text-gray-500">Portal-URL</dt>
<dd>
<a
href={c.provider.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{c.provider.portalUrl}
</a>
</dd>
</div>
)}
{c.portalUsername && (
<div>
<dt className="text-sm text-gray-500">Benutzername</dt>
<dd className="font-mono flex items-center gap-1">
{c.portalUsername}
<CopyButton value={c.portalUsername} />
</dd>
</div>
)}
</div>
</Card>
)}
{/* Adresse */}
{c.address && (
<Card title="Lieferadresse">
<p>
{c.address.street} {c.address.houseNumber}
</p>
<p>{c.address.postalCode} {c.address.city}</p>
</Card>
)}
{/* Notizen */}
{c.notes && (
<Card title="Notizen">
<p className="whitespace-pre-wrap text-gray-700">{c.notes}</p>
</Card>
)}
</div>
)}
</Modal>
);
}
@@ -0,0 +1 @@
export { default as ContractDetailModal } from './ContractDetailModal';
+21 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save } from 'lucide-react';
import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react';
import { CachedEmail, cachedEmailApi } from '../../services/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import Button from '../ui/Button';
@@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import toast from 'react-hot-toast';
import SaveAttachmentModal from './SaveAttachmentModal';
import SaveEmailAsPdfModal from './SaveEmailAsPdfModal';
interface EmailDetailProps {
email: CachedEmail;
@@ -37,6 +38,7 @@ export default function EmailDetail({
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false);
const [saveAttachmentFilename, setSaveAttachmentFilename] = useState<string | null>(null);
const [showSaveAsPdfModal, setShowSaveAsPdfModal] = useState(false);
const queryClient = useQueryClient();
const { hasPermission } = useAuth();
@@ -219,6 +221,15 @@ export default function EmailDetail({
<Reply className="w-4 h-4 mr-1" />
Antworten
</Button>
{/* E-Mail als PDF speichern */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveAsPdfModal(true)}
title="E-Mail als PDF speichern"
>
<FileDown className="w-4 h-4" />
</Button>
{/* Löschen-Button nur für User mit emails:delete Permission */}
{hasPermission('emails:delete') && (
<Button
@@ -481,6 +492,15 @@ export default function EmailDetail({
attachmentFilename={saveAttachmentFilename}
/>
)}
{/* E-Mail als PDF speichern Modal */}
{showSaveAsPdfModal && (
<SaveEmailAsPdfModal
isOpen={true}
onClose={() => setShowSaveAsPdfModal(false)}
emailId={email.id}
/>
)}
</div>
);
}
+1
View File
@@ -4,3 +4,4 @@ export { default as ComposeEmailModal } from './ComposeEmailModal';
export { default as AssignToContractModal } from './AssignToContractModal';
export { default as EmailClientTab } from './EmailClientTab';
export { default as ContractEmailsSection } from './ContractEmailsSection';
export { default as SaveEmailAsPdfModal } from './SaveEmailAsPdfModal';
@@ -3,6 +3,7 @@ import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal } from '../../components/contracts';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -1062,6 +1063,9 @@ export default function ContractDetail() {
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
const [decryptedSipPasswords, setDecryptedSipPasswords] = useState<Record<number, string | null>>({});
// Modal für Vorgängervertrag
const [showPredecessorModal, setShowPredecessorModal] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
@@ -1270,6 +1274,15 @@ export default function ContractDetail() {
</div>
{!isCustomer && (
<div className="flex gap-2">
{c.previousContract && (
<Button
variant="secondary"
onClick={() => setShowPredecessorModal(true)}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Vorgängervertrag
</Button>
)}
{hasPermission('contracts:create') && !c.followUpContract && (
<Button
variant="secondary"
@@ -1320,12 +1333,12 @@ export default function ContractDetail() {
<div>
<dt className="text-sm text-gray-500">Vertragsnummer</dt>
<dd>
<Link
to={`/contracts/${c.previousContract.id}`}
<button
onClick={() => setShowPredecessorModal(true)}
className="text-blue-600 hover:underline"
>
{c.previousContract.contractNumber}
</Link>
</button>
</dd>
</div>
{c.previousContract.providerName && (
@@ -2369,6 +2382,15 @@ export default function ContractDetail() {
<p className="whitespace-pre-wrap">{c.notes}</p>
</Card>
)}
{/* Vorgängervertrag Modal */}
{showPredecessorModal && c.previousContract && (
<ContractDetailModal
contractId={c.previousContract.id}
isOpen={true}
onClose={() => setShowPredecessorModal(false)}
/>
)}
</div>
);
}
+8
View File
@@ -506,6 +506,14 @@ export const cachedEmailApi = {
);
return res.data;
},
// E-Mail als PDF speichern
saveEmailAsPdf: async (emailId: number, params: { entityType: string; entityId?: number; targetKey: string }) => {
const res = await api.post<ApiResponse<{ path: string; filename: string; size: number }>>(
`/emails/${emailId}/save-as-pdf`,
params
);
return res.data;
},
};
// Contracts - Vertragsbaum für Kundenansicht