From 541339879c12789b6c9faf6d84ea08282a0ca20c Mon Sep 17 00:00:00 2001 From: Maaxxs <61059039+MaaxxsDev@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:09:21 +0200 Subject: [PATCH] Let admins choose which data to import instead of all-or-nothing Import always replaced everything - brands, packages, services, FAQs, gallery - in one irreversible sweep, even if you only wanted to bring in one part (e.g. restoring just the gallery from an old backup while leaving today's live catalog alone). Backend: importData() now accepts a "sections" field (JSON array of catalog/services/faqs/gallery) and only DELETE+INSERTs the tables that belong to selected sections; anything not selected is left completely untouched. Tables stay grouped the way their foreign keys require (brands -> car_models -> packages -> package_products/upgrades as one unit, gallery_brands -> gallery_projects -> gallery_photos as another) - importing "packages" without its models would either orphan rows or silently rewrite unrelated ones, so the groups aren't splittable further. Restored images are filtered to just the ones referenced by the tables actually being imported, so deselecting the gallery means its images aren't written to /uploads either. No sections field (older callers) still imports everything, matching prior behavior. Frontend: picking a file parses it client-side (file.text() + JSON.parse) to show each section with live counts pulled straight from the file ("4 Marken, 15 Modelle, 17 Pakete"), all checked by default. The confirmation checkbox's warning text names only what's actually selected. If parsing fails client-side the picker just doesn't render - the import still goes through server-side with the (safe) default of importing everything. Verified end-to-end against a real ~54MB export: importing only "faqs" reverted a changed FAQ while a newly added test brand survived untouched; importing only "catalog" right after removed that test brand while the FAQ stayed as already restored - confirming sections are fully independent both ways. Co-Authored-By: Claude Fable 5 --- .../admin/settings/ExportImportSettings.jsx | 152 +++++++++++++++--- .../src/Controllers/SettingsController.php | 72 +++++++-- hifi/assets/index-C78wIVRz.css | 1 + .../{index-DQAs_YfT.js => index-KOjZhXM_.js} | 44 ++--- hifi/assets/index-wBHRPmjw.css | 1 - hifi/index.html | 4 +- 6 files changed, 217 insertions(+), 57 deletions(-) create mode 100644 hifi/assets/index-C78wIVRz.css rename hifi/assets/{index-DQAs_YfT.js => index-KOjZhXM_.js} (64%) delete mode 100644 hifi/assets/index-wBHRPmjw.css diff --git a/hifi-src/src/pages/admin/settings/ExportImportSettings.jsx b/hifi-src/src/pages/admin/settings/ExportImportSettings.jsx index eb559b9..c77f55d 100644 --- a/hifi-src/src/pages/admin/settings/ExportImportSettings.jsx +++ b/hifi-src/src/pages/admin/settings/ExportImportSettings.jsx @@ -1,25 +1,105 @@ import { useState } from 'react'; import { api, API_BASE } from '../../../api/client.js'; +// Gruppierung muss zur Backend-Konstante SettingsController::IMPORT_SECTIONS passen. +// Tabellen innerhalb einer Gruppe haengen per Fremdschluessel voneinander ab, darum +// werden sie beim Import immer zusammen ersetzt (nie einzeln). +const SECTIONS = [ + { + key: 'catalog', + label: 'Fahrzeug-Katalog', + hint: 'Marken, Modelle, Pakete, Produkte, Upgrades', + tables: ['brands', 'car_models', 'packages', 'package_products', 'package_upgrades'], + }, + { key: 'services', label: 'Leistungen', hint: 'Die Leistungen-Übersicht', tables: ['services'] }, + { key: 'faqs', label: 'FAQs', hint: 'Häufig gestellte Fragen', tables: ['faqs'] }, + { + key: 'gallery', + label: 'Bildergalerie', + hint: 'Galerie-Marken, Projekte und Fotos (inkl. Bilder)', + tables: ['gallery_brands', 'gallery_projects', 'gallery_photos'], + }, +]; + +const TABLE_LABELS = { + brands: 'Marken', + car_models: 'Modelle', + packages: 'Pakete', + package_products: 'Produkte', + package_upgrades: 'Upgrades', + services: 'Leistungen', + faqs: 'FAQs', + gallery_brands: 'Galerie-Marken', + gallery_projects: 'Galerie-Projekte', + gallery_photos: 'Galerie-Fotos', +}; + +// Kurze, lesbare Zusammenfassung ("3 Marken, 12 Modelle, 45 Pakete") aus den im Export +// enthaltenen Zeilen einer Sektion - reine Vorschau, keine Validierung. +const summarize = (parsedData, tables) => { + if (!parsedData) return null; + const parts = tables + .map((t) => [t, Array.isArray(parsedData[t]) ? parsedData[t].length : 0]) + .filter(([, count]) => count > 0) + .map(([t, count]) => `${count} ${TABLE_LABELS[t] || t}`); + return parts.length ? parts.join(', ') : 'leer in dieser Datei'; +}; + export default function ExportImportSettings() { const [importFile, setImportFile] = useState(null); + const [parsedData, setParsedData] = useState(null); + const [parseError, setParseError] = useState(''); + const [selectedSections, setSelectedSections] = useState(() => new Set(SECTIONS.map((s) => s.key))); const [importConfirmed, setImportConfirmed] = useState(false); const [importBusy, setImportBusy] = useState(false); const [importResult, setImportResult] = useState(null); const [importError, setImportError] = useState(''); + const handleFileChange = async (e) => { + const file = e.target.files?.[0] || null; + setImportFile(file); + setImportResult(null); + setImportError(''); + setParsedData(null); + setParseError(''); + setSelectedSections(new Set(SECTIONS.map((s) => s.key))); + if (!file) return; + try { + const text = await file.text(); + const parsed = JSON.parse(text); + if (!parsed || typeof parsed.data !== 'object') throw new Error('kein gültiges Export-Format'); + setParsedData(parsed.data); + } catch { + // Vorschau ist nur ein Komfort-Feature - schlaegt sie fehl, importiert der + // Server trotzdem ganz normal alle Bereiche; die eigentliche Validierung + // passiert ohnehin serverseitig. + setParseError('Datei konnte nicht gelesen werden – Vorschau der Inhalte nicht möglich. Import würde trotzdem versucht (alle Bereiche).'); + } + }; + + const toggleSection = (key) => { + setSelectedSections((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + const handleImport = async (e) => { e.preventDefault(); - if (!importFile || !importConfirmed) return; + if (!importFile || !importConfirmed || selectedSections.size === 0) return; setImportBusy(true); setImportError(''); setImportResult(null); try { const formData = new FormData(); formData.append('file', importFile); + formData.append('sections', JSON.stringify([...selectedSections])); const result = await api.post('/settings/import', formData); setImportResult(result); setImportFile(null); + setParsedData(null); setImportConfirmed(false); e.target.reset(); } catch (err) { @@ -29,6 +109,8 @@ export default function ExportImportSettings() { } }; + const selectedLabels = SECTIONS.filter((s) => selectedSections.has(s.key)).map((s) => s.label); + return (
@@ -49,18 +131,50 @@ export default function ExportImportSettings() {

Daten importieren

- Lädt eine zuvor exportierte Datei auf diesen Server. Ersetzt dabei alle aktuellen - Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen, FAQs und die komplette Bildergalerie auf - diesem Server – das kann nicht rückgängig gemacht werden. Kontaktanfragen und Benutzerkonten bleiben - unangetastet. + Lädt eine zuvor exportierte Datei auf diesen Server. Ersetzt dabei die ausgewählten Bereiche komplett + durch den Inhalt der Datei – das kann nicht rückgängig gemacht werden. Kontaktanfragen und + Benutzerkonten bleiben immer unangetastet.

-
+ setImportFile(e.target.files?.[0] || null)} + onChange={handleFileChange} className="text-sm" /> + + {importFile && ( +
+
+ Was soll importiert werden? +
+ {parseError &&

{parseError}

} +
+ {SECTIONS.map((section) => ( + + ))} +
+
+ )} +