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>
This commit is contained in:
2026-04-05 19:16:47 +02:00
parent 5e9e553882
commit 29eceef26b
16 changed files with 1881 additions and 21 deletions
+2
View File
@@ -36,6 +36,7 @@ import PrivacyPolicyEditor from './pages/settings/PrivacyPolicyEditor';
import PortalPrivacy from './pages/portal/PortalPrivacy';
import AuthorizationTemplateEditor from './pages/settings/AuthorizationTemplateEditor';
import ImprintEditor from './pages/settings/ImprintEditor';
import PdfTemplates from './pages/settings/PdfTemplates';
import WebsitePrivacyPolicyEditor from './pages/settings/WebsitePrivacyPolicyEditor';
import PortalImprint from './pages/portal/PortalImprint';
import PortalWebsitePrivacy from './pages/portal/PortalWebsitePrivacy';
@@ -196,6 +197,7 @@ function App() {
<Route path="settings/gdpr" element={<GDPRDashboard />} />
<Route path="settings/privacy-policy" element={<PrivacyPolicyEditor />} />
<Route path="settings/authorization-template" element={<AuthorizationTemplateEditor />} />
<Route path="settings/pdf-templates" element={<PdfTemplates />} />
<Route path="settings/imprint" element={<ImprintEditor />} />
<Route path="settings/website-privacy-policy" element={<WebsitePrivacyPolicyEditor />} />
+17
View File
@@ -126,6 +126,23 @@ export default function Settings() {
</div>
</div>
</Link>
<Link
to="/settings/pdf-templates"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<FileText className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Auftragsvorlagen
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">PDF-Vorlagen für Auftragsformulare hochladen und Felder zuordnen.</p>
</div>
</div>
</Link>
<Link
to="/settings/email-providers"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
+162 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { pushHistory, popHistory } from '../../utils/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi } from '../../services/api';
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdprApi, pdfTemplateApi } from '../../services/api';
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
@@ -1772,6 +1772,7 @@ export default function ContractDetail() {
</Button>
</Link>
)}
<GenerateOrderButton contractId={contractId} />
{hasPermission('contracts:delete') && (
<Button
variant="danger"
@@ -3269,3 +3270,163 @@ function ContractDocumentsSection({
</Card>
);
}
// ==================== AUFTRAG GENERIEREN BUTTON ====================
function GenerateOrderButton({ contractId }: { contractId: number }) {
const [showDropdown, setShowDropdown] = useState(false);
const [showInputModal, setShowInputModal] = useState<{ templateId: number; templateName: string } | null>(null);
const { data: templatesData } = useQuery({
queryKey: ['pdf-templates'],
queryFn: () => pdfTemplateApi.getAll(),
});
const templates = (templatesData?.data || []).filter(t => t.isActive);
if (templates.length === 0) return null;
const handleSelect = async (templateId: number, templateName: string) => {
setShowDropdown(false);
// Prüfen ob manuelle Inputs nötig sind
try {
const result = await pdfTemplateApi.getRequiredInputs(templateId, contractId);
const inputs = result.data;
if (inputs && (inputs.needsStressfreiEmail || inputs.manualFields.length > 0)) {
setShowInputModal({ templateId, templateName });
} else {
// Direkt generieren (GET-Link)
const token = localStorage.getItem('token');
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank');
}
} catch {
// Fallback: direkt generieren
const token = localStorage.getItem('token');
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?token=${token}`, '_blank');
}
};
return (
<>
{templates.length === 1 ? (
<Button variant="secondary" onClick={() => handleSelect(templates[0].id, templates[0].name)}>
<FileText className="w-4 h-4 mr-2" />
{templates[0].name}
</Button>
) : (
<div className="relative">
<Button variant="secondary" onClick={() => setShowDropdown(!showDropdown)}>
<FileText className="w-4 h-4 mr-2" />
Auftrag generieren
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowDropdown(false)} />
<div className="absolute right-0 mt-1 w-64 bg-white border rounded-lg shadow-lg z-20 py-1">
{templates.map((t) => (
<button
key={t.id}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center gap-2"
onClick={() => handleSelect(t.id, t.name)}
>
<FileText className="w-4 h-4 text-blue-500" />
<div>
<span className="font-medium">{t.name}</span>
{t.providerName && <span className="text-xs text-gray-500 ml-1">({t.providerName})</span>}
</div>
</button>
))}
</div>
</>
)}
</div>
)}
{showInputModal && (
<GenerateInputModal
templateId={showInputModal.templateId}
templateName={showInputModal.templateName}
contractId={contractId}
onClose={() => setShowInputModal(null)}
/>
)}
</>
);
}
function GenerateInputModal({ templateId, templateName, contractId, onClose }: {
templateId: number;
templateName: string;
contractId: number;
onClose: () => void;
}) {
const [stressfreiEmailId, setStressfreiEmailId] = useState('');
const [manualValues, setManualValues] = useState<Record<string, string>>({});
const [generating] = useState(false);
const { data: inputsData, isLoading } = useQuery({
queryKey: ['pdf-inputs', templateId, contractId],
queryFn: () => pdfTemplateApi.getRequiredInputs(templateId, contractId),
});
const inputs = inputsData?.data;
const handleGenerate = () => {
const token = localStorage.getItem('token');
const params = new URLSearchParams();
params.set('token', token || '');
if (stressfreiEmailId) params.set('stressfreiEmailId', stressfreiEmailId);
for (const [key, value] of Object.entries(manualValues)) {
if (value) params.set(`manual_${key}`, value);
}
window.open(`${pdfTemplateApi.generateUrl(templateId, contractId)}?${params}`, '_blank');
onClose();
};
if (isLoading) return null;
return (
<Modal isOpen={true} onClose={onClose} title={`${templateName} Zusätzliche Angaben`}>
<div className="space-y-4">
{inputs?.needsStressfreiEmail && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Stressfrei-Wechseln E-Mail</label>
<select
value={stressfreiEmailId}
onChange={(e) => setStressfreiEmailId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Bitte wählen...</option>
{inputs.stressfreiEmails.map((e) => (
<option key={e.id} value={e.id}>{e.email}</option>
))}
</select>
</div>
)}
{inputs?.manualFields && inputs.manualFields.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">Freitextfelder</p>
{inputs.manualFields.map((f) => (
<Input
key={f.key}
label={f.pdfFieldName}
value={manualValues[f.key] || ''}
onChange={(e) => setManualValues({ ...manualValues, [f.key]: e.target.value })}
placeholder="Eingabe..."
/>
))}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={handleGenerate} disabled={generating}>
<FileText className="w-4 h-4 mr-2" />
{generating ? 'Generiere...' : 'PDF generieren'}
</Button>
</div>
</div>
</Modal>
);
}
@@ -717,6 +717,14 @@ function AddressesTab({
</p>
<p className="text-gray-500">{addr.country}</p>
</CopyableBlock>
{(addr.ownerFirstName || addr.ownerLastName || addr.ownerCompany) && (
<div className="mt-2 pt-2 border-t text-xs text-gray-500">
<span className="font-medium">Eigentümer: </span>
{addr.ownerCompany && <span>{addr.ownerCompany} </span>}
{addr.ownerFirstName} {addr.ownerLastName}
{addr.ownerPhone && <span> · {addr.ownerPhone}</span>}
</div>
)}
</div>
))}
</div>
@@ -2097,6 +2105,16 @@ function AddressModal({
city: address?.city || '',
country: address?.country || 'Deutschland',
isDefault: address?.isDefault || false,
ownerCompany: address?.ownerCompany || '',
ownerFirstName: address?.ownerFirstName || '',
ownerLastName: address?.ownerLastName || '',
ownerStreet: address?.ownerStreet || '',
ownerHouseNumber: address?.ownerHouseNumber || '',
ownerPostalCode: address?.ownerPostalCode || '',
ownerCity: address?.ownerCity || '',
ownerPhone: address?.ownerPhone || '',
ownerMobile: address?.ownerMobile || '',
ownerEmail: address?.ownerEmail || '',
});
const [formData, setFormData] = useState(getInitialFormData);
@@ -2106,15 +2124,7 @@ function AddressModal({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
onClose();
setFormData({
type: 'DELIVERY_RESIDENCE',
street: '',
houseNumber: '',
postalCode: '',
city: '',
country: 'Deutschland',
isDefault: false,
});
setFormData(getInitialFormData());
},
});
@@ -2205,6 +2215,82 @@ function AddressModal({
Als Standard setzen
</label>
{/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */}
{formData.type === 'DELIVERY_RESIDENCE' && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium text-gray-700 mb-1">Eigentümer</h4>
<p className="text-xs text-gray-500 mb-3">
Nur ausfüllen wenn der Kunde nicht selbst Eigentümer ist (z.B. Mietwohnung).
</p>
<div className="space-y-3">
<Input
label="Firma (optional)"
value={formData.ownerCompany}
onChange={(e) => setFormData({ ...formData, ownerCompany: e.target.value })}
placeholder="z.B. Wohnungsbaugesellschaft"
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Vorname"
value={formData.ownerFirstName}
onChange={(e) => setFormData({ ...formData, ownerFirstName: e.target.value })}
/>
<Input
label="Nachname"
value={formData.ownerLastName}
onChange={(e) => setFormData({ ...formData, ownerLastName: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Input
label="Straße"
value={formData.ownerStreet}
onChange={(e) => setFormData({ ...formData, ownerStreet: e.target.value })}
/>
</div>
<Input
label="Hausnr."
value={formData.ownerHouseNumber}
onChange={(e) => setFormData({ ...formData, ownerHouseNumber: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<Input
label="PLZ"
value={formData.ownerPostalCode}
onChange={(e) => setFormData({ ...formData, ownerPostalCode: e.target.value })}
/>
<div className="col-span-2">
<Input
label="Ort"
value={formData.ownerCity}
onChange={(e) => setFormData({ ...formData, ownerCity: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<Input
label="Telefon"
value={formData.ownerPhone}
onChange={(e) => setFormData({ ...formData, ownerPhone: e.target.value })}
/>
<Input
label="Mobil"
value={formData.ownerMobile}
onChange={(e) => setFormData({ ...formData, ownerMobile: e.target.value })}
/>
<Input
label="E-Mail"
value={formData.ownerEmail}
onChange={(e) => setFormData({ ...formData, ownerEmail: e.target.value })}
type="email"
/>
</div>
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
@@ -0,0 +1,498 @@
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>
);
}
+49
View File
@@ -1307,6 +1307,55 @@ export const auditLogApi = {
},
};
// ==================== PDF TEMPLATES ====================
export const pdfTemplateApi = {
getAll: async () => {
const res = await api.get<ApiResponse<import('../types').PdfTemplate[]>>('/pdf-templates');
return res.data;
},
getById: async (id: number) => {
const res = await api.get<ApiResponse<import('../types').PdfTemplate>>(`/pdf-templates/${id}`);
return res.data;
},
create: async (formData: FormData) => {
const res = await api.post<ApiResponse<import('../types').PdfTemplate & { pdfFields?: import('../types').PdfField[] }>>('/pdf-templates', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
update: async (id: number, data: Record<string, unknown>) => {
const res = await api.put<ApiResponse<import('../types').PdfTemplate>>(`/pdf-templates/${id}`, data);
return res.data;
},
delete: async (id: number) => {
const res = await api.delete<ApiResponse<void>>(`/pdf-templates/${id}`);
return res.data;
},
getFields: async (id: number) => {
const res = await api.get<ApiResponse<import('../types').PdfField[]>>(`/pdf-templates/${id}/fields`);
return res.data;
},
getCrmFields: async (maxPhoneFields?: number) => {
const res = await api.get<ApiResponse<import('../types').CrmField[]>>('/pdf-templates/crm-fields', { params: { maxPhoneFields } });
return res.data;
},
getRequiredInputs: async (templateId: number, contractId: number) => {
const res = await api.get<ApiResponse<{
needsStressfreiEmail: boolean;
stressfreiEmails: { id: number; email: string }[];
manualFields: { key: string; pdfFieldName: string }[];
}>>(`/pdf-templates/${templateId}/generate/${contractId}/inputs`);
return res.data;
},
generatePdf: async (templateId: number, contractId: number, extras?: { stressfreiEmailId?: number; manualValues?: Record<string, string> }) => {
const res = await api.post(`/pdf-templates/${templateId}/generate/${contractId}`, extras || {}, { responseType: 'blob' });
return res.data;
},
generateUrl: (templateId: number, contractId: number) =>
`/api/pdf-templates/${templateId}/generate/${contractId}`,
};
// ==================== EMAIL LOG ====================
export interface EmailLog {
+39
View File
@@ -123,6 +123,17 @@ export interface Address {
city: string;
country: string;
isDefault: boolean;
// Eigentümer (leer = Kunde ist selbst Eigentümer)
ownerCompany?: string;
ownerFirstName?: string;
ownerLastName?: string;
ownerStreet?: string;
ownerHouseNumber?: string;
ownerPostalCode?: string;
ownerCity?: string;
ownerPhone?: string;
ownerMobile?: string;
ownerEmail?: string;
}
export interface BankCard {
@@ -163,6 +174,34 @@ export interface ContractDocument {
createdAt: string;
}
export interface PdfTemplate {
id: number;
name: string;
description?: string;
providerName?: string;
templatePath: string;
originalName: string;
fieldMapping: string;
phoneFieldPrefix?: string;
maxPhoneFields?: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PdfField {
name: string;
type: string;
page: number;
y: number;
}
export interface CrmField {
path: string;
label: string;
group: string;
}
export type MeterTariffModel = 'SINGLE' | 'DUAL';
export interface Meter {