Files
opencrm/frontend/src/pages/settings/FactoryDefaults.tsx
T
duffyduck 2c7a87ccd3 factory-defaults: HTML-Templates + Import über UI
Erweitert das bestehende Factory-Defaults-Bundle um vier HTML-Standardtexte
(Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz)
und ergänzt den bisherigen CLI-Only-Import um einen Upload-Pfad in der UI.

Backend:
- collectFactoryDefaults() zieht jetzt auch die Whitelist-AppSettings
- exportFactoryDefaults() legt sie als app-settings/app-settings.json ins ZIP
- importFactoryDefaults(buffer) liest die ZIP idempotent ein – upserts pro
  Kategorie, Whitelist-Filter für AppSettings, Anti-Zip-Slip durch basename
  beim PDF-Lookup
- POST /api/factory-defaults/import (multer memoryStorage, max 50 MB,
  settings:update)
- seed-factory-defaults.ts (CLI) gleichermaßen um seedAppSettings() erweitert

Frontend:
- Import-Card in FactoryDefaults.tsx: Datei-Upload statt CLI-Anleitung
- Erfolgs-Box mit Counts pro Kategorie + Warnings (z.B. fehlende PDFs im ZIP)
- Preview zeigt jetzt auch die Anzahl HTML-Templates

Live verifiziert: Round-Trip Export → DELETE privacyPolicyHtml → Import →
Wert (13.6 KB) wieder vollständig hergestellt, Audit-Log zeigt EXPORT +
UPDATE-Eintrag mit Detail-Counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:26:33 +02:00

