feat: Produktpflege mit Bildupload + Beschreibung + Aktiv-Status
Backend (routes/artikel.js): - 3 neue Spalten via Auto-Migration (bootstrap.js): - bild_url VARCHAR(500) - beschreibung TEXT - aktiv TINYINT(1) DEFAULT 1 - POST/PUT/DELETE Routes jetzt unter requireRole(admin) - GET unterstuetzt ?all=1 fuer inaktive Produkte (Produktpflege-Ansicht) - POST /artikel/:id/bild und DELETE /artikel/:id/bild fuer Bildverwaltung - DELETE faengt FK-Constraint ab (Hinweis: stattdessen inaktiv setzen) Frontend: - Neue Komponente Produkte.jsx als Kartenraster - Bildupload, Beschreibung, Pfandbetrag, Aktiv-Toggle, Edit/Loeschen - Inaktive werden ausgegraut dargestellt - TopNav: neuer Admin-Menupunkt Produkte (FaTags Icon) - Dashboard.jsx: Route eingebaut - client.js: api.getArtikel(true) fuer inaktive, uploadArtikelBild, deleteArtikelBild
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { body, validationResult } from 'express-validator';
|
||||
import pool from '../config/database.js';
|
||||
import { authenticateToken, requireTenant } from '../middleware/auth.js';
|
||||
import { authenticateToken, requireTenant, requireRole } from '../middleware/auth.js';
|
||||
import { upload } from '../middleware/upload.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authenticateToken, requireTenant);
|
||||
|
||||
// GET alle Artikel (auch Inaktive fuer Admin)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const showInactive = req.query.all === '1';
|
||||
const where = showInactive ? 'tenant_id = ?' : 'tenant_id = ? AND aktiv = 1';
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM artikel WHERE tenant_id = ? ORDER BY bezeichnung ASC',
|
||||
`SELECT * FROM artikel WHERE ${where} ORDER BY bezeichnung ASC`,
|
||||
[req.user.tenant_id]
|
||||
);
|
||||
res.json(rows);
|
||||
@@ -27,35 +33,49 @@ router.get('/:id', async (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||
});
|
||||
|
||||
router.post('/', [
|
||||
// POST: Anlegen (Admin only)
|
||||
router.post('/', requireRole(['admin']), [
|
||||
body('bezeichnung').notEmpty().trim(),
|
||||
body('pfand_betrag').isFloat({ min: 0 })
|
||||
body('pfand_betrag').isFloat({ min: 0 }),
|
||||
body('beschreibung').optional({ checkFalsy: true }).trim(),
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO artikel (id, tenant_id, bezeichnung, pfand_betrag) VALUES (UUID(), ?, ?, ?)',
|
||||
[req.user.tenant_id, req.body.bezeichnung, req.body.pfand_betrag]
|
||||
`INSERT INTO artikel (id, tenant_id, bezeichnung, pfand_betrag, beschreibung, aktiv)
|
||||
VALUES (UUID(), ?, ?, ?, ?, 1)`,
|
||||
[req.user.tenant_id, req.body.bezeichnung, req.body.pfand_betrag,
|
||||
req.body.beschreibung || null]
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM artikel WHERE tenant_id = ? ORDER BY erstellt_am DESC LIMIT 1',
|
||||
[req.user.tenant_id]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||
} catch (e) {
|
||||
console.error('POST artikel:', e);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', [
|
||||
// PUT: Aendern (Admin only)
|
||||
router.put('/:id', requireRole(['admin']), [
|
||||
body('bezeichnung').notEmpty().trim(),
|
||||
body('pfand_betrag').isFloat({ min: 0 })
|
||||
body('pfand_betrag').isFloat({ min: 0 }),
|
||||
body('beschreibung').optional({ checkFalsy: true }).trim(),
|
||||
body('aktiv').optional().isBoolean(),
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
|
||||
try {
|
||||
const aktivVal = req.body.aktiv === undefined ? 1 : (req.body.aktiv ? 1 : 0);
|
||||
const [r] = await pool.query(
|
||||
'UPDATE artikel SET bezeichnung=?, pfand_betrag=? WHERE id=? AND tenant_id=?',
|
||||
[req.body.bezeichnung, req.body.pfand_betrag, req.params.id, req.user.tenant_id]
|
||||
`UPDATE artikel
|
||||
SET bezeichnung=?, pfand_betrag=?, beschreibung=?, aktiv=?
|
||||
WHERE id=? AND tenant_id=?`,
|
||||
[req.body.bezeichnung, req.body.pfand_betrag, req.body.beschreibung || null,
|
||||
aktivVal, req.params.id, req.user.tenant_id]
|
||||
);
|
||||
if (r.affectedRows === 0) return res.status(404).json({ error: 'Artikel nicht gefunden' });
|
||||
const [rows] = await pool.query('SELECT * FROM artikel WHERE id=? AND tenant_id=?',
|
||||
@@ -64,13 +84,72 @@ router.put('/:id', [
|
||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
// POST Produktbild (Admin only)
|
||||
router.post('/:id/bild', requireRole(['admin']), upload.single('foto'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'Kein Bild hochgeladen' });
|
||||
try {
|
||||
const [[a]] = await pool.query('SELECT bild_url FROM artikel WHERE id=? AND tenant_id=?',
|
||||
[req.params.id, req.user.tenant_id]);
|
||||
if (!a) {
|
||||
try { fs.unlinkSync(req.file.path); } catch {}
|
||||
return res.status(404).json({ error: 'Artikel nicht gefunden' });
|
||||
}
|
||||
// Altes Bild loeschen (best effort)
|
||||
if (a.bild_url) {
|
||||
const oldPath = path.join(process.env.UPLOAD_DIR || '/app/uploads',
|
||||
a.bild_url.replace(/^\/uploads\//, ''));
|
||||
try { fs.unlinkSync(oldPath); } catch {}
|
||||
}
|
||||
const newUrl = `/uploads/${req.file.filename}`;
|
||||
await pool.query('UPDATE artikel SET bild_url=? WHERE id=? AND tenant_id=?',
|
||||
[newUrl, req.params.id, req.user.tenant_id]);
|
||||
const [rows] = await pool.query('SELECT * FROM artikel WHERE id=? AND tenant_id=?',
|
||||
[req.params.id, req.user.tenant_id]);
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
console.error('POST artikel/bild:', e);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/bild', requireRole(['admin']), async (req, res) => {
|
||||
try {
|
||||
const [[a]] = await pool.query('SELECT bild_url FROM artikel WHERE id=? AND tenant_id=?',
|
||||
[req.params.id, req.user.tenant_id]);
|
||||
if (!a) return res.status(404).json({ error: 'Artikel nicht gefunden' });
|
||||
if (a.bild_url) {
|
||||
const p = path.join(process.env.UPLOAD_DIR || '/app/uploads',
|
||||
a.bild_url.replace(/^\/uploads\//, ''));
|
||||
try { fs.unlinkSync(p); } catch {}
|
||||
}
|
||||
await pool.query('UPDATE artikel SET bild_url=NULL WHERE id=? AND tenant_id=?',
|
||||
[req.params.id, req.user.tenant_id]);
|
||||
res.json({ message: 'Bild entfernt' });
|
||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||
});
|
||||
|
||||
router.delete('/:id', requireRole(['admin']), async (req, res) => {
|
||||
try {
|
||||
// Bild-Datei mitloeschen
|
||||
const [[a]] = await pool.query('SELECT bild_url FROM artikel WHERE id=? AND tenant_id=?',
|
||||
[req.params.id, req.user.tenant_id]);
|
||||
if (a?.bild_url) {
|
||||
const p = path.join(process.env.UPLOAD_DIR || '/app/uploads',
|
||||
a.bild_url.replace(/^\/uploads\//, ''));
|
||||
try { fs.unlinkSync(p); } catch {}
|
||||
}
|
||||
const [r] = await pool.query('DELETE FROM artikel WHERE id=? AND tenant_id=?',
|
||||
[req.params.id, req.user.tenant_id]);
|
||||
if (r.affectedRows === 0) return res.status(404).json({ error: 'Artikel nicht gefunden' });
|
||||
res.json({ message: 'Artikel gelöscht' });
|
||||
} catch (e) { res.status(500).json({ error: 'Serverfehler' }); }
|
||||
} catch (e) {
|
||||
if (e.code === 'ER_ROW_IS_REFERENCED' || e.code === 'ER_ROW_IS_REFERENCED_2') {
|
||||
return res.status(400).json({
|
||||
error: 'Artikel wird in Lieferungen/Rücknahmen verwendet — bitte stattdessen auf "Inaktiv" setzen.'
|
||||
});
|
||||
}
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
39
backend/scripts/bootstrap.js
vendored
39
backend/scripts/bootstrap.js
vendored
@@ -1,18 +1,33 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import pool from '../config/database.js';
|
||||
|
||||
const EXPIRY_COLS = ['notified_7d_at', 'notified_1d_at', 'notified_expired_at'];
|
||||
// Spaltenname -> SQL-Definition
|
||||
const COLS_TO_ADD = {
|
||||
tenants: {
|
||||
notified_7d_at: 'TIMESTAMP NULL',
|
||||
notified_1d_at: 'TIMESTAMP NULL',
|
||||
notified_expired_at: 'TIMESTAMP NULL',
|
||||
},
|
||||
artikel: {
|
||||
bild_url: 'VARCHAR(500) NULL',
|
||||
beschreibung: 'TEXT NULL',
|
||||
aktiv: 'TINYINT(1) NOT NULL DEFAULT 1',
|
||||
},
|
||||
};
|
||||
|
||||
async function ensureExpiryColumns() {
|
||||
const [cols] = await pool.query(
|
||||
`SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tenants'`
|
||||
);
|
||||
const have = new Set(cols.map(c => c.COLUMN_NAME));
|
||||
for (const col of EXPIRY_COLS) {
|
||||
if (!have.has(col)) {
|
||||
await pool.query(`ALTER TABLE tenants ADD COLUMN ${col} TIMESTAMP NULL`);
|
||||
console.log('[bootstrap] tenants ALTER ADD', col);
|
||||
async function ensureColumns() {
|
||||
for (const [table, cols] of Object.entries(COLS_TO_ADD)) {
|
||||
const [existing] = await pool.query(
|
||||
`SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
|
||||
[table]
|
||||
);
|
||||
const have = new Set(existing.map(c => c.COLUMN_NAME));
|
||||
for (const [col, def] of Object.entries(cols)) {
|
||||
if (!have.has(col)) {
|
||||
await pool.query(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
|
||||
console.log(`[bootstrap] ${table} ALTER ADD ${col}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +40,7 @@ export async function bootstrapSuperAdmin() {
|
||||
}
|
||||
|
||||
// Migrations
|
||||
try { await ensureExpiryColumns(); }
|
||||
try { await ensureColumns(); }
|
||||
catch (e) { console.error('[bootstrap] Migration-Fehler:', e.message); }
|
||||
|
||||
const email = process.env.SUPERADMIN_EMAIL;
|
||||
|
||||
@@ -77,7 +77,21 @@ class ApiClient {
|
||||
deleteKundenBild(id) { return this.request(`/kunden-bilder/bild/${id}`, { method: 'DELETE' }); }
|
||||
|
||||
// Artikel
|
||||
getArtikel() { return this.request('/artikel'); }
|
||||
getArtikel(includeInactive) {
|
||||
return this.request('/artikel' + (includeInactive ? '?all=1' : ''));
|
||||
}
|
||||
async uploadArtikelBild(id, file) {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
const res = await fetch(`${API_BASE_URL}/artikel/${id}/bild`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.getToken()}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) { const e = await res.json().catch(()=>({})); throw new Error(e.error || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
deleteArtikelBild(id) { return this.request(`/artikel/${id}/bild`, { method: 'DELETE' }); }
|
||||
createArtikel(a) { return this.request('/artikel', { method: 'POST', body: JSON.stringify(a) }); }
|
||||
updateArtikel(id, a) { return this.request(`/artikel/${id}`, { method: 'PUT', body: JSON.stringify(a) }); }
|
||||
deleteArtikel(id) { return this.request(`/artikel/${id}`, { method: 'DELETE' }); }
|
||||
|
||||
@@ -6,6 +6,7 @@ import AdminDashboard from './AdminDashboard';
|
||||
import KundenVerwaltung from '../KundenVerwaltung';
|
||||
import MitarbeiterVerwaltung from '../MitarbeiterVerwaltung';
|
||||
import Geraete from '../Geraete';
|
||||
import Produkte from '../Produkte';
|
||||
import TourPlanung from '../TourPlanung';
|
||||
import Account from './Account';
|
||||
|
||||
@@ -38,6 +39,7 @@ export default function Dashboard({ user, onLogout }) {
|
||||
{activeView === 'account' && <Account user={user} />}
|
||||
{isAdmin && activeView === 'admin' && <AdminDashboard />}
|
||||
{isAdmin && activeView === 'kunden' && <KundenVerwaltung />}
|
||||
{isAdmin && activeView === 'produkte' && <Produkte />}
|
||||
{isAdmin && activeView === 'mitarbeiter' && <MitarbeiterVerwaltung user={user} />}
|
||||
{isAdmin && activeView === 'geraete' && <Geraete />}
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
FaPlusCircle, FaTruck, FaUndo, FaUsers, FaAddressBook,
|
||||
FaChartBar, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt
|
||||
FaChartBar, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt, FaTags
|
||||
} from 'react-icons/fa';
|
||||
|
||||
const mainItems = [
|
||||
@@ -14,6 +14,7 @@ const mainItems = [
|
||||
const adminItems = [
|
||||
{ icon: FaChartBar, label: 'Auswertung', view: 'admin' },
|
||||
{ icon: FaAddressBook, label: 'Kunden', view: 'kunden' },
|
||||
{ icon: FaTags, label: 'Produkte', view: 'produkte' },
|
||||
{ icon: FaBoxes, label: 'Geräte', view: 'geraete' },
|
||||
{ icon: FaUsers, label: 'Mitarbeiter', view: 'mitarbeiter' },
|
||||
];
|
||||
|
||||
185
src/components/Produkte.jsx
Normal file
185
src/components/Produkte.jsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FaPlus, FaEdit, FaTrash, FaTimes, FaCamera, FaCheck, FaBan } from 'react-icons/fa';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||
const empty = { bezeichnung: '', pfand_betrag: '', beschreibung: '', aktiv: true };
|
||||
|
||||
export default function Produkte() {
|
||||
const [list, setList] = useState([]);
|
||||
const [form, setForm] = useState(empty);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [bildBusy, setBildBusy] = useState(null); // id des Artikels der gerade Bild hochlaedt
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setList(await api.getArtikel(true)); setError(''); }
|
||||
catch (e) { setError(e.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const startEdit = (a) => {
|
||||
setEditId(a.id);
|
||||
setForm({
|
||||
bezeichnung: a.bezeichnung || '',
|
||||
pfand_betrag: a.pfand_betrag ?? '',
|
||||
beschreibung: a.beschreibung || '',
|
||||
aktiv: !!a.aktiv,
|
||||
});
|
||||
};
|
||||
const cancel = () => { setEditId(null); setForm(empty); };
|
||||
|
||||
const save = async () => {
|
||||
if (!form.bezeichnung) return setError('Bezeichnung erforderlich');
|
||||
if (form.pfand_betrag === '' || isNaN(parseFloat(form.pfand_betrag)))
|
||||
return setError('Pfandbetrag erforderlich');
|
||||
try {
|
||||
const payload = {
|
||||
bezeichnung: form.bezeichnung,
|
||||
pfand_betrag: parseFloat(form.pfand_betrag),
|
||||
beschreibung: form.beschreibung || null,
|
||||
aktiv: form.aktiv,
|
||||
};
|
||||
if (editId) await api.updateArtikel(editId, payload);
|
||||
else await api.createArtikel(payload);
|
||||
cancel(); load();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const del = async (id) => {
|
||||
if (!confirm('Produkt wirklich löschen? (Falls bereits in Vorgängen verwendet, lieber auf Inaktiv setzen.)')) return;
|
||||
try { await api.deleteArtikel(id); load(); }
|
||||
catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const uploadBild = async (id, file) => {
|
||||
if (!file) return;
|
||||
setBildBusy(id);
|
||||
try { await api.uploadArtikelBild(id, file); load(); }
|
||||
catch (e) { setError(e.message); } finally { setBildBusy(null); }
|
||||
};
|
||||
const deleteBild = async (id) => {
|
||||
if (!confirm('Bild entfernen?')) return;
|
||||
try { await api.deleteArtikelBild(id); load(); }
|
||||
catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const toggleAktiv = async (a) => {
|
||||
try {
|
||||
await api.updateArtikel(a.id, {
|
||||
bezeichnung: a.bezeichnung,
|
||||
pfand_betrag: a.pfand_betrag,
|
||||
beschreibung: a.beschreibung,
|
||||
aktiv: !a.aktiv,
|
||||
});
|
||||
load();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const bildUrl = (a) =>
|
||||
a.bild_url ? (a.bild_url.startsWith('http') ? a.bild_url : `${API_BASE.replace('/api', '')}${a.bild_url}`) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Produkte</h2>
|
||||
<div className="text-sm text-slate-500">{list.length} Produkte</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<h3 className="font-medium text-slate-800 mb-3">{editId ? 'Produkt bearbeiten' : 'Neues Produkt'}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="label">Bezeichnung *</label>
|
||||
<input className="input" value={form.bezeichnung}
|
||||
onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Pfandbetrag (€) *</label>
|
||||
<input type="number" step="0.01" min="0" className="input" value={form.pfand_betrag}
|
||||
onChange={e => setForm(f => ({ ...f, pfand_betrag: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={form.aktiv}
|
||||
onChange={e => setForm(f => ({ ...f, aktiv: e.target.checked }))} />
|
||||
Aktiv (im Vorgang-Formular auswählbar)
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="label">Beschreibung (optional)</label>
|
||||
<textarea rows={2} className="input"
|
||||
placeholder="z. B. Maße, Material, Hinweise"
|
||||
value={form.beschreibung}
|
||||
onChange={e => setForm(f => ({ ...f, beschreibung: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={save} className="btn-primary"><FaPlus /> {editId ? 'Speichern' : 'Anlegen'}</button>
|
||||
{editId && <button onClick={cancel} className="btn-secondary"><FaTimes /> Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="text-center py-8 text-slate-500">Lade…</div> : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{list.map(a => {
|
||||
const url = bildUrl(a);
|
||||
return (
|
||||
<div key={a.id} className={`card flex flex-col gap-3 ${a.aktiv ? '' : 'opacity-60'}`}>
|
||||
<div className="relative">
|
||||
{url ? (
|
||||
<img src={url} alt={a.bezeichnung}
|
||||
className="w-full aspect-video object-cover rounded-lg ring-1 ring-slate-200 bg-slate-50" />
|
||||
) : (
|
||||
<div className="w-full aspect-video grid place-items-center rounded-lg bg-slate-50 ring-1 ring-slate-200 text-slate-300 text-3xl">
|
||||
<FaCamera />
|
||||
</div>
|
||||
)}
|
||||
{url && (
|
||||
<button onClick={() => deleteBild(a.id)} title="Bild entfernen"
|
||||
className="absolute top-2 right-2 h-7 w-7 grid place-items-center bg-white/95 rounded-full text-rose-600 shadow-soft">
|
||||
<FaTimes size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-semibold text-slate-900">{a.bezeichnung}</div>
|
||||
{!a.aktiv && <span className="badge-slate">inaktiv</span>}
|
||||
</div>
|
||||
<div className="text-brand-900 font-bold text-lg">
|
||||
{Number(a.pfand_betrag).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</div>
|
||||
{a.beschreibung && <p className="text-xs text-slate-500 mt-1 line-clamp-2">{a.beschreibung}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 -mx-1 pt-2 border-t border-slate-100">
|
||||
<input id={`up-${a.id}`} type="file" accept="image/*" className="sr-only"
|
||||
onChange={e => uploadBild(a.id, e.target.files?.[0])} disabled={bildBusy === a.id} />
|
||||
<label htmlFor={`up-${a.id}`} className="btn-secondary flex-1 cursor-pointer text-xs py-2">
|
||||
<FaCamera /> {bildBusy === a.id ? 'Lade…' : (url ? 'Bild ändern' : 'Bild')}
|
||||
</label>
|
||||
<button onClick={() => toggleAktiv(a)} title={a.aktiv ? 'Deaktivieren' : 'Aktivieren'}
|
||||
className="btn-ghost px-2">
|
||||
{a.aktiv ? <FaBan className="text-amber-600" /> : <FaCheck className="text-emerald-600" />}
|
||||
</button>
|
||||
<button onClick={() => startEdit(a)} className="btn-ghost px-2"><FaEdit /></button>
|
||||
<button onClick={() => del(a.id)} className="btn-ghost px-2 text-rose-600"><FaTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<div className="col-span-full card text-center text-slate-400 py-12">Noch keine Produkte angelegt.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user