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>
This commit is contained in:
2026-05-07 19:26:33 +02:00
parent 45f63d1c48
commit 2c7a87ccd3
7 changed files with 568 additions and 54 deletions
+145 -45
View File
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
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,
@@ -17,6 +18,7 @@ import {
Calendar,
FileType,
FileText,
FileCode,
} from 'lucide-react';
import api from '../../services/api';
@@ -27,6 +29,19 @@ interface PreviewCounts {
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() {
@@ -34,6 +49,12 @@ export default function FactoryDefaults() {
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 () => {
@@ -86,9 +107,39 @@ export default function FactoryDefaults() {
{ 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">
@@ -109,14 +160,15 @@ export default function FactoryDefaults() {
<div className="text-sm text-blue-900 space-y-1">
<p className="font-medium">Was sind Factory-Defaults?</p>
<p>
Das sind <strong>reine Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
Kündigungsfristen, Vertragskategorien und PDF-Auftragsvorlagen. Du kannst sie
exportieren, um sie in anderen OpenCRM-Installationen als Startpunkt zu
verwenden.
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, Emails
oder Einstellungen dafür gibt es den separaten{' '}
<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>
@@ -127,16 +179,10 @@ export default function FactoryDefaults() {
<Card title="Export" className="mb-6">
<p className="text-sm text-gray-600 mb-4">
Erstellt ein ZIP mit allen Kataloge-Daten + PDF-Vorlagen. Lade es herunter und
entpacke den Inhalt in einer anderen Installation unter{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
backend/factory-defaults/
</code>
, dann dort{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
npm run seed:defaults
</code>{' '}
ausführen.
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 ? (
@@ -191,34 +237,88 @@ export default function FactoryDefaults() {
</Card>
<Card title="Import">
<div className="space-y-3 text-sm text-gray-600">
<p>
Der Import läuft über ein Kommandozeilen-Script dadurch bleibt klar, was wann
passiert und es gibt keine ungeplanten Überschreibungen im Produktivbetrieb.
</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>
ZIP in einer beliebigen Installation herunterladen und den Inhalt nach{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
backend/factory-defaults/
</code>{' '}
entpacken
</li>
<li>
Optional mehrere ZIPs zusammenwerfen (JSON-Dateien werden automatisch gemerged)
</li>
<li>
Im Backend-Ordner:{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
npm run seed:defaults
</code>
</li>
</ol>
<p className="pt-2">
Das Script läuft <strong>idempotent</strong> gleiche Einträge werden per
unique-Key aktualisiert, neue hinzugefügt. Kann beliebig oft ausgeführt werden.
</p>
<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>
);