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 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-19 17:02:54 +02:00
parent 3079bd23e2
commit 34efe79658
4 changed files with 674 additions and 233 deletions

View file

@ -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;

View file

@ -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,60 +285,16 @@ 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 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();
};
return (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<h1 className="mb-4 text-xl font-bold text-neutral-900 dark:text-white">Pakete</h1>
const overviewTable = (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<label className="text-sm font-medium text-neutral-600 dark:text-neutral-300">Filtern:</label>
<select
value={brandFilter}
onChange={(e) => handleBrandFilterChange(e.target.value)}
onChange={(e) => {
setBrandFilter(e.target.value);
setModelFilter('all');
}}
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="all">Alle Marken</option>
@ -132,10 +306,16 @@ export default function Packages() {
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="all">Alle Modelle</option>
{modelOptions.map((m) => <option key={m.id} value={String(m.id)}>{m.brand_name} {m.name}</option>)}
{filterModelOptions.map((m) => <option key={m.id} value={String(m.id)}>{m.brand_name} {m.name}</option>)}
</select>
{(brandFilter !== 'all' || modelFilter !== 'all') && (
<button onClick={() => handleBrandFilterChange('all')} className="text-sm text-brand-600 hover:underline">
<button
onClick={() => {
setBrandFilter('all');
setModelFilter('all');
}}
className="text-sm text-brand-600 hover:underline"
>
Filter zurücksetzen
</button>
)}
@ -175,8 +355,18 @@ export default function Packages() {
<td className="px-4 py-2 text-right">
<Link to={`/admin/packages/${pkg.id}/products`} className="mr-3 text-brand-600 hover:underline">Produkte</Link>
<Link to={`/admin/packages/${pkg.id}/upgrades`} className="mr-3 text-brand-600 hover:underline">Upgrades</Link>
<button onClick={() => startEdit(pkg)} className="mr-3 text-brand-600 hover:underline">Bearbeiten</button>
<button onClick={() => handleDelete(pkg.id)} className="text-red-600 hover:underline">Löschen</button>
<button
onClick={() => {
if (!confirmDiscard()) return;
const model = modelById.get(pkg.car_model_id);
openModel(model.brand_id, pkg.car_model_id);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
className="mr-3 text-brand-600 hover:underline"
>
Bearbeiten
</button>
<button onClick={() => handleDeleteFromTable(pkg)} className="text-red-600 hover:underline">Löschen</button>
</td>
</tr>
))}
@ -189,78 +379,154 @@ export default function Packages() {
</table>
</div>
</div>
);
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-neutral-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="font-bold text-neutral-900 dark:text-white">{editingId ? 'Paket bearbeiten' : 'Neues Paket'}</h2>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Modell *</label>
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">Pakete</h1>
{/* Modell-Auswahl: der Batch-Editor arbeitet immer auf genau einem Modell. */}
<div className="rounded-xl border border-neutral-200 bg-white p-5 dark:border-neutral-800 dark:bg-neutral-900">
<h2 className="mb-3 font-bold text-neutral-900 dark:text-white">Modell wählen</h2>
<div className="flex flex-wrap gap-2">
<select
required
value={form.car_model_id}
onChange={(e) => setForm({ ...form, car_model_id: 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"
value={brandSel}
onChange={(e) => handleBrandSelect(e.target.value)}
className="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="">Bitte wählen</option>
{models.map((m) => <option key={m.id} value={m.id}>{m.brand_name} {m.name}</option>)}
<option value="">Marke wählen</option>
{brandOptions.map((b) => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
</select>
<select
value={modelSel}
onChange={(e) => handleModelSelect(e.target.value)}
className="min-w-[220px] rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="">Modell wählen</option>
{pickerModelOptions.map((m) => <option key={m.id} value={String(m.id)}>{m.brand_name} {m.name}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Paketname *</label>
<input
required
value={form.name}
onChange={(e) => 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"
/>
{!modelSel && (
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
Modell auswählen, um dessen Pakete anzulegen oder zu bearbeiten die Standard-Paketstufen stehen dann als Checkliste bereit.
</p>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Beschreibung</label>
<p className="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Jede Zeile erscheint als eigener Stichpunkt zusammen mit den Bauteilen.</p>
<textarea
rows={3}
value={form.description}
onChange={(e) => setForm({ ...form, description: 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"
/>
{/* Batch-Editor */}
{activeModel && (
<div className="rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div className="border-b border-neutral-200 px-5 py-4 dark:border-neutral-800">
<h2 className="font-bold text-neutral-900 dark:text-white">
Pakete für {activeModel.brand_name} {activeModel.name}
</h2>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Paketstufen anhaken, Beschreibung und Aufschlag ausfüllen, unten alles auf einmal speichern.
</p>
{/* Kopieren von anderem Modell */}
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2.5 dark:border-neutral-800 dark:bg-neutral-950">
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">Pakete von anderem Modell kopieren:</span>
<select
value={copySource}
onChange={(e) => setCopySource(e.target.value)}
className="rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="">Quellmodell wählen</option>
{models
.filter((m) => String(m.id) !== modelSel)
.sort((a, b) => `${a.brand_name} ${a.name}`.localeCompare(`${b.brand_name} ${b.name}`))
.map((m) => <option key={m.id} value={String(m.id)}>{m.brand_name} {m.name}</option>)}
</select>
<button
type="button"
onClick={handleCopy}
disabled={!copySource || copyBusy}
className="rounded-md border border-brand-500 px-3 py-1.5 text-sm font-semibold text-brand-600 hover:bg-brand-50 disabled:opacity-50 dark:hover:bg-brand-900/20"
>
{copyBusy ? 'Kopiert…' : 'Kopieren'}
</button>
<span className="text-xs text-neutral-400">inkl. Produkte, ohne Upgrades</span>
{copyResult && (
<span className={`w-full text-sm ${copyResult.error ? 'text-red-600' : 'text-brand-600 dark:text-brand-400'}`}>
{copyResult.error || copyResult.text}
</span>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Icon (optional)</label>
<IconPicker value={form.icon_name} onChange={(name) => setForm({ ...form, icon_name: name })} />
</div>
{/* Paket-Zeilen */}
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Kurzer Slogan (optional)</label>
<input
value={form.tagline}
onChange={(e) => setForm({ ...form, tagline: e.target.value })}
placeholder="z. B. Perfekt für den Einstieg"
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"
/>
{rows.map((row, idx) => (
<div key={row.key}>
{row.tierName === null && idx > 0 && rows[idx - 1].tierName !== null && (
<div className="border-t border-neutral-200 bg-neutral-50 px-5 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-400">
Weitere Pakete (alte Namen)
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Preis-Text (optional)</label>
<input
value={form.price_text}
onChange={(e) => setForm({ ...form, price_text: e.target.value })}
placeholder='z. B. "Coming soon" oder "Preis auf Anfrage"'
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"
/>
<p className="mt-1 text-xs text-neutral-400">Ersetzt auf der Kachel den berechneten Preis, solange ausgefüllt.</p>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
)}
<div className={`border-t border-neutral-100 dark:border-neutral-800 ${row.checked ? '' : 'opacity-70'}`}>
<div className="flex flex-wrap items-center gap-3 px-5 py-3">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={form.is_featured}
onChange={(e) => setForm({ ...form, is_featured: e.target.checked })}
className="h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500"
checked={row.checked}
disabled={!!row.existing || saving}
onChange={(e) => toggleRow(row.key, e.target.checked)}
className="h-5 w-5 rounded border-neutral-300 text-brand-600 focus:ring-brand-500 disabled:opacity-60"
/>
Als empfohlen hervorheben
<span className="inline-flex items-center gap-1.5 font-semibold text-neutral-900 dark:text-white">
{row.form.icon_name && <DynamicIcon name={row.form.icon_name} className="h-4 w-4 text-brand-600 dark:text-brand-400" />}
{row.tierName ?? row.existing.name}
</span>
</label>
{row.tierName === null && <span className="text-xs text-neutral-400">(alter Name)</span>}
{row.existing ? (
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
Vorhanden
</span>
) : (
row.checked && (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-400">
Neu
</span>
)
)}
{row.form.is_featured && (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-400">
Empfohlen
</span>
)}
{row.existing && (
<span className="ml-auto flex items-center gap-3 text-sm">
<Link to={`/admin/packages/${row.existing.id}/products`} className="text-brand-600 hover:underline">Produkte</Link>
<Link to={`/admin/packages/${row.existing.id}/upgrades`} className="text-brand-600 hover:underline">Upgrades</Link>
<button type="button" onClick={() => handleDeleteRow(row)} className="text-red-600 hover:underline">Löschen</button>
</span>
)}
</div>
{row.checked && (
<div className="space-y-4 px-5 pb-5 sm:pl-[52px]">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Beschreibung</label>
<p className="mb-1 text-xs text-neutral-500 dark:text-neutral-400">
Jede Zeile erscheint als eigener Stichpunkt zusammen mit den Bauteilen.
</p>
<textarea
rows={10}
value={row.form.description}
onChange={(e) => updateRowForm(row.key, { description: e.target.value })}
onInput={autoGrow}
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Aufschlag</label>
<div className="flex gap-2">
<div className="flex max-w-md gap-2">
<select
value={form.markup_type}
onChange={(e) => setForm({ ...form, markup_type: e.target.value })}
value={row.form.markup_type}
onChange={(e) => updateRowForm(row.key, { markup_type: e.target.value })}
className="w-40 rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900"
>
<option value="none">Kein Aufschlag</option>
@ -271,37 +537,107 @@ export default function Packages() {
type="number"
step="0.01"
min="0"
disabled={form.markup_type === 'none'}
value={form.markup_value}
onChange={(e) => setForm({ ...form, markup_value: e.target.value })}
className="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900"
disabled={row.form.markup_type === 'none'}
value={row.form.markup_value}
onChange={(e) => updateRowForm(row.key, { markup_value: e.target.value })}
className={`${inputCls} disabled:opacity-50`}
/>
</div>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Wird auf die Summe der Bauteilpreise aufgeschlagen und ergibt den Gesamtpreis für den Kunden.</p>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Wird auf die Summe der Bauteilpreise aufgeschlagen und ergibt den Gesamtpreis für den Kunden.
</p>
</div>
<details className="rounded-lg border border-neutral-200 dark:border-neutral-800">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-300">
Weitere Optionen (Icon, Slogan, Preis-Text, Sortierung)
</summary>
<div className="space-y-4 border-t border-neutral-200 p-3 dark:border-neutral-800">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Icon (optional)</label>
<IconPicker value={row.form.icon_name} onChange={(name) => updateRowForm(row.key, { icon_name: name })} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Kurzer Slogan (optional)</label>
<input
value={row.form.tagline}
onChange={(e) => updateRowForm(row.key, { tagline: e.target.value })}
placeholder="z. B. Perfekt für den Einstieg"
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Preis-Text (optional)</label>
<input
value={row.form.price_text}
onChange={(e) => updateRowForm(row.key, { price_text: e.target.value })}
placeholder='z. B. "Coming soon" oder "Preis auf Anfrage"'
className={inputCls}
/>
<p className="mt-1 text-xs text-neutral-400">Ersetzt auf der Kachel den berechneten Preis, solange ausgefüllt.</p>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
checked={row.form.is_featured}
onChange={(e) => updateRowForm(row.key, { is_featured: e.target.checked })}
className="h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500"
/>
Als empfohlen hervorheben
</label>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">Sortierung</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(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"
value={row.form.sort_order}
onChange={(e) => updateRowForm(row.key, { sort_order: e.target.value })}
className={`${inputCls} max-w-[160px]`}
/>
</div>
</div>
</details>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" className="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600">
{editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-md border border-neutral-300 px-4 py-2 text-sm dark:border-neutral-700">
Abbrechen
</button>
{rowErrors[row.key] && <p className="text-sm text-red-600">{rowErrors[row.key]}</p>}
</div>
)}
</div>
</form>
</div>
))}
</div>
{/* Speichern-Leiste */}
<div className="sticky bottom-0 flex flex-wrap items-center gap-3 rounded-b-xl border-t border-neutral-200 bg-white px-5 py-3 dark:border-neutral-800 dark:bg-neutral-900">
<button
type="button"
onClick={handleSaveAll}
disabled={saving || !isDirty}
className="rounded-md bg-brand-500 px-5 py-2 text-sm font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Alle speichern'}
</button>
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{createCount} neu, {updateCount} geändert
</span>
{globalMsg && (
<span className={`text-sm ${globalMsg.kind === 'error' ? 'text-red-600' : 'text-brand-600 dark:text-brand-400'}`}>
{globalMsg.text}
</span>
)}
</div>
</div>
)}
{/* Uebersicht aller Pakete: prominent ohne Modellauswahl, sonst eingeklappt. */}
{activeModel ? (
<details className="rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<summary className="cursor-pointer select-none px-5 py-3 font-bold text-neutral-900 dark:text-white">
Alle Pakete (Übersicht)
</summary>
<div className="border-t border-neutral-200 p-5 dark:border-neutral-800">{overviewTable}</div>
</details>
) : (
overviewTable
)}
</div>
);
}

View file

@ -115,6 +115,7 @@ $router->post('/packages', $perm('packages.manage', fn($p) => PackageController:
$router->put('/packages/{id}', $perm('packages.manage', fn($p) => PackageController::update($p)));
$router->delete('/packages/{id}', $perm('packages.manage', fn($p) => PackageController::destroy($p)));
$router->get('/packages/{id}/products', $perm('packages.manage', fn($p) => PackageController::products($p)));
$router->post('/models/{id}/copy-packages', $perm('packages.manage', fn($p) => PackageController::copyFromModel($p)));
$router->get('/packages/{id}/upgrades', $maint('vehicles', fn($p) => PackageUpgradeController::index($p)));
$router->post('/package-upgrades', $perm('packages.manage', fn($p) => PackageUpgradeController::store()));

View file

@ -105,6 +105,91 @@ class PackageController
Http::send(['ok' => true]);
}
/**
* Kopiert alle Pakete eines Quellmodells (inkl. der verknuepften
* package_products-Zeilen samt gecachter Scrape-Daten - KEIN Re-Scraping)
* auf das Zielmodell. Paketstufen, deren Slug im Ziel schon existiert,
* werden uebersprungen (abgesichert durch UNIQUE uniq_model_slug).
* package_upgrades werden bewusst nicht mitkopiert.
*/
public static function copyFromModel(array $params): void
{
$targetId = (int) $params['id'];
$body = Http::jsonBody();
$sourceId = (int) ($body['source_model_id'] ?? 0);
if ($targetId <= 0 || $sourceId <= 0) {
Http::error('Quell- und Zielmodell erforderlich', 422);
}
if ($sourceId === $targetId) {
Http::error('Quell- und Zielmodell müssen unterschiedlich sein', 422);
}
$db = Database::connection();
$stmt = $db->prepare('SELECT id FROM car_models WHERE id IN (?, ?)');
$stmt->execute([$sourceId, $targetId]);
if (count($stmt->fetchAll()) !== 2) {
Http::error('Modell nicht gefunden', 404);
}
$stmt = $db->prepare('SELECT slug FROM packages WHERE car_model_id = ?');
$stmt->execute([$targetId]);
$existingSlugs = array_column($stmt->fetchAll(), 'slug');
$stmt = $db->prepare('SELECT * FROM packages WHERE car_model_id = ? ORDER BY sort_order, name');
$stmt->execute([$sourceId]);
$sourcePackages = $stmt->fetchAll();
$copied = [];
$skipped = [];
$db->beginTransaction();
try {
$insertPackage = $db->prepare(
'INSERT INTO packages (car_model_id, name, slug, description, markup_type, markup_value, icon_name, tagline, price_text, is_featured, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$copyProducts = $db->prepare(
'INSERT INTO package_products (package_id, source_url, name_override, scraped_name, scraped_price, scraped_currency,
scraped_image_url, price_updated_at, scrape_status, scrape_error, sort_order)
SELECT ?, source_url, name_override, scraped_name, scraped_price, scraped_currency,
scraped_image_url, price_updated_at, scrape_status, scrape_error, sort_order
FROM package_products WHERE package_id = ?'
);
foreach ($sourcePackages as $pkg) {
if (in_array($pkg['slug'], $existingSlugs, true)) {
$skipped[] = $pkg['name'];
continue;
}
$insertPackage->execute([
$targetId,
$pkg['name'],
$pkg['slug'],
$pkg['description'],
$pkg['markup_type'],
$pkg['markup_value'],
$pkg['icon_name'],
$pkg['tagline'],
$pkg['price_text'],
(int) $pkg['is_featured'],
(int) $pkg['sort_order'],
]);
$newId = (int) $db->lastInsertId();
$copyProducts->execute([$newId, $pkg['id']]);
$copied[] = ['name' => $pkg['name'], 'products' => $copyProducts->rowCount()];
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Http::send(['copied' => $copied, 'skipped' => $skipped]);
}
public static function destroy(array $params): void
{
$stmt = Database::connection()->prepare('DELETE FROM packages WHERE id = ?');