326 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import {
ArrowLeft,
Download,
Upload,
Package,
Info,
Loader2,
Check,
AlertCircle,
Building2,
Tag,
Clock,
Calendar,
FileType,
FileText,
FileCode,
} from 'lucide-react';
import api from '../../services/api';
interface PreviewCounts {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
appSettings: number;
}
interface ImportResult {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
pdfTemplatesSkipped: number;
appSettings: number;
warnings: string[];
}
export default function FactoryDefaults() {
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [downloadDone, setDownloadDone] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const queryClient = useQueryClient();
const { data: previewData, isLoading } = useQuery({
queryKey: ['factory-defaults-preview'],
queryFn: async () => {
const res = await api.get<{ success: boolean; data: { counts: PreviewCounts } }>(
'/factory-defaults/preview',
);
return res.data;
},
});
const counts = previewData?.data?.counts;
const handleExport = async () => {
setDownloading(true);
setDownloadError(null);
setDownloadDone(false);
try {
const res = await api.get('/factory-defaults/export', {
responseType: 'blob',
});
const blob = new Blob([res.data as BlobPart], { type: 'application/zip' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const dateStr = new Date().toISOString().split('T')[0];
a.href = url;
a.download = `factory-defaults-${dateStr}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
setDownloadDone(true);
setTimeout(() => setDownloadDone(false), 3000);
} catch (err) {
setDownloadError(
err instanceof Error ? err.message : 'Fehler beim Export',
);
} finally {
setDownloading(false);
}
};
const sections = counts
? [
{ icon: Building2, label: 'Anbieter', count: counts.providers, color: 'text-blue-600' },
{ icon: Tag, label: 'Tarife', count: counts.tariffs, color: 'text-indigo-600' },
{ icon: Clock, label: 'Kündigungsfristen', count: counts.cancellationPeriods, color: 'text-purple-600' },
{ icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' },
{ icon: FileType, label: 'Vertragskategorien', count: counts.contractCategories, color: 'text-orange-600' },
{ icon: FileText, label: 'PDF-Auftragsvorlagen', count: counts.pdfTemplates, color: 'text-green-600' },
{ icon: FileCode, label: 'HTML-Templates', count: counts.appSettings ?? 0, color: 'text-teal-600' },
]
: [];
const handleImport = async (file: File) => {
setImporting(true);
setImportError(null);
setImportResult(null);
try {
const formData = new FormData();
formData.append('zip', file);
const res = await api.post<{ success: boolean; data: ImportResult; error?: string }>(
'/factory-defaults/import',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
if (!res.data.success) {
throw new Error(res.data.error || 'Fehler beim Import');
}
setImportResult(res.data.data);
// Caches invalidieren neue Anbieter, Tarife, Vorlagen tauchen sofort
// an anderer Stelle (Provider-Liste, Vertrag-Anlage, …) auf.
queryClient.invalidateQueries();
} catch (err: any) {
setImportError(
err?.response?.data?.error || err?.message || 'Fehler beim Import',
);
} finally {
setImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<div className="flex items-center gap-3">
<Package className="w-6 h-6 text-blue-600" />
<h1 className="text-2xl font-bold">Factory-Defaults</h1>
</div>
</div>
{/* Info-Box */}
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900 space-y-1">
<p className="font-medium">Was sind Factory-Defaults?</p>
<p>
Das sind <strong>Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
Kündigungsfristen, Vertragskategorien, PDF-Auftragsvorlagen und die
HTML-Standardtexte (Datenschutzerklärung, Impressum, Vollmacht-Vorlage,
Website-Datenschutz). Du kannst sie exportieren, um sie in anderen
OpenCRM-Installationen als Startpunkt zu verwenden.
</p>
<p className="pt-1">
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, E-Mails
oder Konfigurationen (SMTP, Secrets) dafür gibt es den separaten{' '}
<Link to="/settings/database-backup" className="underline">
Datenbank-Backup
</Link>
.
</p>
</div>
</div>
<Card title="Export" className="mb-6">
<p className="text-sm text-gray-600 mb-4">
Erstellt ein ZIP mit allen Kataloge-Daten, PDF-Auftragsvorlagen und den
HTML-Standardtexten (Datenschutz / Impressum / Vollmacht). In einer anderen
OpenCRM-Installation kannst du es dann unten unter <strong>Import</strong> wieder
einspielen.
</p>
{isLoading ? (
<div className="flex items-center gap-2 text-gray-500 py-4">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">Lade Übersicht</span>
</div>
) : counts ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
{sections.map((s) => (
<div
key={s.label}
className="border border-gray-200 rounded-lg p-3 flex items-center gap-3"
>
<s.icon className={`w-5 h-5 ${s.color}`} />
<div>
<div className="text-sm text-gray-500">{s.label}</div>
<div className="text-xl font-bold">{s.count}</div>
</div>
</div>
))}
</div>
) : null}
<div className="flex items-center gap-3">
<Button onClick={handleExport} disabled={downloading}>
{downloading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
ZIP wird erstellt
</>
) : downloadDone ? (
<>
<Check className="w-4 h-4 mr-2" />
Heruntergeladen
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Factory-Defaults exportieren
</>
)}
</Button>
</div>
{downloadError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-sm text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{downloadError}</span>
</div>
)}
</Card>
<Card title="Import">
<p className="text-sm text-gray-600 mb-4">
Lade hier eine zuvor exportierte Factory-Defaults-ZIP hoch. Bestehende Einträge
werden anhand des Unique-Keys (Name / Code) <strong>aktualisiert</strong>, neue
werden angelegt. Es wird nichts gelöscht der Vorgang ist idempotent.
</p>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleImport(f);
}}
/>
<div className="flex items-center gap-3">
<Button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
variant="secondary"
>
{importing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Import läuft
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
ZIP hochladen
</>
)}
</Button>
<span className="text-xs text-gray-500">
Alternativ:{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded">
npm run seed:defaults
</code>{' '}
im Backend
</span>
</div>
{importResult && (
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800">
<div className="flex items-center gap-2 font-medium mb-2">
<Check className="w-4 h-4" />
Import erfolgreich
</div>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>Anbieter: {importResult.providers}</li>
<li>Tarife: {importResult.tariffs}</li>
<li>Kündigungsfristen: {importResult.cancellationPeriods}</li>
<li>Laufzeiten: {importResult.contractDurations}</li>
<li>Vertragskategorien: {importResult.contractCategories}</li>
<li>
PDF-Vorlagen: {importResult.pdfTemplates}
{importResult.pdfTemplatesSkipped > 0 &&
` (${importResult.pdfTemplatesSkipped} übersprungen)`}
</li>
<li>HTML-Templates: {importResult.appSettings}</li>
</ul>
{importResult.warnings.length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200 text-amber-700 text-xs">
<div className="font-medium mb-1">Hinweise:</div>
<ul className="list-disc list-inside space-y-0.5">
{importResult.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
</div>
)}
{importError && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2 text-sm text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{importError}</span>
</div>
)}
</Card>
</div>
);
}