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:
parent
3079bd23e2
commit
34efe79658
4 changed files with 674 additions and 233 deletions
19
hifi-src/src/constants/packageTiers.js
Normal file
19
hifi-src/src/constants/packageTiers.js
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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 = ?');
|
||||
|
|
|
|||
Loading…
Reference in a new issue