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>
This commit is contained in:
duffyduck 2026-04-23 14:10:12 +02:00
parent b78afce43c
commit 60dc98e265
13 changed files with 913 additions and 40 deletions

5
backend/.gitignore vendored
View File

@ -17,6 +17,11 @@ prisma/backups/*
uploads/*
!uploads/.gitkeep
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
factory-defaults/*
!factory-defaults/.gitkeep
!factory-defaults/README.md
# Logs
*.log
npm-debug.log*

View File

View File

@ -0,0 +1,45 @@
# Factory Defaults
Dieser Ordner enthält **Stammdaten-Kataloge**, die beim Initialisieren einer neuen
OpenCRM-Installation automatisch eingespielt werden können.
## Was ist drin?
- `providers.json` Anbieter inkl. zugehöriger Tarife
- `cancellation-periods.json` Kündigungsfristen (z.B. "14 Tage", "1 Monat")
- `contract-durations.json` Vertragslaufzeiten (z.B. "12 Monate", "24 Monate")
- `contract-categories.json` Vertragskategorien (Strom, Gas, DSL …)
- `pdf-templates.json` + `pdf-templates/*.pdf` PDF-Auftragsvorlagen mit Feldzuordnungen
**NICHT enthalten sind Kundendaten, Verträge, Dokumente, E-Mails oder SMTP-Einstellungen.**
Dafür gibt es den separaten Datenbank-Backup-Export.
## Wie nutze ich das?
### Export (aus bestehender Installation)
In den CRM-Einstellungen → Factory-Defaults → „Exportieren" → ZIP herunterladen
und den Inhalt in diesen Ordner entpacken.
### Import
```bash
npm run seed:defaults
```
Das Script liest alle Dateien aus diesem Ordner, merged mehrere JSONs automatisch
per unique-name und spielt sie per Prisma `upsert` ein. Kann mehrfach ausgeführt
werden (idempotent).
## Mehrere Export-Dateien mergen
Wenn du mehrere ZIPs entpackst (z.B. Provider-Pakete von verschiedenen Quellen),
kannst du die JSON-Dateien frei umbenennen das Script liest alle `*.json` im
jeweiligen Unterordner und merged den Inhalt zusammen.
Beispiel:
```
providers/
verivox.json
check24.json
eigene.json
```
Alle drei werden eingespielt, gleiche Anbieter werden über den `name` gemerged.

View File

@ -15,7 +15,8 @@
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"db:backup": "tsx prisma/backup-data.ts",
"db:restore": "tsx prisma/restore-data.ts"
"db:restore": "tsx prisma/restore-data.ts",
"seed:defaults": "tsx scripts/seed-factory-defaults.ts"
},
"dependencies": {
"@prisma/client": "^5.22.0",

View File

@ -0,0 +1,325 @@
/**
* Seed-Script: Factory-Defaults aus backend/factory-defaults/ in die DB einspielen.
*
* - Liest alle JSON-Dateien aus den Unterordnern (providers/, contract-meta/, pdf-templates/)
* - Merged mehrere Dateien pro Kategorie automatisch
* - Nutzt Prisma upsert idempotent, kann mehrfach aufgerufen werden
* - Kopiert PDF-Dateien aus pdf-templates/ nach uploads/pdf-templates/
*
* Aufruf:
* npm run seed:defaults
*/
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const ROOT = path.join(process.cwd(), 'factory-defaults');
const UPLOADS_ROOT = path.join(process.cwd(), 'uploads');
const PDF_UPLOAD_DIR = path.join(UPLOADS_ROOT, 'pdf-templates');
interface ProviderDef {
name: string;
portalUrl?: string | null;
usernameFieldName?: string | null;
passwordFieldName?: string | null;
isActive?: boolean;
tariffs?: { name: string; isActive?: boolean }[];
}
interface CancellationPeriodDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractDurationDef {
code: string;
description: string;
isActive?: boolean;
}
interface ContractCategoryDef {
code: string;
name: string;
icon?: string | null;
color?: string | null;
sortOrder?: number;
isActive?: boolean;
}
interface PdfTemplateDef {
name: string;
description?: string | null;
providerName?: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix?: string | null;
maxPhoneFields?: number | null;
isActive?: boolean;
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
}
/**
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
*/
function readJsonArrays<T>(dir: string): T[] {
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
const result: T[] = [];
for (const f of files) {
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
try {
const data = JSON.parse(content);
if (Array.isArray(data)) {
result.push(...data);
}
} catch (e) {
console.warn(`⚠ Konnte ${f} nicht parsen überspringe.`);
}
}
return result;
}
/**
* Dedupliziert Einträge per unique-Key (letzter Eintrag gewinnt).
*/
function dedupe<T>(items: T[], keyFn: (item: T) => string): T[] {
const map = new Map<string, T>();
for (const item of items) {
map.set(keyFn(item), item);
}
return Array.from(map.values());
}
async function seedProviders() {
const items = dedupe(readJsonArrays<ProviderDef>(path.join(ROOT, 'providers')), (p) => p.name);
if (items.length === 0) {
console.log(' providers/ keine Einträge gefunden');
return;
}
let providerCount = 0;
let tariffCount = 0;
for (const p of items) {
const provider = await prisma.provider.upsert({
where: { name: p.name },
update: {
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
create: {
name: p.name,
portalUrl: p.portalUrl ?? null,
usernameFieldName: p.usernameFieldName ?? null,
passwordFieldName: p.passwordFieldName ?? null,
isActive: p.isActive ?? true,
},
});
providerCount++;
if (p.tariffs && p.tariffs.length > 0) {
for (const t of p.tariffs) {
await prisma.tariff.upsert({
where: { providerId_name: { providerId: provider.id, name: t.name } },
update: { isActive: t.isActive ?? true },
create: {
providerId: provider.id,
name: t.name,
isActive: t.isActive ?? true,
},
});
tariffCount++;
}
}
}
console.log(` ✓ Anbieter: ${providerCount}, Tarife: ${tariffCount}`);
}
async function seedCancellationPeriods() {
const items = dedupe(
readJsonArrays<CancellationPeriodDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
// Nur die relevanten Objekte (CancellationPeriod hat code+description, keine 'name')
const relevant = items.filter((i) => 'code' in i && 'description' in i && !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' cancellation-periods keine Einträge');
return;
}
for (const c of relevant) {
await prisma.cancellationPeriod.upsert({
where: { code: c.code },
update: { description: c.description, isActive: c.isActive ?? true },
create: { code: c.code, description: c.description, isActive: c.isActive ?? true },
});
}
console.log(` ✓ Kündigungsfristen: ${relevant.length}`);
}
async function seedContractDurations() {
const items = dedupe(
readJsonArrays<ContractDurationDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && i.description),
(i) => i.code,
);
const relevant = items.filter((i) => !('name' in i) && !('icon' in i));
if (relevant.length === 0) {
console.log(' contract-durations keine Einträge');
return;
}
for (const d of relevant) {
await prisma.contractDuration.upsert({
where: { code: d.code },
update: { description: d.description, isActive: d.isActive ?? true },
create: { code: d.code, description: d.description, isActive: d.isActive ?? true },
});
}
console.log(` ✓ Laufzeiten: ${relevant.length}`);
}
async function seedContractCategories() {
const items = dedupe(
readJsonArrays<ContractCategoryDef>(path.join(ROOT, 'contract-meta')).filter((i) => i.code && (i as any).name),
(i) => i.code,
);
if (items.length === 0) {
console.log(' contract-categories keine Einträge');
return;
}
for (const c of items) {
await prisma.contractCategory.upsert({
where: { code: c.code },
update: {
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
create: {
code: c.code,
name: c.name,
icon: c.icon ?? null,
color: c.color ?? null,
sortOrder: c.sortOrder ?? 0,
isActive: c.isActive ?? true,
},
});
}
console.log(` ✓ Vertragskategorien: ${items.length}`);
}
async function seedPdfTemplates() {
const items = dedupe(
readJsonArrays<PdfTemplateDef>(path.join(ROOT, 'pdf-templates')),
(t) => t.name,
);
if (items.length === 0) {
console.log(' pdf-templates keine Einträge');
return;
}
// Upload-Verzeichnis sicherstellen
if (!fs.existsSync(PDF_UPLOAD_DIR)) {
fs.mkdirSync(PDF_UPLOAD_DIR, { recursive: true });
}
let count = 0;
let skipped = 0;
for (const t of items) {
const srcPdf = path.join(ROOT, 'pdf-templates', t.pdfFilename);
if (!fs.existsSync(srcPdf)) {
console.warn(` ⚠ PDF fehlt: ${t.pdfFilename} Template "${t.name}" übersprungen`);
skipped++;
continue;
}
// PDF nach uploads/pdf-templates/ kopieren (mit eindeutigem Namen)
const ext = path.extname(t.originalName || t.pdfFilename) || '.pdf';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const destFilename = `seed-${t.name.replace(/[^a-zA-Z0-9]/g, '-')}-${uniqueSuffix}${ext}`;
const destPdf = path.join(PDF_UPLOAD_DIR, destFilename);
const relativePath = `/uploads/pdf-templates/${destFilename}`;
fs.copyFileSync(srcPdf, destPdf);
const fieldMappingJson = JSON.stringify(t.fieldMapping || {});
// Bei existierendem Template: alten Pfad löschen, wenn Neuimport
const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } });
if (existing?.templatePath) {
const oldRel = existing.templatePath.startsWith('/uploads/')
? existing.templatePath.substring('/uploads/'.length)
: existing.templatePath;
const oldAbs = path.join(UPLOADS_ROOT, oldRel);
if (fs.existsSync(oldAbs)) {
try {
fs.unlinkSync(oldAbs);
} catch {
// ignore
}
}
}
await prisma.pdfTemplate.upsert({
where: { name: t.name },
update: {
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
create: {
name: t.name,
description: t.description ?? null,
providerName: t.providerName ?? null,
templatePath: relativePath,
originalName: t.originalName,
fieldMapping: fieldMappingJson,
phoneFieldPrefix: t.phoneFieldPrefix ?? null,
maxPhoneFields: t.maxPhoneFields ?? 8,
isActive: t.isActive ?? true,
},
});
count++;
}
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
}
async function main() {
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
if (!fs.existsSync(ROOT)) {
console.error(`❌ Ordner nicht gefunden: ${ROOT}`);
console.error(' Lege Export-Dateien unter backend/factory-defaults/ ab.');
process.exit(1);
}
await seedProviders();
await seedCancellationPeriods();
await seedContractDurations();
await seedContractCategories();
await seedPdfTemplates();
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
}
main()
.catch((e) => {
console.error('\n❌ Fehler beim Einspielen:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,64 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
import { createAuditLog } from '../services/audit.service.js';
/**
* Factory-Defaults als ZIP exportieren (Download).
*/
export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
try {
const buffer = await factoryDefaultsService.exportFactoryDefaults();
const dateStr = new Date().toISOString().split('T')[0];
const filename = `factory-defaults-${dateStr}.zip`;
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'unknown',
action: 'EXPORT',
resourceType: 'FactoryDefaults',
resourceId: dateStr,
resourceLabel: 'Factory-Defaults exportiert',
endpoint: req.path,
httpMethod: req.method,
ipAddress: req.socket.remoteAddress || 'unknown',
});
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
res.send(buffer);
} catch (error) {
console.error('Fehler beim Factory-Defaults-Export:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Export',
});
}
}
/**
* Kurze Übersicht was exportiert würde (für Frontend, ohne Download).
*/
export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
try {
const data = await factoryDefaultsService.collectFactoryDefaults();
res.json({
success: true,
data: {
counts: {
providers: data.providers.length,
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
cancellationPeriods: data.cancellationPeriods.length,
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length,
},
},
});
} catch (error) {
console.error('Fehler beim Preview:', error);
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}

View File

@ -32,6 +32,7 @@ import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import birthdayRoutes from './routes/birthday.routes.js';
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
@ -83,6 +84,7 @@ app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
// Health check
app.get('/api/health', (req, res) => {

View File

@ -0,0 +1,23 @@
import { Router } from 'express';
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Preview (was wäre im Export drin?)
router.get(
'/preview',
authenticate,
requirePermission('settings:read'),
factoryDefaultsController.previewFactoryDefaults,
);
// Export als ZIP-Download
router.get(
'/export',
authenticate,
requirePermission('settings:update'),
factoryDefaultsController.exportFactoryDefaults,
);
export default router;

View File

@ -0,0 +1,194 @@
/**
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
* Vertragskategorien und PDF-Auftragsvorlagen.
*/
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import prisma from '../lib/prisma.js';
export interface FactoryDefaultsManifest {
version: 1;
exportedAt: string;
counts: {
providers: number;
tariffs: number;
cancellationPeriods: number;
contractDurations: number;
contractCategories: number;
pdfTemplates: number;
};
}
export interface ProviderExport {
name: string;
portalUrl: string | null;
usernameFieldName: string | null;
passwordFieldName: string | null;
isActive: boolean;
tariffs: { name: string; isActive: boolean }[];
}
export interface PdfTemplateExport {
name: string;
description: string | null;
providerName: string | null;
originalName: string;
fieldMapping: any;
phoneFieldPrefix: string | null;
maxPhoneFields: number | null;
isActive: boolean;
// Datei wird separat im ZIP abgelegt unter pdf-templates/<name>.pdf
pdfFilename: string;
}
/**
* Sammelt alle Katalog-Daten aus der DB.
*/
export async function collectFactoryDefaults() {
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
await Promise.all([
prisma.provider.findMany({
include: { tariffs: { select: { name: true, isActive: true } } },
orderBy: { name: 'asc' },
}),
prisma.cancellationPeriod.findMany({ orderBy: { code: 'asc' } }),
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
]);
return {
providers: providers.map<ProviderExport>((p) => ({
name: p.name,
portalUrl: p.portalUrl,
usernameFieldName: p.usernameFieldName,
passwordFieldName: p.passwordFieldName,
isActive: p.isActive,
tariffs: p.tariffs.map((t) => ({ name: t.name, isActive: t.isActive })),
})),
cancellationPeriods: cancellationPeriods.map((c) => ({
code: c.code,
description: c.description,
isActive: c.isActive,
})),
contractDurations: contractDurations.map((d) => ({
code: d.code,
description: d.description,
isActive: d.isActive,
})),
contractCategories: contractCategories.map((c) => ({
code: c.code,
name: c.name,
icon: c.icon,
color: c.color,
sortOrder: c.sortOrder,
isActive: c.isActive,
})),
pdfTemplates: pdfTemplates.map<PdfTemplateExport>((t) => {
// fieldMapping ist im DB als String abgelegt zu Objekt parsen
let fieldMapping: any = {};
try {
fieldMapping = JSON.parse(t.fieldMapping);
} catch {
fieldMapping = {};
}
return {
name: t.name,
description: t.description,
providerName: t.providerName,
originalName: t.originalName,
fieldMapping,
phoneFieldPrefix: t.phoneFieldPrefix,
maxPhoneFields: t.maxPhoneFields,
isActive: t.isActive,
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
};
}),
};
}
function sanitizeFilename(name: string): string {
return name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_').replace(/_+/g, '_');
}
/**
* Erstellt ein ZIP-Archiv mit allen Factory-Defaults.
* Die PDF-Dateien werden aus dem uploads-Verzeichnis gelesen.
*/
export async function exportFactoryDefaults(): Promise<Buffer> {
const data = await collectFactoryDefaults();
const manifest: FactoryDefaultsManifest = {
version: 1,
exportedAt: new Date().toISOString(),
counts: {
providers: data.providers.length,
tariffs: data.providers.reduce((sum, p) => sum + p.tariffs.length, 0),
cancellationPeriods: data.cancellationPeriods.length,
contractDurations: data.contractDurations.length,
contractCategories: data.contractCategories.length,
pdfTemplates: data.pdfTemplates.length,
},
};
return new Promise<Buffer>((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
archive.on('end', () => resolve(Buffer.concat(chunks)));
archive.on('error', reject);
// JSON-Dateien vorbereiten
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
archive.append(JSON.stringify(data.providers, null, 2), {
name: 'providers/providers.json',
});
archive.append(JSON.stringify(data.cancellationPeriods, null, 2), {
name: 'contract-meta/cancellation-periods.json',
});
archive.append(JSON.stringify(data.contractDurations, null, 2), {
name: 'contract-meta/contract-durations.json',
});
archive.append(JSON.stringify(data.contractCategories, null, 2), {
name: 'contract-meta/contract-categories.json',
});
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
name: 'pdf-templates/pdf-templates.json',
});
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
const uploadsRoot = path.join(process.cwd(), 'uploads');
(async () => {
try {
const templateRows = await prisma.pdfTemplate.findMany({
select: { name: true, templatePath: true },
});
const pathByName = new Map(templateRows.map((r) => [r.name, r.templatePath]));
for (const t of data.pdfTemplates) {
const storedPath = pathByName.get(t.name);
if (!storedPath) continue;
const relPath = storedPath.startsWith('/uploads/')
? storedPath.substring('/uploads/'.length)
: storedPath;
const absPath = path.join(uploadsRoot, relPath);
if (fs.existsSync(absPath)) {
archive.file(absPath, { name: `pdf-templates/${t.pdfFilename}` });
} else {
console.warn(`[factoryDefaults] PDF-Datei nicht gefunden: ${absPath}`);
}
}
archive.finalize();
} catch (err) {
reject(err);
}
})();
});
}

