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:
parent
b78afce43c
commit
60dc98e265
|
|
@ -17,6 +17,11 @@ prisma/backups/*
|
||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Factory Defaults (firmen-spezifische Kataloge, bleiben lokal)
|
||||||
|
factory-defaults/*
|
||||||
|
!factory-defaults/.gitkeep
|
||||||
|
!factory-defaults/README.md
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:backup": "tsx prisma/backup-data.ts",
|
"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": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ import consentPublicRoutes from './routes/consent-public.routes.js';
|
||||||
import emailLogRoutes from './routes/emailLog.routes.js';
|
import emailLogRoutes from './routes/emailLog.routes.js';
|
||||||
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||||
import birthdayRoutes from './routes/birthday.routes.js';
|
import birthdayRoutes from './routes/birthday.routes.js';
|
||||||
|
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.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/email-logs', emailLogRoutes);
|
||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
app.use('/api/birthdays', birthdayRoutes);
|
||||||
|
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -10,48 +10,18 @@
|
||||||
|
|
||||||
### Security System testen
|
### 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
|
## ✅ 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**
|
- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen**
|
||||||
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
|
- Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
|
||||||
1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) – wie bisher
|
1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) – wie bisher
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import PortalSettings from './pages/settings/PortalSettings';
|
||||||
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
||||||
import EmailProviders from './pages/settings/EmailProviders';
|
import EmailProviders from './pages/settings/EmailProviders';
|
||||||
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||||
|
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
||||||
import AuditLogs from './pages/settings/AuditLogs';
|
import AuditLogs from './pages/settings/AuditLogs';
|
||||||
import EmailLogPage from './pages/settings/EmailLogs';
|
import EmailLogPage from './pages/settings/EmailLogs';
|
||||||
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
import GDPRDashboard from './pages/settings/GDPRDashboard';
|
||||||
|
|
@ -192,6 +193,7 @@ function App() {
|
||||||
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
||||||
<Route path="settings/email-providers" element={<EmailProviders />} />
|
<Route path="settings/email-providers" element={<EmailProviders />} />
|
||||||
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
||||||
|
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
||||||
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
<Route path="settings/audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
<Route path="settings/email-logs" element={<EmailLogPage />} />
|
||||||
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
<Route path="settings/gdpr" element={<GDPRDashboard />} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Card from '../components/ui/Card';
|
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() {
|
export default function Settings() {
|
||||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||||
|
|
@ -177,6 +177,23 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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') && (
|
{hasPermission('audit:read') && (
|
||||||
<Link
|
<Link
|
||||||
to="/settings/audit-logs"
|
to="/settings/audit-logs"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue