Web: Einzelstuecke verleihen, auch direkt nach dem Scannen

Verleihen und Zuruecknehmen in der Einzelstueck-Tabelle eines Artikels und
in der Liste aller Einzelstuecke, dort als Spalten "Verliehen an" und
"Zurueck bis" (filter- und sortierbar). Ueberfaellig ist rot, demnaechst
faellig gelb - dieselben Zustandsfarben wie beim MHD.

Die Scan-Zielseite /i/<uid> leitete vorher nur zum Artikel weiter. Fuer
"scannen und verleihen" braucht die Aktion aber genau dort ihren Platz:
Die Seite zeigt jetzt, was man in der Hand haelt - Stueck, Artikel,
Lagerort, Verleihzustand - und bietet Verleihen bzw. Zuruecknehmen an.
Gegenstueck zur Lagerort-Seite /l/<code>, die es fuers Einlagern genauso
macht.

Anzeige und Aktionen liegen einmal in components/Verleih.jsx, weil alle
drei Stellen dasselbe brauchen. Der Knopf haengt nicht an isAdmin:
Verleihen ist eine Buchung wie Ein- und Auslagern.

Dazu zwei Dashboard-Karten: alles Verliehene und, getrennt, nur das
Ueberfaellige. Stuecke ohne Rueckgabedatum sind nie faellig und tauchen in
der zweiten Karte nicht auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-17 09:30:14 +02:00
co-authored by Claude Opus 5
parent 467ef3c804
commit 1332dd86fa
9 changed files with 472 additions and 36 deletions
+27 -1
View File
@@ -8,6 +8,12 @@ import { ProduktThumb } from "../components/ProduktBild";
import { locationOptions, locationPathById } from "../locationPath";
import { categoryInfoMap } from "../categoryPath";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import {
VerleihAktion,
VerleihForm,
VerleihText,
verleihRowClass,
} from "../components/Verleih";
/**
* Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) –
@@ -22,6 +28,7 @@ export default function ItemList() {
const [categories, setCategories] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
async function load() {
setLoading(true);
@@ -71,6 +78,16 @@ export default function ItemList() {
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
) : (locationPathById(it.location_id, locations) || "–")) },
{ key: "loan", header: "Verliehen an", width: 200,
filterText: (it) => it.loan?.borrower || "",
filterValues: (it) => [it.loan?.borrower || ""],
filterOptionLabel: (v) => (v === "" ? "(im Haus)" : v),
sortValue: (it) => it.loan?.borrower || "",
render: (it) => <VerleihText loan={it.loan} /> },
{ key: "due", header: "Zurück bis", width: 120,
filterText: (it) => it.loan?.due_on || "",
sortValue: (it) => it.loan?.due_on || "",
render: (it) => it.loan?.due_on || "–" },
{ key: "shop", header: "Gekauft bei", width: 150, filterText: (it) => it.shop_name || "",
render: (it) => (isAdmin ? (
<select value={it.shop_id ?? ""} style={{ marginTop: 0 }}
@@ -90,6 +107,10 @@ export default function ItemList() {
render: (it) => <span className="muted">{it.note || "–"}</span> },
{ key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
{ key: "actions", header: "", label: "Verleihen", fixed: true, width: 52, align: "num",
render: (it) => (
<VerleihAktion item={it} onLend={setLend} onChanged={load} onError={setError} />
) },
];
return (
@@ -103,8 +124,13 @@ export default function ItemList() {
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card">
<DataTable id="items" columns={columns} rows={items} loading={loading}
getRowKey={(it) => it.id} empty="Noch keine Einzelstücke." />
getRowKey={(it) => it.id} rowClassName={(it) => verleihRowClass(it.loan)}
empty="Noch keine Einzelstücke." />
</div>
{lend && (
<VerleihForm item={lend} onError={setError} onCancel={() => setLend(null)}
onDone={async () => { setLend(null); await load(); }} />
)}
</div>
);
}
+113 -17
View File
@@ -1,37 +1,133 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api";
import Icon from "../components/Icon";
import { ProduktThumb } from "../components/ProduktBild";
import { FristBadge, VerleihForm, VerleihHistorie } from "../components/Verleih";
import { useToast } from "../toast";
import { locationPathById } from "../locationPath";
/**
* Ziel eines gescannten QR-Codes (/i/<uid>): löst die UID auf und öffnet den
* zugehörigen Artikel mit dem Einzelstück im Blick.
* Ziel eines gescannten Einzelstück-QR bzw. NFC-Tags (/i/<uid>) – zeigt, was man
* da in der Hand hält, und bietet die Handgriffe direkt an: verleihen,
* zurücknehmen, Artikel öffnen.
*
* Vorher leitete die Seite nur zum Artikel weiter; fürs Verleihen direkt nach
* dem Scannen braucht die Aktion aber genau hier ihren Platz. Gegenstück zu
* `LocationResolve.jsx` für Lagerort-QR.
*/
export default function ItemResolve() {
const { uid } = useParams();
const navigate = useNavigate();
const toast = useToast();
const [item, setItem] = useState(null);
const [locations, setLocations] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [lend, setLend] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let ok = true;
api.itemByUid(uid)
.then((item) => {
if (ok) navigate(`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`,
{ replace: true });
})
.catch((err) => { if (ok) setError(err.message); });
return () => { ok = false; };
}, [uid, navigate]);
const laden = useCallback(async () => {
setLoading(true);
try {
const gefunden = await api.itemByUid(uid);
setItem(gefunden);
setError(null);
// Nur für den vollen Lagerort-Pfad („Keller → Regal 2"). Scheitert es,
// bleibt der Name aus dem Stück selbst stehen.
try { setLocations(await api.listLocations()); } catch { /* nicht kritisch */ }
} catch (err) { setError(err.message); } finally { setLoading(false); }
}, [uid]);
useEffect(() => { laden(); }, [laden]);
async function zurueck() {
setBusy(true);
try {
await api.returnItem(item.id);
toast(`${item.uid} ist zurück.`);
await laden();
} catch (err) { setError(err.message); } finally { setBusy(false); }
}
const ort = item
? (locationPathById(item.location_id, locations) || item.location_name)
: null;
return (
<div>
<div className="page-head">
<div>
<h1>Einzelstück {uid}</h1>
<div className="sub">{error ? error : "Wird geöffnet…"}</div>
<h1><Icon name="tag" size={20} /> {item?.product_name || `Einzelstück ${uid}`}</h1>
<div className="sub">
{loading ? "Wird geladen…" : (
<>
<span className="strong">{uid}</span>
{item?.product_brand ? ` · ${item.product_brand}` : ""}
{ort ? ` · ${ort}` : ""}
</>
)}
</div>
</div>
{item && (
item.loan ? (
<button className="btn primary" disabled={busy} onClick={zurueck}>
<Icon name="checkin" size={16} />{busy ? "Moment…" : "Zurücknehmen"}
</button>
) : (
<button className="btn primary" onClick={() => setLend(true)}>
<Icon name="loan" size={16} />Verleihen
</button>
)
)}
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{item && (
<div className="card">
<div className="card-head">
<ProduktThumb productId={item.product_id} alt={item.product_name} />
<h2>{item.loan ? `Verliehen an ${item.loan.borrower}` : "Im Haus"}</h2>
{item.loan && <FristBadge loan={item.loan} />}
<Link className="btn sm ghost" style={{ marginLeft: "auto" }}
to={`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`}>
Artikel öffnen
</Link>
</div>
<dl className="kv">
<dt>Lagerort</dt>
<dd>
{ort || "– ohne –"}
{item.loan && (
<span className="muted small"> · gerade nicht dort, aber dorthin zurück</span>
)}
</dd>
{item.loan && (
<>
<dt>Verliehen seit</dt>
<dd>{item.loan.lent_on}</dd>
{item.loan.reason && (<><dt>Grund</dt><dd>{item.loan.reason}</dd></>)}
<dt>Zurück bis</dt>
<dd>{item.loan.due_on || "– offen –"}</dd>
</>
)}
{item.note && (<><dt>Notiz</dt><dd>{item.note}</dd></>)}
{item.warranty_until && (
<><dt>Garantie bis</dt><dd>{item.warranty_until}</dd></>
)}
</dl>
<div style={{ marginTop: "var(--sp-3)" }}>
<VerleihHistorie item={item} onError={setError} />
</div>
</div>
)}
{item && lend && (
<VerleihForm item={item} onError={setError} onCancel={() => setLend(false)}
onDone={async () => { setLend(false); await laden(); }} />
)}
</div>
);
}