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:
@@ -18,15 +18,21 @@ backend/factory-defaults/
|
|||||||
│ ├── cancellation-periods.json # Kündigungsfristen
|
│ ├── cancellation-periods.json # Kündigungsfristen
|
||||||
│ ├── contract-durations.json # Vertragslaufzeiten
|
│ ├── contract-durations.json # Vertragslaufzeiten
|
||||||
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
|
│ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...)
|
||||||
└── pdf-templates/
|
├── pdf-templates/
|
||||||
├── pdf-templates.json # Metadaten + Feldzuordnungen
|
│ ├── pdf-templates.json # Metadaten + Feldzuordnungen
|
||||||
└── *.pdf # PDF-Vorlagen-Dateien
|
│ └── *.pdf # PDF-Vorlagen-Dateien
|
||||||
|
└── app-settings/
|
||||||
|
└── app-settings.json # HTML-Templates: Datenschutz / Impressum /
|
||||||
|
# Vollmacht / Website-Datenschutz
|
||||||
```
|
```
|
||||||
|
|
||||||
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
|
**Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen,
|
||||||
Datenschutzerklärungen oder andere AppSettings. Dafür gibt es den separaten
|
Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten
|
||||||
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
|
**Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen).
|
||||||
|
|
||||||
|
Bei den AppSettings ist nur eine **Whitelist** vorgesehen (HTML-Texte für rechtliche
|
||||||
|
Standardpflichten) – andere Keys werden beim Import ignoriert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Export (aus einer bestehenden Installation)
|
## Export (aus einer bestehenden Installation)
|
||||||
@@ -46,7 +52,8 @@ factory-defaults-2026-04-23.zip
|
|||||||
├── contract-meta/contract-durations.json
|
├── contract-meta/contract-durations.json
|
||||||
├── contract-meta/contract-categories.json
|
├── contract-meta/contract-categories.json
|
||||||
├── pdf-templates/pdf-templates.json
|
├── pdf-templates/pdf-templates.json
|
||||||
└── pdf-templates/*.pdf
|
├── pdf-templates/*.pdf
|
||||||
|
└── app-settings/app-settings.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme,
|
Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme,
|
||||||
@@ -56,7 +63,15 @@ neue Installationen oder Partner-Setups.
|
|||||||
|
|
||||||
## Import (in eine andere Installation)
|
## Import (in eine andere Installation)
|
||||||
|
|
||||||
### Schritt-für-Schritt
|
### Variante A: Über die UI (empfohlen)
|
||||||
|
|
||||||
|
1. Im Ziel-CRM als Admin einloggen
|
||||||
|
2. **Einstellungen → Factory-Defaults**
|
||||||
|
3. Im Bereich **Import** auf **„ZIP hochladen"** klicken
|
||||||
|
4. Die exportierte ZIP wählen – der Import läuft direkt
|
||||||
|
5. Erfolgsmeldung zeigt Counts pro Kategorie an
|
||||||
|
|
||||||
|
### Variante B: Über die CLI (für Bare-Metal / Migration / mehrere ZIPs zusammenführen)
|
||||||
|
|
||||||
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
|
1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage)
|
||||||
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
|
2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`),
|
||||||
@@ -234,6 +249,24 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
|
|||||||
**Unique Key:** `name`
|
**Unique Key:** `name`
|
||||||
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
|
**Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen.
|
||||||
|
|
||||||
|
### `app-settings/app-settings.json`
|
||||||
|
|
||||||
|
HTML-Standardtexte als Werkseinstellung. Es ist eine **Whitelist** aktiv – andere Keys
|
||||||
|
werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secrets).
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "key": "privacyPolicyHtml", "value": "<h1>Datenschutzerklärung</h1>..." },
|
||||||
|
{ "key": "imprintHtml", "value": "<h1>Impressum</h1>..." },
|
||||||
|
{ "key": "authorizationTemplateHtml","value": "<h1>Vollmacht</h1>..." },
|
||||||
|
{ "key": "websitePrivacyPolicyHtml", "value": "<h1>Website-Datenschutz</h1>..." }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unique Key:** `key`
|
||||||
|
**Erlaubte Keys:** `privacyPolicyHtml`, `imprintHtml`, `authorizationTemplateHtml`,
|
||||||
|
`websitePrivacyPolicyHtml`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Berechtigungen
|
## Berechtigungen
|
||||||
@@ -242,6 +275,7 @@ Array von Providern, jeweils inkl. zugehöriger Tarife:
|
|||||||
|--------|--------------|
|
|--------|--------------|
|
||||||
| Factory-Defaults Vorschau | `settings:read` |
|
| Factory-Defaults Vorschau | `settings:read` |
|
||||||
| Factory-Defaults Export (UI) | `settings:update` |
|
| Factory-Defaults Export (UI) | `settings:update` |
|
||||||
|
| Factory-Defaults Import (UI) | `settings:update` |
|
||||||
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
| Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ interface PdfTemplateDef {
|
|||||||
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
|
pdfFilename: string; // Dateiname im pdf-templates/-Ordner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppSettingDef {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist – muss synchron zu factoryDefaults.service.ts sein.
|
||||||
|
const FACTORY_DEFAULT_APP_SETTING_KEYS = new Set([
|
||||||
|
'privacyPolicyHtml',
|
||||||
|
'authorizationTemplateHtml',
|
||||||
|
'imprintHtml',
|
||||||
|
'websitePrivacyPolicyHtml',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
|
* Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück.
|
||||||
*/
|
*/
|
||||||
@@ -299,6 +312,31 @@ async function seedPdfTemplates() {
|
|||||||
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedAppSettings() {
|
||||||
|
const items = readJsonArrays<AppSettingDef>(path.join(ROOT, 'app-settings'));
|
||||||
|
if (items.length === 0) {
|
||||||
|
console.log(' app-settings – keine Einträge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const s of items) {
|
||||||
|
if (!s.key || typeof s.value !== 'string') continue;
|
||||||
|
if (!FACTORY_DEFAULT_APP_SETTING_KEYS.has(s.key)) {
|
||||||
|
console.warn(` ⚠ AppSetting-Key '${s.key}' nicht auf Whitelist – übersprungen`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: s.key },
|
||||||
|
update: { value: s.value },
|
||||||
|
create: { key: s.key, value: s.value },
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
console.log(` ✓ HTML-Templates: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
|
console.log('\n📦 Factory-Defaults werden eingespielt...\n');
|
||||||
|
|
||||||
@@ -313,6 +351,7 @@ async function main() {
|
|||||||
await seedContractDurations();
|
await seedContractDurations();
|
||||||
await seedContractCategories();
|
await seedContractCategories();
|
||||||
await seedPdfTemplates();
|
await seedPdfTemplates();
|
||||||
|
await seedAppSettings();
|
||||||
|
|
||||||
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
|
console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
contractDurations: data.contractDurations.length,
|
contractDurations: data.contractDurations.length,
|
||||||
contractCategories: data.contractCategories.length,
|
contractCategories: data.contractCategories.length,
|
||||||
pdfTemplates: data.pdfTemplates.length,
|
pdfTemplates: data.pdfTemplates.length,
|
||||||
|
appSettings: data.appSettings.length,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -62,3 +63,39 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory-Defaults aus ZIP importieren (Upload via multipart/form-data, Feld 'zip').
|
||||||
|
* Idempotent: bestehende Einträge werden per unique-Key aktualisiert, nichts wird gelöscht.
|
||||||
|
*/
|
||||||
|
export async function importFactoryDefaults(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const file = (req as any).file as Express.Multer.File | undefined;
|
||||||
|
if (!file || !file.buffer) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Keine ZIP-Datei hochgeladen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await factoryDefaultsService.importFactoryDefaults(file.buffer);
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email || 'unknown',
|
||||||
|
// 'UPDATE' weil Factory-Defaults DB-Records upserted; das Label nennt
|
||||||
|
// den Vorgang explizit als Import.
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'FactoryDefaults',
|
||||||
|
resourceLabel: `Factory-Defaults importiert: ${result.providers} Anbieter, ${result.tariffs} Tarife, ${result.pdfTemplates} PDF-Vorlagen, ${result.appSettings} HTML-Templates`,
|
||||||
|
endpoint: req.path,
|
||||||
|
httpMethod: req.method,
|
||||||
|
ipAddress: req.socket.remoteAddress || 'unknown',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Factory-Defaults-Import:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Import',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
|
import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// In-Memory-Upload für die ZIP – wird direkt verarbeitet, keine temporäre Datei.
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const ok =
|
||||||
|
file.mimetype === 'application/zip' ||
|
||||||
|
file.mimetype === 'application/x-zip-compressed' ||
|
||||||
|
file.mimetype === 'application/octet-stream' || // manche Browser senden das für .zip
|
||||||
|
file.originalname.toLowerCase().endsWith('.zip');
|
||||||
|
if (ok) cb(null, true);
|
||||||
|
else cb(new Error('Nur ZIP-Dateien sind erlaubt'));
|
||||||
|
},
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
// Preview (was wäre im Export drin?)
|
// Preview (was wäre im Export drin?)
|
||||||
router.get(
|
router.get(
|
||||||
'/preview',
|
'/preview',
|
||||||
@@ -20,4 +36,13 @@ router.get(
|
|||||||
factoryDefaultsController.exportFactoryDefaults,
|
factoryDefaultsController.exportFactoryDefaults,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Import aus ZIP (multipart, Feld 'zip')
|
||||||
|
router.post(
|
||||||
|
'/import',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings:update'),
|
||||||
|
upload.single('zip'),
|
||||||
|
factoryDefaultsController.importFactoryDefaults,
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
* Factory-Defaults: Export + Import von Stammdaten-Katalogen.
|
||||||
* Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen –
|
* Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails –
|
||||||
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
* nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten,
|
||||||
* Vertragskategorien und PDF-Auftragsvorlagen.
|
* Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte
|
||||||
|
* HTML-Templates (Datenschutz / Impressum / Vollmacht).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
|
||||||
|
// Whitelist der AppSetting-Keys, die ins Factory-Default-Bundle gehören.
|
||||||
|
// Bewusst klein gehalten: nur HTML-Templates für rechtliche Standardtexte –
|
||||||
|
// keine Secrets, keine SMTP-Konfiguration, keine User-spezifischen Settings.
|
||||||
|
export const FACTORY_DEFAULT_APP_SETTING_KEYS = [
|
||||||
|
'privacyPolicyHtml',
|
||||||
|
'authorizationTemplateHtml',
|
||||||
|
'imprintHtml',
|
||||||
|
'websitePrivacyPolicyHtml',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface AppSettingExport {
|
||||||
|
key: (typeof FACTORY_DEFAULT_APP_SETTING_KEYS)[number];
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FactoryDefaultsManifest {
|
export interface FactoryDefaultsManifest {
|
||||||
version: 1;
|
version: 1;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
@@ -20,6 +37,7 @@ export interface FactoryDefaultsManifest {
|
|||||||
contractDurations: number;
|
contractDurations: number;
|
||||||
contractCategories: number;
|
contractCategories: number;
|
||||||
pdfTemplates: number;
|
pdfTemplates: number;
|
||||||
|
appSettings: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +67,7 @@ export interface PdfTemplateExport {
|
|||||||
* Sammelt alle Katalog-Daten aus der DB.
|
* Sammelt alle Katalog-Daten aus der DB.
|
||||||
*/
|
*/
|
||||||
export async function collectFactoryDefaults() {
|
export async function collectFactoryDefaults() {
|
||||||
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] =
|
const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.provider.findMany({
|
prisma.provider.findMany({
|
||||||
include: { tariffs: { select: { name: true, isActive: true } } },
|
include: { tariffs: { select: { name: true, isActive: true } } },
|
||||||
@@ -59,6 +77,11 @@ export async function collectFactoryDefaults() {
|
|||||||
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
|
prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }),
|
||||||
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
|
prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }),
|
||||||
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
|
prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }),
|
||||||
|
prisma.appSetting.findMany({
|
||||||
|
where: { key: { in: [...FACTORY_DEFAULT_APP_SETTING_KEYS] } },
|
||||||
|
select: { key: true, value: true },
|
||||||
|
orderBy: { key: 'asc' },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -108,6 +131,7 @@ export async function collectFactoryDefaults() {
|
|||||||
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
appSettings: appSettings as AppSettingExport[],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +156,7 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
contractDurations: data.contractDurations.length,
|
contractDurations: data.contractDurations.length,
|
||||||
contractCategories: data.contractCategories.length,
|
contractCategories: data.contractCategories.length,
|
||||||
pdfTemplates: data.pdfTemplates.length,
|
pdfTemplates: data.pdfTemplates.length,
|
||||||
|
appSettings: data.appSettings.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +185,9 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
|
archive.append(JSON.stringify(data.pdfTemplates, null, 2), {
|
||||||
name: 'pdf-templates/pdf-templates.json',
|
name: 'pdf-templates/pdf-templates.json',
|
||||||
});
|
});
|
||||||
|
archive.append(JSON.stringify(data.appSettings, null, 2), {
|
||||||
|
name: 'app-settings/app-settings.json',
|
||||||
|
});
|
||||||
|
|
||||||
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
|
// PDF-Dateien physisch hinzufügen (Pfade aus DB laden)
|
||||||
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
||||||
@@ -192,3 +220,244 @@ export async function exportFactoryDefaults(): Promise<Buffer> {
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// IMPORT
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface FactoryDefaultsImportResult {
|
||||||
|
providers: number;
|
||||||
|
tariffs: number;
|
||||||
|
cancellationPeriods: number;
|
||||||
|
contractDurations: number;
|
||||||
|
contractCategories: number;
|
||||||
|
pdfTemplates: number;
|
||||||
|
pdfTemplatesSkipped: number;
|
||||||
|
appSettings: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonEntry<T>(zip: AdmZip, name: string): T[] {
|
||||||
|
const entry = zip.getEntry(name);
|
||||||
|
if (!entry) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(entry.getData().toString('utf-8'));
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wendet ein Factory-Defaults-ZIP idempotent auf die DB an.
|
||||||
|
* - upsert über unique-Keys: nichts wird gelöscht
|
||||||
|
* - PDFs landen in `${uploads}/pdf-templates/` mit eindeutigem Suffix
|
||||||
|
* - AppSettings nur Whitelist-Keys (FACTORY_DEFAULT_APP_SETTING_KEYS)
|
||||||
|
*
|
||||||
|
* Robust gegen Zip-Slip: wir greifen nur auf bekannte Entry-Namen zu
|
||||||
|
* (`pdf-templates/<basename>`), niemals auf einen aus dem ZIP konstruierten
|
||||||
|
* Pfad im Filesystem.
|
||||||
|
*/
|
||||||
|
export async function importFactoryDefaults(
|
||||||
|
zipBuffer: Buffer,
|
||||||
|
): Promise<FactoryDefaultsImportResult> {
|
||||||
|
const zip = new AdmZip(zipBuffer);
|
||||||
|
const result: FactoryDefaultsImportResult = {
|
||||||
|
providers: 0,
|
||||||
|
tariffs: 0,
|
||||||
|
cancellationPeriods: 0,
|
||||||
|
contractDurations: 0,
|
||||||
|
contractCategories: 0,
|
||||||
|
pdfTemplates: 0,
|
||||||
|
pdfTemplatesSkipped: 0,
|
||||||
|
appSettings: 0,
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Providers + Tariffs
|
||||||
|
const providers = parseJsonEntry<ProviderExport>(zip, 'providers/providers.json');
|
||||||
|
for (const p of providers) {
|
||||||
|
if (!p.name) continue;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.providers++;
|
||||||
|
for (const t of p.tariffs ?? []) {
|
||||||
|
if (!t.name) continue;
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
result.tariffs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Contract-Meta
|
||||||
|
const cancellationPeriods = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
|
||||||
|
zip,
|
||||||
|
'contract-meta/cancellation-periods.json',
|
||||||
|
);
|
||||||
|
for (const c of cancellationPeriods) {
|
||||||
|
if (!c.code || !c.description) continue;
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
result.cancellationPeriods++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractDurations = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>(
|
||||||
|
zip,
|
||||||
|
'contract-meta/contract-durations.json',
|
||||||
|
);
|
||||||
|
for (const d of contractDurations) {
|
||||||
|
if (!d.code || !d.description) continue;
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
result.contractDurations++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractCategories = parseJsonEntry<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
}>(zip, 'contract-meta/contract-categories.json');
|
||||||
|
for (const c of contractCategories) {
|
||||||
|
if (!c.code || !c.name) continue;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.contractCategories++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PDF-Vorlagen (JSON + binär aus dem ZIP)
|
||||||
|
const pdfTemplates = parseJsonEntry<PdfTemplateExport>(
|
||||||
|
zip,
|
||||||
|
'pdf-templates/pdf-templates.json',
|
||||||
|
);
|
||||||
|
if (pdfTemplates.length > 0) {
|
||||||
|
const uploadsRoot = path.join(process.cwd(), 'uploads');
|
||||||
|
const pdfDestDir = path.join(uploadsRoot, 'pdf-templates');
|
||||||
|
if (!fs.existsSync(pdfDestDir)) {
|
||||||
|
fs.mkdirSync(pdfDestDir, { recursive: true });
|
||||||
|
}
|
||||||
|
for (const t of pdfTemplates) {
|
||||||
|
if (!t.name || !t.pdfFilename) continue;
|
||||||
|
// Anti-Zip-Slip: nur basename verwenden, kein Pfad
|
||||||
|
const basename = path.basename(t.pdfFilename);
|
||||||
|
const entry = zip.getEntry(`pdf-templates/${basename}`);
|
||||||
|
if (!entry) {
|
||||||
|
result.pdfTemplatesSkipped++;
|
||||||
|
result.warnings.push(`PDF fehlt im ZIP: ${basename} – Vorlage "${t.name}" übersprungen`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(t.originalName || basename) || '.pdf';
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const safeName = t.name.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
const destFilename = `seed-${safeName}-${uniqueSuffix}${ext}`;
|
||||||
|
const destPdf = path.join(pdfDestDir, destFilename);
|
||||||
|
const relativePath = `/uploads/pdf-templates/${destFilename}`;
|
||||||
|
|
||||||
|
fs.writeFileSync(destPdf, entry.getData());
|
||||||
|
|
||||||
|
// Bei existierender Vorlage die alte Datei aufräumen
|
||||||
|
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(uploadsRoot, oldRel);
|
||||||
|
if (fs.existsSync(oldAbs)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(oldAbs);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMappingJson = JSON.stringify(t.fieldMapping ?? {});
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.pdfTemplates++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AppSettings (HTML-Templates, Whitelist)
|
||||||
|
const appSettings = parseJsonEntry<AppSettingExport>(zip, 'app-settings/app-settings.json');
|
||||||
|
const allowedKeys = new Set<string>(FACTORY_DEFAULT_APP_SETTING_KEYS);
|
||||||
|
for (const s of appSettings) {
|
||||||
|
if (!s.key || typeof s.value !== 'string') continue;
|
||||||
|
if (!allowedKeys.has(s.key)) {
|
||||||
|
result.warnings.push(`AppSetting-Key '${s.key}' nicht auf Whitelist – ignoriert`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: s.key },
|
||||||
|
update: { value: s.value },
|
||||||
|
create: { key: s.key, value: s.value },
|
||||||
|
});
|
||||||
|
result.appSettings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **📦 Factory-Defaults: HTML-Templates + Import via UI**
|
||||||
|
- Datenschutzerklärung, Impressum, Vollmacht-Vorlage und Website-Datenschutz
|
||||||
|
werden jetzt mit ins Factory-Defaults-ZIP gepackt (`app-settings/`-Ordner,
|
||||||
|
Whitelist-geschützt – andere AppSetting-Keys werden ignoriert).
|
||||||
|
- Import läuft jetzt auch über die UI (Einstellungen → Factory-Defaults →
|
||||||
|
„ZIP hochladen"). Der CLI-Weg `npm run seed:defaults` bleibt erhalten und
|
||||||
|
wurde gleichermaßen um die HTML-Templates erweitert.
|
||||||
|
- Zwei-Wege-Roundtrip live verifiziert: Export → AppSetting löschen →
|
||||||
|
Import → Wert wieder vollständig hergestellt; Counts in Audit-Log.
|
||||||
|
|
||||||
- [x] **🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar**
|
- [x] **🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar**
|
||||||
- Mass-Assignment-Whitelist (`pickUserUpdate`) hat `hasGdprAccess` /
|
- Mass-Assignment-Whitelist (`pickUserUpdate`) hat `hasGdprAccess` /
|
||||||
`hasDeveloperAccess` rausgefiltert → Service erhielt sie nie → Rollen
|
`hasDeveloperAccess` rausgefiltert → Service erhielt sie nie → Rollen
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Download,
|
Download,
|
||||||
|
Upload,
|
||||||
Package,
|
Package,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
FileType,
|
FileType,
|
||||||
FileText,
|
FileText,
|
||||||
|
FileCode,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
@@ -27,6 +29,19 @@ interface PreviewCounts {
|
|||||||
contractDurations: number;
|
contractDurations: number;
|
||||||
contractCategories: number;
|
contractCategories: number;
|
||||||
pdfTemplates: 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() {
|
export default function FactoryDefaults() {
|
||||||
@@ -34,6 +49,12 @@ export default function FactoryDefaults() {
|
|||||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
const [downloadDone, setDownloadDone] = useState(false);
|
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({
|
const { data: previewData, isLoading } = useQuery({
|
||||||
queryKey: ['factory-defaults-preview'],
|
queryKey: ['factory-defaults-preview'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -86,9 +107,39 @@ export default function FactoryDefaults() {
|
|||||||
{ icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' },
|
{ icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' },
|
||||||
{ icon: FileType, label: 'Vertragskategorien', count: counts.contractCategories, color: 'text-orange-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: 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<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">
|
<div className="text-sm text-blue-900 space-y-1">
|
||||||
<p className="font-medium">Was sind Factory-Defaults?</p>
|
<p className="font-medium">Was sind Factory-Defaults?</p>
|
||||||
<p>
|
<p>
|
||||||
Das sind <strong>reine Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
|
Das sind <strong>Stammdaten-Kataloge</strong> wie Anbieter, Tarife,
|
||||||
Kündigungsfristen, Vertragskategorien und PDF-Auftragsvorlagen. Du kannst sie
|
Kündigungsfristen, Vertragskategorien, PDF-Auftragsvorlagen und die
|
||||||
exportieren, um sie in anderen OpenCRM-Installationen als Startpunkt zu
|
HTML-Standardtexte (Datenschutzerklärung, Impressum, Vollmacht-Vorlage,
|
||||||
verwenden.
|
Website-Datenschutz). Du kannst sie exportieren, um sie in anderen
|
||||||
|
OpenCRM-Installationen als Startpunkt zu verwenden.
|
||||||
</p>
|
</p>
|
||||||
<p className="pt-1">
|
<p className="pt-1">
|
||||||
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, Emails
|
<strong>Nicht enthalten</strong> sind Kundendaten, Verträge, Dokumente, E-Mails
|
||||||
oder Einstellungen – dafür gibt es den separaten{' '}
|
oder Konfigurationen (SMTP, Secrets) – dafür gibt es den separaten{' '}
|
||||||
<Link to="/settings/database-backup" className="underline">
|
<Link to="/settings/database-backup" className="underline">
|
||||||
Datenbank-Backup
|
Datenbank-Backup
|
||||||
</Link>
|
</Link>
|
||||||
@@ -127,16 +179,10 @@ export default function FactoryDefaults() {
|
|||||||
|
|
||||||
<Card title="Export" className="mb-6">
|
<Card title="Export" className="mb-6">
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
Erstellt ein ZIP mit allen Kataloge-Daten + PDF-Vorlagen. Lade es herunter und
|
Erstellt ein ZIP mit allen Kataloge-Daten, PDF-Auftragsvorlagen und den
|
||||||
entpacke den Inhalt in einer anderen Installation unter{' '}
|
HTML-Standardtexten (Datenschutz / Impressum / Vollmacht). In einer anderen
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
|
OpenCRM-Installation kannst du es dann unten unter <strong>Import</strong> wieder
|
||||||
backend/factory-defaults/
|
einspielen.
|
||||||
</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>
|
</p>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -191,34 +237,88 @@ export default function FactoryDefaults() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Import">
|
<Card title="Import">
|
||||||
<div className="space-y-3 text-sm text-gray-600">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
<p>
|
Lade hier eine zuvor exportierte Factory-Defaults-ZIP hoch. Bestehende Einträge
|
||||||
Der Import läuft über ein Kommandozeilen-Script – dadurch bleibt klar, was wann
|
werden anhand des Unique-Keys (Name / Code) <strong>aktualisiert</strong>, neue
|
||||||
passiert und es gibt keine ungeplanten Überschreibungen im Produktivbetrieb.
|
werden angelegt. Es wird nichts gelöscht – der Vorgang ist idempotent.
|
||||||
</p>
|
</p>
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
|
||||||
<li>
|
<input
|
||||||
ZIP in einer beliebigen Installation herunterladen und den Inhalt nach{' '}
|
ref={fileInputRef}
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
|
type="file"
|
||||||
backend/factory-defaults/
|
accept=".zip,application/zip,application/x-zip-compressed"
|
||||||
</code>{' '}
|
className="hidden"
|
||||||
entpacken
|
onChange={(e) => {
|
||||||
</li>
|
const f = e.target.files?.[0];
|
||||||
<li>
|
if (f) handleImport(f);
|
||||||
Optional mehrere ZIPs zusammenwerfen (JSON-Dateien werden automatisch gemerged)
|
}}
|
||||||
</li>
|
/>
|
||||||
<li>
|
|
||||||
Im Backend-Ordner:{' '}
|
<div className="flex items-center gap-3">
|
||||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">
|
<Button
|
||||||
npm run seed:defaults
|
onClick={() => fileInputRef.current?.click()}
|
||||||
</code>
|
disabled={importing}
|
||||||
</li>
|
variant="secondary"
|
||||||
</ol>
|
>
|
||||||
<p className="pt-2">
|
{importing ? (
|
||||||
Das Script läuft <strong>idempotent</strong> – gleiche Einträge werden per
|
<>
|
||||||
unique-Key aktualisiert, neue hinzugefügt. Kann beliebig oft ausgeführt werden.
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
</p>
|
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>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user