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