View File

@ -10,48 +10,18 @@
### Security System testen
### Factory-Defaults: Export + Import von Lieferanten & Formularvorlagen
**Ziel:** Einmal gepflegte Stammdaten (Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
PDF-Auftragsvorlagen) sollen sich exportieren und in andere Installationen oder
als Factory-Default beim Initialisieren wieder einspielen lassen.
**⚠️ Wichtig Abgrenzung:**
- **KEINE Kundendaten, Verträge, Dokumente, Emails, SMTP-Einstellungen o.ä.**
- NUR reine Stammdaten-Kataloge ohne Bezug zu Kunden/Firma
- Für komplette Backups (inkl. Kundendaten, Dokumente, Einstellungen) gibt es bereits
den separaten Backup-Export
**Konzept:**
- Ordner: `backend/factory-defaults/` (gitignoriert für echte Firmen-Exports,
aber mit `.gitkeep` und optional einem öffentlich teilbaren `seeds/`-Unterordner)
Struktur:
```
backend/factory-defaults/
providers/ → providers.json (Anbieter + Tarife)
contract-meta/ → cancellationPeriods.json, contractDurations.json, contractCategories.json
pdf-templates/ → {templateName}.json + dazugehörige PDF-Datei
```
- **Export:** Button in Einstellungen → "Factory-Defaults exportieren"
- Erstellt ZIP mit allen JSON-Dateien + PDF-Vorlagen
- **Nur** Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien,
PDF-Auftragsvorlagen (inkl. PDF-Dateien + Feldzuordnungen)
- User entpackt das ZIP nach `backend/factory-defaults/`
- **Import-Script:** `backend/scripts/seed-factory-defaults.ts`
- Liest alle Dateien aus `backend/factory-defaults/`
- Bei mehreren Dateien: automatisches Mergen (per unique name)
- Nutzt Prisma `upsert` → idempotent, kann mehrfach ausgeführt werden
- PDF-Dateien werden nach `uploads/` kopiert + Pfade in DB aktualisiert
- Aufruf: `npm run seed:defaults`
- **Init-DB Integration:** Script wird optional beim Setup mit ausgeführt
wenn der Ordner existiert
---
## ✅ Erledigt
- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
- Enthält: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
- Enthält NICHT: Kundendaten, Verträge, Dokumente, Emails, Einstellungen (dafür gibt es den Datenbank-Backup)
- Neue Einstellungsseite „Factory-Defaults" mit Übersicht (Anzahl pro Kategorie) und Export-Button
- Export: ZIP mit manifest.json + Kategorie-JSONs + PDF-Dateien, Download über Browser
- Import-Script: `npm run seed:defaults` liest `backend/factory-defaults/`, merged mehrere JSONs pro Kategorie, upsertet idempotent + kopiert PDFs in uploads/
- Ordner `backend/factory-defaults/` gitignoriert (außer .gitkeep + README), damit firmen-spezifische Kataloge nicht ins Repo kommen
- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen**
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) wie bisher

