import { useEffect, useMemo, useState } from 'react'; 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 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, icon_name: '', tagline: '', price_text: '', is_featured: false, 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 [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 = async () => { const [pkgs, mdls] = await Promise.all([api.get('/packages'), api.get('/models')]); setPackages(pkgs); setModels(mdls); return pkgs; }; useEffect(() => { load(); }, []); const modelById = useMemo(() => new Map(models.map((m) => [m.id, m])), [models]); const brandOptions = useMemo(() => { const seen = new Map(); models.forEach((m) => { if (!seen.has(m.brand_id)) seen.set(m.brand_id, m.brand_name); }); return Array.from(seen, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); }, [models]); 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) .sort((a, b) => a.name.localeCompare(b.name)), [models, brandFilter] ); const filteredPackages = useMemo( () => packages.filter((pkg) => { const model = modelById.get(pkg.car_model_id); if (brandFilter !== 'all' && String(model?.brand_id) !== brandFilter) return false; if (modelFilter !== 'all' && String(pkg.car_model_id) !== modelFilter) return false; return true; }), [packages, modelById, brandFilter, modelFilter] ); const overviewTable = (
{(brandFilter !== 'all' || modelFilter !== 'all') && ( )} {filteredPackages.length} von {packages.length}
{filteredPackages.map((pkg) => ( ))} {filteredPackages.length === 0 && ( )}
Marke / Modell Paket Aufschlag Aktionen
{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

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

Modell wählen

{!modelSel && (

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

)}
{/* Batch-Editor */} {activeModel && (

Pakete für {activeModel.brand_name} {activeModel.name}

Paketstufen anhaken, Beschreibung und Aufschlag ausfüllen, unten alles auf einmal speichern.

{/* Kopieren von anderem Modell */}
Pakete von anderem Modell kopieren: inkl. Produkte, ohne Upgrades {copyResult && ( {copyResult.error || copyResult.text} )}
{/* Paket-Zeilen */}
{rows.map((row, idx) => (
{row.tierName === null && idx > 0 && rows[idx - 1].tierName !== null && (
Weitere Pakete (alte Namen)
)}
{row.tierName === null && (alter Name)} {row.existing ? ( Vorhanden ) : ( row.checked && ( Neu ) )} {row.form.is_featured && ( Empfohlen )} {row.existing && ( Produkte Upgrades )}
{row.checked && (

Jede Zeile erscheint als eigener Stichpunkt zusammen mit den Bauteilen.