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
+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>
);
}