Files
opencrm/frontend/src/pages/settings/PdfTemplates.tsx
T
duffyduck 29eceef26b PDF-Auftragsvorlagen-System, Objekttyp/Lage-Felder, Eigentümer-Fallback bei Bankverbindung
- PDF-Template-Editor in Einstellungen: Vorlagen hochladen, Formularfelder automatisch auslesen, CRM-Felder zuordnen
- PDF-Vorschau mit annotierten Feldnamen, seitenweise Sortierung der Felder
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion und konfigurierbarer Maximalanzahl
- Nicht zugeordnete Felder bleiben editierbar im generierten PDF
- Eigentümer-Felder mit Namens-Kombinationen (Firma+Name etc.) und Fallback auf Kundendaten
- Stressfrei-E-Mail als Feld-Option im Template-Editor
- Objekttyp, Lage und Lage des Anschlusses als neue Felder bei Festnetz-Verträgen (DSL, Glasfaser, Kabel)
- Bankverbindung-Fallback: wenn keine am Vertrag verknüpft, wird automatisch die neueste aktive des Kunden genommen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:16:47 +02:00

499 lines
22 KiB
TypeScript

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { pdfTemplateApi, contractApi } from '../../services/api';
import type { PdfTemplate, CrmField, Contract } from '../../types';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal';
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react';
export default function PdfTemplates() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<PdfTemplate | null>(null);
const [mappingTemplate, setMappingTemplate] = useState<PdfTemplate | null>(null);
const [testTemplate, setTestTemplate] = useState<PdfTemplate | null>(null);
const { data: templatesData, isLoading } = useQuery({
queryKey: ['pdf-templates'],
queryFn: () => pdfTemplateApi.getAll(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => pdfTemplateApi.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }),
});
const templates = templatesData?.data || [];
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-2xl font-bold flex-1">Auftragsvorlagen</h1>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Vorlage hochladen
</Button>
</div>
<Card className="mb-6">
<p className="text-sm text-gray-600">
Laden Sie PDF-Formulare hoch (z.B. EWE Auftragsformular) und verknüpfen Sie die Formularfelder mit CRM-Daten.
Beim Erstellen eines Auftrags werden die Felder automatisch mit den Kundendaten befüllt.
</p>
</Card>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : templates.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
Noch keine Vorlagen vorhanden. Laden Sie eine PDF-Vorlage hoch.
</div>
</Card>
) : (
<div className="space-y-4">
{templates.map((t) => (
<Card key={t.id}>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-blue-500 mt-0.5" />
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold">{t.name}</h3>
{t.providerName && <Badge variant="info">{t.providerName}</Badge>}
{!t.isActive && <Badge variant="danger">Inaktiv</Badge>}
</div>
{t.description && <p className="text-sm text-gray-500 mt-1">{t.description}</p>}
<p className="text-xs text-gray-400 mt-1">
Datei: {t.originalName}
{t.maxPhoneFields && ` · Max. ${t.maxPhoneFields} Rufnummern`}
</p>
{(() => {
const mapping = JSON.parse(t.fieldMapping || '{}');
const count = Object.keys(mapping).length;
return (
<p className="text-xs text-gray-400">
{count > 0 ? `${count} Felder verknüpft` : 'Noch keine Felder verknüpft'}
</p>
);
})()}
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setMappingTemplate(t)}>
<Link2 className="w-4 h-4 mr-1" />
Felder zuordnen
</Button>
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
<Play className="w-4 h-4 text-green-500" />
</Button>
<a href={`/api${t.templatePath}`} target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
<Eye className="w-4 h-4" />
</Button>
</a>
<Button variant="ghost" size="sm" onClick={() => setEditingTemplate(t)} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => {
if (confirm(`Vorlage "${t.name}" wirklich löschen?`)) deleteMutation.mutate(t.id);
}} title="Löschen">
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Erstellen Modal */}
{showCreateModal && (
<CreateTemplateModal onClose={() => { setShowCreateModal(false); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} />
)}
{/* Bearbeiten Modal */}
{editingTemplate && (
<EditTemplateModal template={editingTemplate} onClose={() => { setEditingTemplate(null); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} />
)}
{/* Feld-Mapping Modal */}
{mappingTemplate && (
<FieldMappingModal template={mappingTemplate} onClose={() => { setMappingTemplate(null); queryClient.invalidateQueries({ queryKey: ['pdf-templates'] }); }} />
)}
{/* Test-Vorschau Modal */}
{testTemplate && (
<TestPreviewModal template={testTemplate} onClose={() => setTestTemplate(null)} />
)}
</div>
);
}
// ==================== CREATE MODAL ====================
function CreateTemplateModal({ onClose }: { onClose: () => void }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [providerName, setProviderName] = useState('');
const [maxPhoneFields, setMaxPhoneFields] = useState('8');
const [file, setFile] = useState<File | null>(null);
const createMutation = useMutation({
mutationFn: async () => {
const formData = new FormData();
formData.append('name', name);
if (description) formData.append('description', description);
if (providerName) formData.append('providerName', providerName);
formData.append('maxPhoneFields', maxPhoneFields);
formData.append('template', file!);
return pdfTemplateApi.create(formData);
},
onSuccess: () => onClose(),
});
return (
<Modal isOpen={true} onClose={onClose} title="Neue Auftragsvorlage">
<div className="space-y-4">
<Input label="Name *" value={name} onChange={(e) => setName(e.target.value)} placeholder="z.B. EWE Auftragsformular" required />
<Input label="Anbieter" value={providerName} onChange={(e) => setProviderName(e.target.value)} placeholder="z.B. EWE" />
<Input label="Beschreibung" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optionale Beschreibung" />
<Input label="Max. Rufnummern-Felder" type="number" value={maxPhoneFields} onChange={(e) => setMaxPhoneFields(e.target.value)} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">PDF-Vorlage *</label>
<label className="flex items-center justify-center gap-2 p-4 border-2 border-dashed rounded-lg cursor-pointer hover:bg-gray-50">
<Upload className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-600">{file ? file.name : 'PDF-Datei auswählen'}</span>
<input type="file" accept=".pdf" className="hidden" onChange={(e) => setFile(e.target.files?.[0] || null)} />
</label>
<p className="text-xs text-gray-500 mt-1">
Die PDF muss Formularfelder enthalten. Diese werden nach dem Hochladen automatisch erkannt.
</p>
</div>
{createMutation.isError && (
<div className="p-3 bg-red-50 text-red-700 text-sm rounded-lg">
{createMutation.error instanceof Error ? createMutation.error.message : 'Fehler beim Erstellen'}
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => createMutation.mutate()} disabled={!name || !file || createMutation.isPending}>
{createMutation.isPending ? 'Hochladen...' : 'Hochladen'}
</Button>
</div>
</div>
</Modal>
);
}
// ==================== EDIT MODAL ====================
function EditTemplateModal({ template, onClose }: { template: PdfTemplate; onClose: () => void }) {
const [name, setName] = useState(template.name);
const [description, setDescription] = useState(template.description || '');
const [providerName, setProviderName] = useState(template.providerName || '');
const [maxPhoneFields, setMaxPhoneFields] = useState(template.maxPhoneFields?.toString() || '8');
const [isActive, setIsActive] = useState(template.isActive);
const updateMutation = useMutation({
mutationFn: () => pdfTemplateApi.update(template.id, { name, description, providerName, maxPhoneFields: parseInt(maxPhoneFields), isActive }),
onSuccess: () => onClose(),
});
return (
<Modal isOpen={true} onClose={onClose} title="Vorlage bearbeiten">
<div className="space-y-4">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
<Input label="Anbieter" value={providerName} onChange={(e) => setProviderName(e.target.value)} />
<Input label="Beschreibung" value={description} onChange={(e) => setDescription(e.target.value)} />
<Input label="Max. Rufnummern-Felder" type="number" value={maxPhoneFields} onChange={(e) => setMaxPhoneFields(e.target.value)} />
<label className="flex items-center gap-2">
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} className="rounded" />
<span className="text-sm">Aktiv</span>
</label>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => updateMutation.mutate()} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
</Modal>
);
}
// ==================== FIELD MAPPING MODAL ====================
function FieldMappingModal({ template, onClose }: { template: PdfTemplate; onClose: () => void }) {
const [mapping, setMapping] = useState<Record<string, string>>(JSON.parse(template.fieldMapping || '{}'));
const { data: fieldsData } = useQuery({
queryKey: ['pdf-fields', template.id],
queryFn: () => pdfTemplateApi.getFields(template.id),
});
const { data: crmFieldsData } = useQuery({
queryKey: ['crm-fields', template.maxPhoneFields],
queryFn: () => pdfTemplateApi.getCrmFields(template.maxPhoneFields || 8),
});
const saveMutation = useMutation({
mutationFn: () => pdfTemplateApi.update(template.id, { fieldMapping: mapping }),
onSuccess: () => onClose(),
});
const pdfFields = fieldsData?.data || [];
const totalPages = (fieldsData as any)?.totalPages || 1;
const crmFields = crmFieldsData?.data || [];
// CRM-Felder nach Gruppe
const crmGroups = crmFields.reduce((acc, f) => {
if (!acc[f.group]) acc[f.group] = [];
acc[f.group].push(f);
return acc;
}, {} as Record<string, CrmField[]>);
const [highlightedField, setHighlightedField] = useState<string | null>(null);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex">
{/* PDF-Vorschau links (nur zur Ansicht, nicht interaktiv) */}
<div className="w-1/2 bg-gray-800 flex flex-col">
<div className="p-3 bg-gray-900 text-white text-sm flex items-center justify-between">
<span>PDF-Vorschau mit Feldnamen</span>
<span className="text-gray-400 text-xs">[Feldname] zeigt wo das Feld in der PDF liegt</span>
</div>
<iframe
src={`/api/pdf-templates/${template.id}/preview?token=${localStorage.getItem('token')}`}
className="flex-1 w-full bg-white"
title="PDF Vorschau mit Feldnamen"
/>
</div>
{/* Zuordnung rechts */}
<div className="w-1/2 bg-white flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Felder zuordnen</h2>
<p className="text-xs text-gray-500">
PDF-Feld links in der Vorschau finden, dann rechts das CRM-Feld zuordnen
</p>
</div>
<Badge>{Object.keys(mapping).length} / {pdfFields.length}</Badge>
</div>
<div className="flex-1 overflow-y-auto p-4">
{pdfFields.length === 0 ? (
<div className="text-center py-8 text-amber-600 bg-amber-50 rounded-lg">
<p className="font-medium">Keine Formularfelder gefunden.</p>
<p className="text-sm mt-1">Die PDF muss interaktive Formularfelder enthalten.</p>
</div>
) : (
<>
<div className="flex gap-2 mb-3">
<button
onClick={() => setHighlightedField('all')}
className={`px-3 py-1 rounded text-xs font-medium ${highlightedField === 'all' || !highlightedField ? 'bg-gray-200 text-gray-800' : 'text-gray-500 hover:bg-gray-100'}`}
>
Alle ({pdfFields.length})
</button>
<button
onClick={() => setHighlightedField('open')}
className={`px-3 py-1 rounded text-xs font-medium ${highlightedField === 'open' ? 'bg-amber-100 text-amber-800' : 'text-gray-500 hover:bg-gray-100'}`}
>
Offen ({pdfFields.filter(f => !mapping[f.name]).length})
</button>
<button
onClick={() => setHighlightedField('done')}
className={`px-3 py-1 rounded text-xs font-medium ${highlightedField === 'done' ? 'bg-green-100 text-green-800' : 'text-gray-500 hover:bg-gray-100'}`}
>
Zugeordnet ({pdfFields.filter(f => !!mapping[f.name]).length})
</button>
</div>
<div className="space-y-2">
{(() => {
const filtered = pdfFields.filter((f: any) => {
if (highlightedField === 'open') return !mapping[f.name];
if (highlightedField === 'done') return !!mapping[f.name];
return true;
});
let lastPage = -1;
let fieldNum = 0;
return filtered.map((pdfField: any) => {
fieldNum++;
const isAssigned = !!mapping[pdfField.name];
const assignedLabel = isAssigned
? crmFields.find(c => c.path === mapping[pdfField.name])?.label
: null;
const showPageHeader = totalPages > 1 && pdfField.page !== lastPage;
lastPage = pdfField.page;
return (
<div key={pdfField.name}>
{showPageHeader && (
<div className="flex items-center gap-2 py-2 mt-2 first:mt-0">
<div className="flex-1 border-t border-blue-200" />
<span className="text-xs font-medium text-blue-600 px-2 bg-blue-50 rounded-full">
Seite {pdfField.page + 1}
</span>
<div className="flex-1 border-t border-blue-200" />
</div>
)}
<div
className={`p-3 rounded-lg border transition-colors ${
isAssigned ? 'border-green-200 bg-green-50' : 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-400 w-5">{fieldNum}.</span>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isAssigned ? 'bg-green-500' : 'bg-amber-400'}`} />
<span className="font-mono text-sm font-medium truncate" title={pdfField.name}>{pdfField.name}</span>
<Badge variant="default">{pdfField.type}</Badge>
{assignedLabel && (
<span className="text-xs text-green-700 ml-auto flex-shrink-0"> {assignedLabel}</span>
)}
</div>
<select
value={mapping[pdfField.name] || ''}
onChange={(e) => {
const newMapping = { ...mapping };
if (e.target.value) newMapping[pdfField.name] = e.target.value;
else delete newMapping[pdfField.name];
setMapping(newMapping);
}}
className={`block w-full px-3 py-1.5 border rounded text-sm ${
isAssigned ? 'border-green-300 bg-white' : 'border-gray-300'
}`}
>
<option value="">-- Nicht zuordnen --</option>
{Object.entries(crmGroups).map(([group, fields]) => (
<optgroup key={group} label={group}>
{fields.map((f) => (
<option key={f.path} value={f.path}>{f.label}</option>
))}
</optgroup>
))}
</select>
</div>
</div>
);
});
})()}
</div>
</>
)}
</div>
<div className="p-4 border-t flex justify-between items-center">
<p className="text-xs text-gray-500">
{Object.keys(mapping).length} von {pdfFields.length} zugeordnet
</p>
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
</div>
</div>
);
}
// ==================== TEST PREVIEW MODAL ====================
function TestPreviewModal({ template, onClose }: { template: PdfTemplate; onClose: () => void }) {
const [search, setSearch] = useState('');
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
const { data: contractsData, isLoading } = useQuery({
queryKey: ['contracts-search', search],
queryFn: () => contractApi.getAll({ search: search || undefined, limit: 10 }),
enabled: search.length >= 2,
});
const contracts: Contract[] = contractsData?.data || [];
const token = localStorage.getItem('token');
const handleGenerate = () => {
if (!selectedContractId) return;
const url = `${pdfTemplateApi.generateUrl(template.id, selectedContractId)}?token=${token}`;
window.open(url, '_blank');
};
return (
<Modal isOpen={true} onClose={onClose} title={`Testvorschau: ${template.name}`}>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Wählen Sie einen Vertrag aus, um die Vorlage mit echten Daten zu testen.
</p>
<Input
label="Vertrag suchen (Vertragsnr., Kunde, Kundennr.)"
value={search}
onChange={(e) => { setSearch(e.target.value); setSelectedContractId(null); }}
placeholder="z.B. ELE-ML9U66 oder Robbers"
/>
{isLoading && <p className="text-sm text-gray-500">Suche...</p>}
{contracts.length > 0 && (
<div className="max-h-48 overflow-y-auto border rounded-lg">
{contracts.map((c) => (
<button
key={c.id}
onClick={() => setSelectedContractId(c.id)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center justify-between border-b last:border-0 ${
selectedContractId === c.id ? 'bg-blue-50 border-blue-200' : ''
}`}
>
<div>
<span className="font-mono font-medium">{c.contractNumber}</span>
{c.customer && (
<span className="text-gray-500 ml-2">
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
</span>
)}
</div>
<Badge variant="default">{c.type}</Badge>
</button>
))}
</div>
)}
{search.length >= 2 && !isLoading && contracts.length === 0 && (
<p className="text-sm text-gray-500">Keine Verträge gefunden.</p>
)}
{selectedContractId && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800">
Vertrag ausgewählt. Klicken Sie auf "PDF generieren" um die befüllte Vorlage zu öffnen.
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose}>Schließen</Button>
<Button onClick={handleGenerate} disabled={!selectedContractId}>
<Play className="w-4 h-4 mr-2" />
PDF generieren
</Button>
</div>
</div>
</Modal>
);
}