Files
opencrm/frontend/src/pages/settings/FactoryDefaults.tsx
T
duffyduck 60dc98e265 Factory-Defaults: Export + Import von Stammdaten-Katalogen
Ein neues System um Stammdaten-Kataloge zwischen Installationen zu teilen –
explizit ohne Kundendaten, Verträge oder Einstellungen.

**Was wird exportiert:**
- Anbieter + zugehörige Tarife
- Kündigungsfristen
- Vertragslaufzeiten
- Vertragskategorien
- PDF-Auftragsvorlagen (JSON + PDF-Dateien + Feldzuordnungen)

**Was NICHT:**
- Kundendaten, Verträge, Dokumente, Emails, SMTP-Einstellungen
  → dafür gibt es den Datenbank-Backup

**Neue Einstellungsseite /settings/factory-defaults:**
- Zeigt Anzahl pro Kategorie (Anbieter, Tarife, Fristen, …)
- "Exportieren"-Button lädt ZIP herunter (manifest.json + JSONs + PDFs)
- Import-Anleitung inline

**Import-Script:**
- `npm run seed:defaults` (tsx scripts/seed-factory-defaults.ts)
- Liest alle JSON-Dateien aus backend/factory-defaults/*/*.json
- Merged mehrere Dateien automatisch pro Kategorie (unique-key gewinnt zuletzt)
- Upsertet idempotent → kann mehrfach ausgeführt werden
- Kopiert PDF-Vorlagen aus factory-defaults/pdf-templates/ nach uploads/pdf-templates/
- Alte PDF-Dateien werden beim Re-Import entsorgt

Backend:
- services/factoryDefaults.service.ts: collectFactoryDefaults() + exportFactoryDefaults()
- controllers/factoryDefaults.controller.ts: preview + export
- routes/factoryDefaults.routes.ts: GET /api/factory-defaults/preview + /export
- scripts/seed-factory-defaults.ts: CLI-Import-Script
- .gitignore: factory-defaults/* außer .gitkeep und README.md

Frontend:
- pages/settings/FactoryDefaults.tsx: Übersicht + Export-Button
- Settings-Karte „Factory-Defaults" im System-Abschnitt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 14:10:12 +02:00

226 lines
7.7 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 { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import {
ArrowLeft,
Download,
Package,
Info,
Loader2,
Check,
AlertCircle,
Building2,
Tag,
Clock,
Calendar,
FileType,
FileText,
} from 'lucide-react';
import api from '../../services/api';
interface PreviewCounts {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
}
export default function FactoryDefaults() {
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [downloadDone, setDownloadDone] = useState(false);
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' },
]
: [];
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>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.
</p>
<p className="pt-1">
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, Emails
oder Einstellungen 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-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.
</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">
<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>
</div>
</Card>
</div>
);
}