HifiPlanet-Website/hifi-src/src/pages/admin/Packages.jsx
Maaxxs f3ab7d0816 Initial commit: HifiPlanet Car-Hifi Shop (Frontend + Backend)
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>
2026-07-05 13:37:06 +02:00

248 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}