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:
co-authored by
Claude Opus 5
parent
467ef3c804
commit
1332dd86fa
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user