React/Vite Frontend (hifi-src) + PHP/MariaDB Backend (hifi/api) fuer den Car-Hifi Umbau-Shop HifiPlanet in Amorbach, inkl. Admin-Panel, 2FA, Wartungsmodus, Cookie-Consent und rechtlichen Pflichtseiten. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
248 lines
11 KiB
JavaScript
248 lines
11 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { api } from '../../api/client.js';
|
||
|
||
const emptyForm = { name: '', car_model_id: '', description: '', markup_type: 'none', markup_value: 0, sort_order: 0 };
|
||
|
||
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 [brandFilter, setBrandFilter] = useState('all');
|
||
const [modelFilter, setModelFilter] = useState('all');
|
||
|
||
const load = () => {
|
||
api.get('/packages').then(setPackages);
|
||
api.get('/models').then(setModels);
|
||
};
|
||
|
||
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 modelOptions = useMemo(
|
||
() =>
|
||
models
|
||
.filter((m) => brandFilter === 'all' || String(m.brand_id) === brandFilter)
|
||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||
[models, brandFilter]
|
||
);
|
||
|
||
const handleBrandFilterChange = (value) => {
|
||
setBrandFilter(value);
|
||
setModelFilter('all');
|
||
};
|
||
|
||
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 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,
|
||
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-slate-900 dark:text-white">Pakete</h1>
|
||
|
||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||
<label className="text-sm font-medium text-slate-600 dark:text-slate-300">Filtern:</label>
|
||
<select
|
||
value={brandFilter}
|
||
onChange={(e) => handleBrandFilterChange(e.target.value)}
|
||
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
>
|
||
<option value="all">Alle Marken</option>
|
||
{brandOptions.map((b) => <option key={b.id} value={String(b.id)}>{b.name}</option>)}
|
||
</select>
|
||
<select
|
||
value={modelFilter}
|
||
onChange={(e) => setModelFilter(e.target.value)}
|
||
className="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
>
|
||
<option value="all">Alle Modelle</option>
|
||
{modelOptions.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">
|
||
Filter zurücksetzen
|
||
</button>
|
||
)}
|
||
<span className="text-sm text-slate-400">{filteredPackages.length} von {packages.length}</span>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400">
|
||
<tr>
|
||
<th className="px-4 py-2">Marke / Modell</th>
|
||
<th className="px-4 py-2">Paket</th>
|
||
<th className="px-4 py-2">Aufschlag</th>
|
||
<th className="px-4 py-2 text-right">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
||
{filteredPackages.map((pkg) => (
|
||
<tr key={pkg.id} className="bg-white dark:bg-slate-950">
|
||
<td className="px-4 py-2">{pkg.brand_name} {pkg.model_name}</td>
|
||
<td className="px-4 py-2 font-medium text-slate-800 dark:text-slate-100">{pkg.name}</td>
|
||
<td className="px-4 py-2 text-slate-500 dark:text-slate-400">
|
||
{pkg.markup_type === 'fixed' && `+${pkg.markup_value} €`}
|
||
{pkg.markup_type === 'percent' && `+${pkg.markup_value} %`}
|
||
{(!pkg.markup_type || pkg.markup_type === 'none') && '–'}
|
||
</td>
|
||
<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>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{filteredPackages.length === 0 && (
|
||
<tr><td colSpan={4} className="px-4 py-6 text-center text-slate-400">
|
||
{packages.length === 0 ? 'Noch keine Pakete angelegt.' : 'Keine Pakete für diesen Filter.'}
|
||
</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="h-fit space-y-4 rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||
<h2 className="font-bold text-slate-900 dark:text-white">{editingId ? 'Paket bearbeiten' : 'Neues Paket'}</h2>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Modell *</label>
|
||
<select
|
||
required
|
||
value={form.car_model_id}
|
||
onChange={(e) => setForm({ ...form, car_model_id: e.target.value })}
|
||
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
>
|
||
<option value="">Bitte wählen…</option>
|
||
{models.map((m) => <option key={m.id} value={m.id}>{m.brand_name} {m.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Paketname *</label>
|
||
<input
|
||
required
|
||
value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Beschreibung</label>
|
||
<p className="mb-1 text-xs text-slate-500 dark:text-slate-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-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Aufschlag</label>
|
||
<div className="flex gap-2">
|
||
<select
|
||
value={form.markup_type}
|
||
onChange={(e) => setForm({ ...form, markup_type: e.target.value })}
|
||
className="w-40 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
>
|
||
<option value="none">Kein Aufschlag</option>
|
||
<option value="fixed">Fester Betrag (€)</option>
|
||
<option value="percent">Prozent (%)</option>
|
||
</select>
|
||
<input
|
||
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-slate-300 bg-white px-3 py-2 text-sm disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900"
|
||
/>
|
||
</div>
|
||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">Wird auf die Summe der Bauteilpreise aufgeschlagen und ergibt den Gesamtpreis für den Kunden.</p>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-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-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||
/>
|
||
</div>
|
||
|
||
{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-slate-300 px-4 py-2 text-sm dark:border-slate-700">
|
||
Abbrechen
|
||
</button>
|
||
)}
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|