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
+5
View File
@@ -28,6 +28,11 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
- **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit - **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit
**QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und **QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und
Entfernen mit Grund. Chargen und Einzelstücke sind einem Lagerort zugeordnet. Entfernen mit Grund. Chargen und Einzelstücke sind einem Lagerort zugeordnet.
- **Einzelstücke verleihen** mit Name und Grund als Freitext und optionalem
Rückgabedatum – auch direkt nach dem Scannen des QR/NFC-Tags. Der Lagerort
bleibt stehen (dorthin gehört das Stück zurück), als verliehen zählt es aber
nicht mehr zum Bestand. Zurückgegebene Verleihe bleiben als Verlauf erhalten;
überfällige Rückgaben sind in Liste und Dashboard rot.
- **Kategorien** verschachtelt und pro Typ (Lebensmittel/Gegenstand); für - **Kategorien** verschachtelt und pro Typ (Lebensmittel/Gegenstand); für
Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe). Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe).
- **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg - **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg
+10 -1
View File
@@ -113,7 +113,16 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
- **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den - **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den
Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne
Gegenbuchung würde diese Kurve verfälschen. Gegenbuchung würde diese Kurve verfälschen.
- Noch offen: Oberfläche in Web und App sowie die **Rückgabe-Erinnerung**. Sie - **Web-Oberfläche:** Verleihen und Zurücknehmen in der Einzelstück-Tabelle
eines Artikels und in der Liste aller Einzelstücke (Spalten „Verliehen an" und
„Zurück bis", filter- und sortierbar), zwei Dashboard-Karten („Verliehen",
„Überfällige Verleihe") und der Verleih-Verlauf je Stück. Überfällig ist rot,
demnächst fällig gelb – dieselben Zustandsfarben wie beim MHD.
- **Nach dem Scannen direkt verleihen:** Das Ziel eines Einzelstück-QR bzw.
NFC-Tags (`/i/<uid>`) leitete vorher nur zum Artikel weiter. Jetzt zeigt es,
was man in der Hand hält, und bietet Verleihen bzw. Zurücknehmen an der Stelle
an – Gegenstück zur Lagerort-Seite (`/l/<code>`).
- Noch offen: Oberfläche in der iOS-App sowie die **Rückgabe-Erinnerung**. Sie
kann nur den Weg der Ablauf-Meldungen gehen (lokale Benachrichtigung der App kann nur den Weg der Ablauf-Meldungen gehen (lokale Benachrichtigung der App
aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die
Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3. Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3.
+17
View File
@@ -227,6 +227,23 @@ export const api = {
deleteItemDocument: (itemId, docId) => deleteItemDocument: (itemId, docId) =>
request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }), request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }),
// Verleih von Einzelstücken. Der Lagerort bleibt dabei stehen – dorthin
// gehört das Stück zurück; verliehen zählt es aber nicht mehr als Bestand.
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
itemLoans: (itemId) => request(`/items/${itemId}/loans`),
// Offene Verleihe, fälligste zuerst. dueIn grenzt auf die ein, deren Rückgabe
// in so vielen Tagen ansteht (überfällige sind immer dabei).
loans: ({ openOnly = true, dueIn } = {}) => {
const p = new URLSearchParams();
if (!openOnly) p.set("open_only", "false");
if (dueIn != null) p.set("due_in", String(dueIn));
const q = p.toString();
return request(`/loans${q ? `?${q}` : ""}`);
},
updateLoan: (loanId, body) => request(`/loans/${loanId}`, { method: "PATCH", body }),
deleteLoan: (loanId) => request(`/loans/${loanId}`, { method: "DELETE" }),
// Bestand // Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }), checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),
+43 -17
View File
@@ -7,6 +7,13 @@ import Icon from "./Icon";
import { REASONS } from "./ObjektBestand"; import { REASONS } from "./ObjektBestand";
import { locationOptions, locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { itemDeepLink } from "../qr"; import { itemDeepLink } from "../qr";
import {
VerleihAktion,
VerleihForm,
VerleihHistorie,
VerleihText,
verleihRowClass,
} from "./Verleih";
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v; const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
@@ -40,6 +47,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_ADD); const [addForm, setAddForm] = useState(EMPTY_ADD);
const [remove, setRemove] = useState(null); // { item, reason, note } | null const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
@@ -263,14 +271,15 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th>QR</th><th>UID</th><th>Lagerort</th><th>Gekauft am</th> <th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th>
<th>Garantie bis</th><th>Gekauft bei</th><th>Notiz</th><th></th> <th>Gekauft am</th><th>Garantie bis</th><th>Gekauft bei</th>
<th>Notiz</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map((it) => ( {items.map((it) => (
<Fragment key={it.id}> <Fragment key={it.id}>
<tr className="einzel-mainrow"> <tr className={`einzel-mainrow ${verleihRowClass(it.loan)}`.trim()}>
<td><QrImg text={itemDeepLink(it.uid)} size={56} /></td> <td><QrImg text={itemDeepLink(it.uid)} size={56} /></td>
<td data-label="UID" className="strong nowrap">{it.uid}</td> <td data-label="UID" className="strong nowrap">{it.uid}</td>
<td data-label="Lagerort"> <td data-label="Lagerort">
@@ -282,6 +291,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</select> </select>
) : <span>{ortName(it.location_id) || "–"}</span>} ) : <span>{ortName(it.location_id) || "–"}</span>}
</td> </td>
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
<td data-label="Gekauft am"> <td data-label="Gekauft am">
{isAdmin ? ( {isAdmin ? (
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }} <input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
@@ -311,24 +321,35 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
) : <span>{it.note || "–"}</span>} ) : <span>{it.note || "–"}</span>}
</td> </td>
<td className="num"> <td className="num">
{isAdmin && ( <div className="field-inline" style={{ justifyContent: "flex-end" }}>
<div className="field-inline" style={{ justifyContent: "flex-end" }}> {/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */}
<button className="btn-icon" title="Mit Grund entfernen" <VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}> onError={onError} />
<Icon name="checkout" size={16} /> {isAdmin && (
</button> <>
<button className="btn-icon danger" title="Löschen (Korrektur)" <button className="btn-icon" title="Mit Grund entfernen"
onClick={() => loeschen(it)}> onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
<Icon name="trash" size={16} /> <Icon name="checkout" size={16} />
</button> </button>
</div> <button className="btn-icon danger" title="Löschen (Korrektur)"
)} onClick={() => loeschen(it)}>
<Icon name="trash" size={16} />
</button>
</>
)}
</div>
</td> </td>
</tr> </tr>
<tr className="einzel-detailrow"> <tr className="einzel-detailrow">
<td colSpan={8}> <td colSpan={9}>
<div style={{ display: "grid", gap: "var(--sp-4)", <div style={{ display: "grid", gap: "var(--sp-4)",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}> gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
<div>
<span className="muted small">Verleih</span>
<div style={{ marginTop: "var(--sp-2)" }}>
<VerleihHistorie item={it} onError={onError} />
</div>
</div>
<label style={{ margin: 0 }}>Kaufpreis <label style={{ margin: 0 }}>Kaufpreis
<div className="field-inline"> <div className="field-inline">
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0" <input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
@@ -409,7 +430,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</tr> </tr>
</Fragment> </Fragment>
))} ))}
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>} {items.length === 0 && <tr><td colSpan={9} className="empty">Noch keine Einzelstücke.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -506,6 +527,11 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</form> </form>
)} )}
{lend && (
<VerleihForm item={lend} onError={onError} onCancel={() => setLend(null)}
onDone={async () => { setLend(null); await nachAktion(); }} />
)}
{isAdmin && remove && ( {isAdmin && remove && (
<form onSubmit={doRemove} className="card-sub" style={{ marginTop: "var(--sp-3)" }}> <form onSubmit={doRemove} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="trash" /><h3>Einzelstück {remove.item.uid} entfernen</h3> <div className="card-head"><Icon name="trash" /><h3>Einzelstück {remove.item.uid} entfernen</h3>
+9
View File
@@ -62,6 +62,15 @@ const PATHS = {
<circle cx="12" cy="10" r="3" /> <circle cx="12" cy="10" r="3" />
</> </>
), ),
// Verleih: Paket auf offener Hand – gibt etwas heraus, ohne es abzugeben.
loan: (
<>
<rect x="7" y="3" width="10" height="7" rx="1" />
<path d="M12 3v7" />
<path d="M3 14h4l2.5 2.5" />
<path d="M7 14c1.5 0 2.7.5 3.8 1.2l1.4.9c.6.4 1.4.4 2 0l3.3-2c.8-.5 1.9-.3 2.4.5.5.8.3 1.9-.5 2.4l-5.2 3.3c-.9.6-2 .7-3 .3L7 18.5" />
</>
),
cart: ( cart: (
<> <>
<circle cx="8" cy="21" r="1" /> <circle cx="8" cy="21" r="1" />
+181
View File
@@ -0,0 +1,181 @@
import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { useToast } from "../toast";
import { daysUntil, relativeExpiry } from "../units";
/**
* Verleih eines Einzelstücks – Anzeige und Aktionen, an drei Stellen benutzt:
* in der Einzelstück-Tabelle eines Artikels, in der Liste aller Einzelstücke und
* auf der Scan-Zielseite (/i/<uid>).
*
* Der Lagerort wird dabei nie angetastet: Er sagt weiter, wohin das Stück nach
* der Rückgabe zurückgehört. Verliehen zählt es nur nicht mehr als Bestand.
*/
/** Fällig-Text mit Zustandsfarbe: rot überfällig, gelb demnächst, sonst schlicht. */
export function FristBadge({ loan, warnTage = 7 }) {
if (!loan || !loan.due_on) return null;
const tage = daysUntil(loan.due_on);
const art = loan.days_overdue ? "danger" : (tage != null && tage <= warnTage ? "warn" : null);
const text = loan.days_overdue
? `überfällig (${relativeExpiry(-loan.days_overdue)})`
: `zurück ${relativeExpiry(tage)}`;
return art
? <span className={`badge ${art}`}>{text}</span>
: <span className="muted small">{text}</span>;
}
/** Einzeiler „verliehen an X" mit Frist – oder ein Strich, wenn das Stück da ist. */
export function VerleihText({ loan }) {
if (!loan) return <span className="muted">–</span>;
return (
<span className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
<span className="strong">{loan.borrower}</span>
<FristBadge loan={loan} />
</span>
);
}
/** Zeilenklasse für Tabellen: überfällig rot, demnächst fällig gelb. */
export function verleihRowClass(loan, warnTage = 7) {
if (!loan) return "";
if (loan.days_overdue) return "row-danger";
const tage = daysUntil(loan.due_on);
return tage != null && tage <= warnTage ? "row-warn" : "";
}
const LEER = { borrower: "", reason: "", due_on: "" };
/**
* Formular zum Herausgeben. Nur der Name ist Pflicht; Grund und Rückgabedatum
* sind bewusst optional – „kurz mal mitgenommen" soll nicht an einem
* Datumszwang scheitern.
*/
export function VerleihForm({ item, onDone, onCancel, onError }) {
const toast = useToast();
const [form, setForm] = useState(LEER);
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
if (!form.borrower.trim()) { onError("Bitte einen Namen angeben."); return; }
setBusy(true);
try {
await api.lendItem(item.id, {
borrower: form.borrower.trim(),
reason: form.reason.trim() || null,
due_on: form.due_on || null,
});
setForm(LEER);
toast(`${item.uid} an ${form.borrower.trim()} verliehen.`);
if (onDone) await onDone();
} catch (err) { onError(err.message); } finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head">
<Icon name="loan" /><h3>Einzelstück {item.uid} verleihen</h3>
{onCancel && (
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
title="Schließen" onClick={onCancel}>
<Icon name="close" size={16} />
</button>
)}
</div>
<div className="row">
<label className="grow">Verliehen an
<input autoFocus value={form.borrower} placeholder="z.B. Markus"
onChange={(e) => setForm({ ...form, borrower: e.target.value })} />
</label>
<label className="grow">Grund (optional)
<input value={form.reason} placeholder="z.B. Hochzeit"
onChange={(e) => setForm({ ...form, reason: e.target.value })} />
</label>
<label className="grow">Zurück bis (optional)
<input type="date" value={form.due_on}
onChange={(e) => setForm({ ...form, due_on: e.target.value })} />
</label>
</div>
<p className="muted small">
Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. Als
verliehen zählt es aber nicht mehr zum Bestand.
</p>
<button className="btn primary" disabled={busy}>
<Icon name="loan" size={16} />{busy ? "Wird gespeichert…" : "Verleihen"}
</button>
</form>
);
}
/**
* Knopf-Paar für eine Tabellenzeile: verleihen bzw. zurücknehmen. Das Formular
* öffnet der Aufrufer, damit es in seiner Tabelle an der richtigen Stelle steht.
*/
export function VerleihAktion({ item, onLend, onChanged, onError, size = 16 }) {
const toast = useToast();
const [busy, setBusy] = useState(false);
async function zurueck() {
setBusy(true);
try {
await api.returnItem(item.id);
toast(`${item.uid} ist zurück.`);
if (onChanged) await onChanged();
} catch (err) { onError(err.message); } finally { setBusy(false); }
}
if (item.loan) {
return (
<button type="button" className="btn-icon" disabled={busy}
title={`Zurücknehmen (von ${item.loan.borrower})`} onClick={zurueck}>
<Icon name="checkin" size={size} />
</button>
);
}
return (
<button type="button" className="btn-icon" title="Verleihen"
onClick={() => onLend(item)}>
<Icon name="loan" size={size} />
</button>
);
}
/** Verleih-Historie eines Stücks (neueste zuerst), bei Bedarf nachgeladen. */
export function VerleihHistorie({ item, onError }) {
const [zeilen, setZeilen] = useState(null);
const [offen, setOffen] = useState(false);
async function laden() {
setOffen(true);
try { setZeilen(await api.itemLoans(item.id)); }
catch (err) { onError(err.message); }
}
if (!offen) {
return (
<button type="button" className="btn sm ghost" onClick={laden}>
<Icon name="history" size={14} />Verleih-Verlauf
</button>
);
}
if (zeilen == null) return <span className="muted small">Wird geladen…</span>;
if (zeilen.length === 0) return <span className="muted small">Noch nie verliehen.</span>;
return (
<ul className="clean-list">
{zeilen.map((v) => (
<li key={v.id} className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
<span className="strong">{v.borrower}</span>
<span className="muted small">
{v.lent_on}
{v.returned_on ? ` – ${v.returned_on}` : " – offen"}
{v.reason ? ` · ${v.reason}` : ""}
</span>
{!v.returned_on && <FristBadge loan={v} />}
</li>
))}
</ul>
);
}
+67
View File
@@ -5,6 +5,7 @@ import Icon from "../components/Icon";
import { CardSkeleton } from "../components/Skeleton"; import { CardSkeleton } from "../components/Skeleton";
import ShoppingNeedText from "../components/ShoppingNeedText"; import ShoppingNeedText from "../components/ShoppingNeedText";
import BewegungIcon from "../components/BewegungIcon"; import BewegungIcon from "../components/BewegungIcon";
import { FristBadge, verleihRowClass } from "../components/Verleih";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { import {
articlePrimary, articleSecondary, fmt, relativeExpiry, articlePrimary, articleSecondary, fmt, relativeExpiry,
@@ -396,6 +397,60 @@ function KarteEinkaufsliste({ props: karteProps }) {
/**
* Verliehene Einzelstücke – wer hat was, und ist die Rückgabe schon fällig?
*
* „modus" trennt die beiden nützlichen Blickwinkel: alles Draußene oder nur das
* Überfällige. Stücke ohne Rückgabedatum sind nie fällig und tauchen deshalb im
* Modus „ueberfaellig" nicht auf.
*/
function KarteVerleih({ modus = "alle" }) {
const { daten, fehler } = useDaten(() => api.loans());
const zeilen = (daten || []).filter(
(v) => (modus === "ueberfaellig" ? !!v.days_overdue : true),
);
const leer = modus === "ueberfaellig"
? "Nichts überfällig."
: "Gerade ist nichts verliehen.";
return (
<Zustand fehler={fehler} daten={daten} leer={leer}>
{zeilen.length === 0 ? (
<div className="empty">{leer}</div>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Stück</th><th>Verliehen an</th><th>Zurück</th></tr>
</thead>
<tbody>
{zeilen.map((v) => (
<tr key={v.id} className={verleihRowClass(v)}>
<td data-label="Stück">
<Link to={`/i/${encodeURIComponent(v.item_uid || "")}`}>
{v.product_name || v.item_uid}
</Link>
{v.product_name && <div className="muted small">{v.item_uid}</div>}
</td>
<td data-label="Verliehen an">
{v.borrower}
{v.reason && <div className="muted small">{v.reason}</div>}
</td>
<td data-label="Zurück">
{v.due_on
? <FristBadge loan={v} />
: <span className="muted small">offen</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Zustand>
);
}
function KarteBewegungen() { function KarteBewegungen() {
const { daten, fehler } = useDaten(() => api.listMovements(20)); const { daten, fehler } = useDaten(() => api.listMovements(20));
return ( return (
@@ -655,6 +710,18 @@ export const KARTEN = {
config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }], config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
komponente: KarteEinkaufsliste, komponente: KarteEinkaufsliste,
}, },
loans: {
titel: "Verliehen", icon: "loan", vorschau: "tabelle",
beschreibung: "Welche Einzelstücke gerade draußen sind und bei wem",
standard: { w: 6, h: 5 }, min: { w: 4, h: 4 },
komponente: () => <KarteVerleih modus="alle" />,
},
"loans-overdue": {
titel: "Überfällige Verleihe", icon: "alert", vorschau: "tabelle",
beschreibung: "Nur Stücke, deren Rückgabedatum vorbei ist",
standard: { w: 6, h: 4 }, min: { w: 4, h: 3 },
komponente: () => <KarteVerleih modus="ueberfaellig" />,
},
movements: { movements: {
titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle", titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle",
beschreibung: "Wer hat zuletzt was ein- oder ausgelagert", beschreibung: "Wer hat zuletzt was ein- oder ausgelagert",
+27 -1
View File
@@ -8,6 +8,12 @@ import { ProduktThumb } from "../components/ProduktBild";
import { locationOptions, locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { categoryInfoMap } from "../categoryPath"; import { categoryInfoMap } from "../categoryPath";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel"; 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) – * Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) –
@@ -22,6 +28,7 @@ export default function ItemList() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -71,6 +78,16 @@ export default function ItemList() {
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)} {locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select> </select>
) : (locationPathById(it.location_id, locations) || "–")) }, ) : (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 || "", { key: "shop", header: "Gekauft bei", width: 150, filterText: (it) => it.shop_name || "",
render: (it) => (isAdmin ? ( render: (it) => (isAdmin ? (
<select value={it.shop_id ?? ""} style={{ marginTop: 0 }} <select value={it.shop_id ?? ""} style={{ marginTop: 0 }}
@@ -90,6 +107,10 @@ export default function ItemList() {
render: (it) => <span className="muted">{it.note || "–"}</span> }, render: (it) => <span className="muted">{it.note || "–"}</span> },
{ key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num", { key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> }, 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 ( return (
@@ -103,8 +124,13 @@ export default function ItemList() {
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card"> <div className="card">
<DataTable id="items" columns={columns} rows={items} loading={loading} <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> </div>
{lend && (
<VerleihForm item={lend} onError={setError} onCancel={() => setLend(null)}
onDone={async () => { setLend(null); await load(); }} />
)}
</div> </div>
); );
} }
+113 -17
View File
@@ -1,37 +1,133 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; 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 * Ziel eines gescannten Einzelstück-QR bzw. NFC-Tags (/i/<uid>) – zeigt, was man
* zugehörigen Artikel mit dem Einzelstück im Blick. * 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() { export default function ItemResolve() {
const { uid } = useParams(); const { uid } = useParams();
const navigate = useNavigate(); const toast = useToast();
const [item, setItem] = useState(null);
const [locations, setLocations] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [lend, setLend] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => { const laden = useCallback(async () => {
let ok = true; setLoading(true);
api.itemByUid(uid) try {
.then((item) => { const gefunden = await api.itemByUid(uid);
if (ok) navigate(`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`, setItem(gefunden);
{ replace: true }); setError(null);
}) // Nur für den vollen Lagerort-Pfad („Keller → Regal 2"). Scheitert es,
.catch((err) => { if (ok) setError(err.message); }); // bleibt der Name aus dem Stück selbst stehen.
return () => { ok = false; }; try { setLocations(await api.listLocations()); } catch { /* nicht kritisch */ }
}, [uid, navigate]); } 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 ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Einzelstück {uid}</h1> <h1><Icon name="tag" size={20} /> {item?.product_name || `Einzelstück ${uid}`}</h1>
<div className="sub">{error ? error : "Wird geöffnet…"}</div> <div className="sub">
{loading ? "Wird geladen…" : (
<>
<span className="strong">{uid}</span>
{item?.product_brand ? ` · ${item.product_brand}` : ""}
{ort ? ` · ${ort}` : ""}
</>
)}
</div>
</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> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</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> </div>
); );
} }