View File

@ -25,6 +25,7 @@ import PortalSettings from './pages/settings/PortalSettings';
import DeadlineSettings from './pages/settings/DeadlineSettings';
import EmailProviders from './pages/settings/EmailProviders';
import DatabaseBackup from './pages/settings/DatabaseBackup';
import FactoryDefaults from './pages/settings/FactoryDefaults';
import AuditLogs from './pages/settings/AuditLogs';
import EmailLogPage from './pages/settings/EmailLogs';
import GDPRDashboard from './pages/settings/GDPRDashboard';
@ -192,6 +193,7 @@ function App() {
<Route path="settings/deadlines" element={<DeadlineSettings />} />
<Route path="settings/email-providers" element={<EmailProviders />} />
<Route path="settings/database-backup" element={<DatabaseBackup />} />
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
<Route path="settings/audit-logs" element={<AuditLogs />} />
<Route path="settings/email-logs" element={<EmailLogPage />} />
<Route path="settings/gdpr" element={<GDPRDashboard />} />

View File

@ -1,7 +1,7 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Card from '../components/ui/Card';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit } from 'lucide-react';
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, FileText, FileEdit, PackageCheck } from 'lucide-react';
export default function Settings() {
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
@ -177,6 +177,23 @@ export default function Settings() {
</div>
</div>
</Link>
<Link
to="/settings/factory-defaults"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
>
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
<PackageCheck className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
Factory-Defaults
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</h3>
<p className="text-sm text-gray-500 mt-1">Stammdaten-Kataloge (Anbieter, Tarife, PDF-Vorlagen) exportieren ohne Kundendaten.</p>
</div>
</div>
</Link>
{hasPermission('audit:read') && (
<Link
to="/settings/audit-logs"

View File

@ -0,0 +1,225 @@
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>
);
}