From 34efe7965866a382257adf3a19d3b11012208e1b Mon Sep 17 00:00:00 2001 From: Maaxxs <61059039+MaaxxsDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:02:54 +0200 Subject: [PATCH] Rebuild the admin package editor as a per-model batch workflow Client feedback: creating ~30 models x 8 packages through the old one-at-a-time form (free-text name, rows={3} description box) was unworkable. Package names are always the same eight tiers, so the form asked for information that was never variable. The page is now a batch editor: pick brand + model, get the eight standard tiers (Base, Clear, Drive, Prime, Elite R, Apex, The Statement, Limitless - new shared constant in constants/packageTiers.js) as checkbox rows. Checking a row opens a large auto-growing description textarea and the markup fields; icon/tagline/price-text/featured/sort live in a collapsed details block. One "Alle speichern" button creates and updates everything in one pass, with per-row error reporting and failed rows keeping their input. Existing packages load pre-filled; packages with non-standard names appear in a read-only-named legacy section, still editable and deletable. Deleting stays behind an explicit button - unchecking never deletes. The old all-packages table remains below as a collapsible overview, and its Bearbeiten action now jumps into the batch editor. New endpoint POST /models/{id}/copy-packages copies every package of a source model - including linked package_products rows with their cached scrape data, so no re-scraping - onto the target, skipping tiers whose slug already exists there (backed by uniq_model_slug), all in one transaction. The editor exposes it as "Pakete von anderem Modell kopieren" with a copied/skipped result line: set up one model, replicate to the other 29, adjust. The public tier theming is intentionally untouched: ModelPage keeps assigning card themes by price rank, so legacy package names continue to render correctly. Co-Authored-By: Claude Fable 5 --- hifi-src/src/constants/packageTiers.js | 19 + hifi-src/src/pages/admin/Packages.jsx | 802 +++++++++++++----- hifi/api/public/index.php | 1 + .../api/src/Controllers/PackageController.php | 85 ++ 4 files changed, 674 insertions(+), 233 deletions(-) create mode 100644 hifi-src/src/constants/packageTiers.js diff --git a/hifi-src/src/constants/packageTiers.js b/hifi-src/src/constants/packageTiers.js new file mode 100644 index 0000000..daffe8d --- /dev/null +++ b/hifi-src/src/constants/packageTiers.js @@ -0,0 +1,19 @@ +// Die 8 Standard-Paketstufen (Preisleiter, aufsteigend) - entsprechen den +// Referenz-Kacheln (Strassen-Motive base.webp ... limitless.webp). +// Nur vom Admin-Bereich genutzt: die oeffentliche ModelPage ordnet ihre +// Kachel-Themes weiterhin per Preis-Rang zu (siehe ModelPage.jsx tierOf), +// damit auch Alt-Pakete mit abweichenden Namen korrekt dargestellt werden. +export const PACKAGE_TIERS = [ + 'Base', + 'Clear', + 'Drive', + 'Prime', + 'Elite R', + 'Apex', + 'The Statement', + 'Limitless', +]; + +// Default-Sortierung fuer neu angelegte Tier-Pakete: 10, 20, ..., 80 - +// laesst Luecken, falls dazwischen mal etwas einsortiert werden muss. +export const tierSortOrder = (index) => (index + 1) * 10; diff --git a/hifi-src/src/pages/admin/Packages.jsx b/hifi-src/src/pages/admin/Packages.jsx index b3410ea..52ebfc9 100644 --- a/hifi-src/src/pages/admin/Packages.jsx +++ b/hifi-src/src/pages/admin/Packages.jsx @@ -3,10 +3,23 @@ import { Link } from 'react-router-dom'; import { api } from '../../api/client.js'; import IconPicker from '../../components/IconPicker.jsx'; import DynamicIcon from '../../components/DynamicIcon.jsx'; +import { PACKAGE_TIERS, tierSortOrder } from '../../constants/packageTiers.js'; -const emptyForm = { - name: '', - car_model_id: '', +const inputCls = + 'w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900'; + +const formFromPkg = (pkg) => ({ + description: pkg.description || '', + markup_type: pkg.markup_type || 'none', + markup_value: pkg.markup_value || 0, + icon_name: pkg.icon_name || '', + tagline: pkg.tagline || '', + price_text: pkg.price_text || '', + is_featured: !!pkg.is_featured, + sort_order: pkg.sort_order ?? 0, +}); + +const emptyRowForm = (tierIndex) => ({ description: '', markup_type: 'none', markup_value: 0, @@ -14,24 +27,78 @@ const emptyForm = { tagline: '', price_text: '', is_featured: false, - sort_order: 0, + sort_order: tierSortOrder(tierIndex), +}); + +const FORM_FIELDS = ['description', 'markup_type', 'markup_value', 'icon_name', 'tagline', 'price_text', 'is_featured', 'sort_order']; + +// Eine Zeile gilt als "zu speichern", wenn sie neu angehakt ist oder sich +// gegenueber dem geladenen Stand (existing) etwas geaendert hat. +const isRowDirty = (row) => { + if (!row.existing) return row.checked; + const base = formFromPkg(row.existing); + return FORM_FIELDS.some((f) => + f === 'is_featured' ? !!base[f] !== !!row.form[f] : String(base[f]) !== String(row.form[f]) + ); +}; + +// Baut die Editor-Zeilen fuer ein Modell: die 8 Standard-Stufen in +// Leiter-Reihenfolge (bestehende Pakete case-insensitiv per Name zugeordnet, +// jedes hoechstens einmal), dahinter alle uebrigen Pakete mit Alt-Namen. +const buildRows = (modelId, packages) => { + const modelPkgs = packages + .filter((p) => p.car_model_id === modelId) + .slice() + .sort((a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)); + const used = new Set(); + const tierRows = PACKAGE_TIERS.map((tier, i) => { + const match = modelPkgs.find((p) => !used.has(p.id) && p.name.trim().toLowerCase() === tier.toLowerCase()); + if (match) used.add(match.id); + return { + key: `tier-${i}`, + tierName: tier, + existing: match || null, + checked: !!match, + form: match ? formFromPkg(match) : emptyRowForm(i), + }; + }); + const legacyRows = modelPkgs + .filter((p) => !used.has(p.id)) + .map((p) => ({ key: `legacy-${p.id}`, tierName: null, existing: p, checked: true, form: formFromPkg(p) })); + return [...tierRows, ...legacyRows]; +}; + +// Beschreibungs-Textarea waechst beim Tippen mit dem Inhalt mit. +const autoGrow = (e) => { + e.target.style.height = 'auto'; + e.target.style.height = `${e.target.scrollHeight}px`; }; export default function Packages() { const [packages, setPackages] = useState([]); const [models, setModels] = useState([]); - const [form, setForm] = useState(emptyForm); - const [editingId, setEditingId] = useState(null); - const [error, setError] = useState(''); + const [brandSel, setBrandSel] = useState(''); + const [modelSel, setModelSel] = useState(''); + const [rows, setRows] = useState([]); + const [rowErrors, setRowErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [globalMsg, setGlobalMsg] = useState(null); // { kind: 'ok' | 'error', text } + const [copySource, setCopySource] = useState(''); + const [copyBusy, setCopyBusy] = useState(false); + const [copyResult, setCopyResult] = useState(null); // { text } | { error } const [brandFilter, setBrandFilter] = useState('all'); const [modelFilter, setModelFilter] = useState('all'); - const load = () => { - api.get('/packages').then(setPackages); - api.get('/models').then(setModels); + const load = async () => { + const [pkgs, mdls] = await Promise.all([api.get('/packages'), api.get('/models')]); + setPackages(pkgs); + setModels(mdls); + return pkgs; }; - useEffect(load, []); + useEffect(() => { + load(); + }, []); const modelById = useMemo(() => new Map(models.map((m) => [m.id, m])), [models]); @@ -43,7 +110,163 @@ export default function Packages() { return Array.from(seen, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); }, [models]); - const modelOptions = useMemo( + const pickerModelOptions = useMemo( + () => + models + .filter((m) => !brandSel || String(m.brand_id) === brandSel) + .sort((a, b) => a.name.localeCompare(b.name)), + [models, brandSel] + ); + + const activeModel = modelSel ? modelById.get(Number(modelSel)) : null; + + const isDirty = useMemo(() => rows.some(isRowDirty), [rows]); + + const confirmDiscard = () => !isDirty || confirm('Ungespeicherte Änderungen verwerfen?'); + + const openModel = (brandId, modelId, pkgs = packages) => { + setBrandSel(String(brandId)); + setModelSel(String(modelId)); + setRows(buildRows(Number(modelId), pkgs)); + setRowErrors({}); + setGlobalMsg(null); + setCopySource(''); + setCopyResult(null); + }; + + const handleBrandSelect = (value) => { + if (!confirmDiscard()) return; + setBrandSel(value); + setModelSel(''); + setRows([]); + setRowErrors({}); + setGlobalMsg(null); + setCopyResult(null); + }; + + const handleModelSelect = (value) => { + if (!confirmDiscard()) return; + if (!value) { + setModelSel(''); + setRows([]); + return; + } + const model = modelById.get(Number(value)); + openModel(model ? model.brand_id : brandSel, value); + }; + + const toggleRow = (key, checked) => + setRows((rs) => rs.map((r) => (r.key === key ? { ...r, checked } : r))); + + const updateRowForm = (key, patch) => + setRows((rs) => rs.map((r) => (r.key === key ? { ...r, form: { ...r.form, ...patch } } : r))); + + const rowPayload = (row) => ({ + name: row.tierName ?? row.existing.name, + car_model_id: Number(modelSel), + ...row.form, + markup_value: Number(row.form.markup_value), + sort_order: Number(row.form.sort_order), + }); + + const handleSaveAll = async () => { + setSaving(true); + setGlobalMsg(null); + const errors = {}; + const preSaveRows = rows; + let created = 0; + let updated = 0; + + // Bewusst sequentiell: max. ~8 Requests, die PHP-Session serialisiert ohnehin, + // und Fehler lassen sich so eindeutig einer Zeile zuordnen. + for (const row of rows) { + try { + if (row.existing) { + if (isRowDirty(row)) { + await api.put(`/packages/${row.existing.id}`, rowPayload(row)); + updated += 1; + } + } else if (row.checked) { + await api.post('/packages', rowPayload(row)); + created += 1; + } + } catch (err) { + errors[row.key] = err.message; + } + } + + const pkgs = await load(); + // Fehlgeschlagene Zeilen behalten ihre Eingaben, damit nichts verloren geht. + const rebuilt = buildRows(Number(modelSel), pkgs).map((r) => { + if (!errors[r.key]) return r; + const old = preSaveRows.find((p) => p.key === r.key); + return old ? { ...r, checked: old.checked, form: old.form } : r; + }); + setRows(rebuilt); + setRowErrors(errors); + + const failed = Object.keys(errors).length; + const parts = []; + if (created) parts.push(`${created} neu angelegt`); + if (updated) parts.push(`${updated} aktualisiert`); + if (!parts.length && !failed) parts.push('Keine Änderungen'); + setGlobalMsg( + failed + ? { kind: 'error', text: `${parts.join(', ') || 'Nichts gespeichert'} – ${failed} Paket(e) fehlgeschlagen (Details an der Zeile).` } + : { kind: 'ok', text: `${parts.join(', ')}.` } + ); + setSaving(false); + }; + + const handleDeleteRow = async (row) => { + if (!confirm(`Paket "${row.tierName ?? row.existing.name}" inkl. aller Produkte wirklich löschen?`)) return; + await api.delete(`/packages/${row.existing.id}`); + const pkgs = await load(); + setRows(buildRows(Number(modelSel), pkgs)); + setGlobalMsg(null); + }; + + const handleDeleteFromTable = async (pkg) => { + if (!confirm('Paket inkl. aller Produkte wirklich löschen?')) return; + await api.delete(`/packages/${pkg.id}`); + const pkgs = await load(); + if (modelSel) setRows(buildRows(Number(modelSel), pkgs)); + }; + + const handleCopy = async () => { + if (!copySource || !confirmDiscard()) return; + const src = modelById.get(Number(copySource)); + const tgt = modelById.get(Number(modelSel)); + if ( + !confirm( + `Alle Pakete von "${src.brand_name} ${src.name}" inkl. verknüpfter Produkte nach "${tgt.brand_name} ${tgt.name}" kopieren?\n\nBereits vorhandene Paketstufen werden übersprungen. Upgrades werden nicht mitkopiert.` + ) + ) { + return; + } + setCopyBusy(true); + setCopyResult(null); + try { + const res = await api.post(`/models/${modelSel}/copy-packages`, { source_model_id: Number(copySource) }); + const productCount = res.copied.reduce((sum, c) => sum + c.products, 0); + const parts = [`${res.copied.length} Paket(e) kopiert (${productCount} Produkte)`]; + if (res.skipped.length) parts.push(`übersprungen: ${res.skipped.join(', ')}`); + setCopyResult({ text: `${parts.join(' – ')}.` }); + const pkgs = await load(); + setRows(buildRows(Number(modelSel), pkgs)); + setRowErrors({}); + } catch (err) { + setCopyResult({ error: err.message }); + } + setCopyBusy(false); + }; + + // Zusammenfassung fuer die Speichern-Leiste + const createCount = rows.filter((r) => !r.existing && r.checked).length; + const updateCount = rows.filter((r) => r.existing && isRowDirty(r)).length; + + // ---- Uebersichtstabelle (bestehende Ansicht, bleibt als Nachschlagewerk) ---- + const filterModelOptions = useMemo( () => models .filter((m) => brandFilter === 'all' || String(m.brand_id) === brandFilter) @@ -51,11 +274,6 @@ export default function Packages() { [models, brandFilter] ); - const handleBrandFilterChange = (value) => { - setBrandFilter(value); - setModelFilter('all'); - }; - const filteredPackages = useMemo( () => packages.filter((pkg) => { @@ -67,241 +285,359 @@ export default function Packages() { [packages, modelById, brandFilter, modelFilter] ); - const startEdit = (pkg) => { - setEditingId(pkg.id); - setForm({ - name: pkg.name, - car_model_id: pkg.car_model_id, - description: pkg.description || '', - markup_type: pkg.markup_type || 'none', - markup_value: pkg.markup_value || 0, - icon_name: pkg.icon_name || '', - tagline: pkg.tagline || '', - price_text: pkg.price_text || '', - is_featured: !!pkg.is_featured, - sort_order: pkg.sort_order, - }); - }; + const overviewTable = ( +
+
+ + + + {(brandFilter !== 'all' || modelFilter !== 'all') && ( + + )} + {filteredPackages.length} von {packages.length} +
- const resetForm = () => { - setEditingId(null); - setForm(emptyForm); - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - setError(''); - const payload = { ...form, car_model_id: Number(form.car_model_id), markup_value: Number(form.markup_value) }; - try { - if (editingId) { - await api.put(`/packages/${editingId}`, payload); - } else { - await api.post('/packages', payload); - } - resetForm(); - load(); - } catch (err) { - setError(err.message); - } - }; - - const handleDelete = async (id) => { - if (!confirm('Paket inkl. aller Produkte wirklich löschen?')) return; - await api.delete(`/packages/${id}`); - load(); - }; +
+ + + + + + + + + + + {filteredPackages.map((pkg) => ( + + + + + + + ))} + {filteredPackages.length === 0 && ( + + )} + +
Marke / ModellPaketAufschlagAktionen
{pkg.brand_name} {pkg.model_name} + + {pkg.icon_name && } + {pkg.name} + {pkg.is_featured && ( + + Empfohlen + + )} + + + {pkg.markup_type === 'fixed' && `+${pkg.markup_value} €`} + {pkg.markup_type === 'percent' && `+${pkg.markup_value} %`} + {(!pkg.markup_type || pkg.markup_type === 'none') && '–'} + + Produkte + Upgrades + + +
+ {packages.length === 0 ? 'Noch keine Pakete angelegt.' : 'Keine Pakete für diesen Filter.'} +
+
+
+ ); return ( -
-
-

Pakete

+
+

Pakete

-
- + {/* Modell-Auswahl: der Batch-Editor arbeitet immer auf genau einem Modell. */} +
+

Modell wählen

+
- {(brandFilter !== 'all' || modelFilter !== 'all') && ( - - )} - {filteredPackages.length} von {packages.length} -
- -
- - - - - - - - - - - {filteredPackages.map((pkg) => ( - - - - - - - ))} - {filteredPackages.length === 0 && ( - - )} - -
Marke / ModellPaketAufschlagAktionen
{pkg.brand_name} {pkg.model_name} - - {pkg.icon_name && } - {pkg.name} - {pkg.is_featured && ( - - Empfohlen - - )} - - - {pkg.markup_type === 'fixed' && `+${pkg.markup_value} €`} - {pkg.markup_type === 'percent' && `+${pkg.markup_value} %`} - {(!pkg.markup_type || pkg.markup_type === 'none') && '–'} - - Produkte - Upgrades - - -
- {packages.length === 0 ? 'Noch keine Pakete angelegt.' : 'Keine Pakete für diesen Filter.'} -
+ {!modelSel && ( +

+ Modell auswählen, um dessen Pakete anzulegen oder zu bearbeiten – die Standard-Paketstufen stehen dann als Checkliste bereit. +

+ )}
-
-

{editingId ? 'Paket bearbeiten' : 'Neues Paket'}

-
- - -
-
- - setForm({ ...form, name: e.target.value })} - className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900" - /> -
-
- -

Jede Zeile erscheint als eigener Stichpunkt zusammen mit den Bauteilen.

-