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:
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user