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
@@ -227,6 +227,23 @@ export const api = {
|
||||
deleteItemDocument: (itemId, docId) =>
|
||||
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
|
||||
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||||
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),
|
||||
|
||||
@@ -7,6 +7,13 @@ import Icon from "./Icon";
|
||||
import { REASONS } from "./ObjektBestand";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { itemDeepLink } from "../qr";
|
||||
import {
|
||||
VerleihAktion,
|
||||
VerleihForm,
|
||||
VerleihHistorie,
|
||||
VerleihText,
|
||||
verleihRowClass,
|
||||
} from "./Verleih";
|
||||
|
||||
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 [addForm, setAddForm] = useState(EMPTY_ADD);
|
||||
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 [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
|
||||
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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Gekauft am</th>
|
||||
<th>Garantie bis</th><th>Gekauft bei</th><th>Notiz</th><th></th>
|
||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th>
|
||||
<th>Gekauft am</th><th>Garantie bis</th><th>Gekauft bei</th>
|
||||
<th>Notiz</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<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 data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||
<td data-label="Lagerort">
|
||||
@@ -282,6 +291,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
</select>
|
||||
) : <span>{ortName(it.location_id) || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
|
||||
<td data-label="Gekauft am">
|
||||
{isAdmin ? (
|
||||
<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>}
|
||||
</td>
|
||||
<td className="num">
|
||||
{isAdmin && (
|
||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="btn-icon" title="Mit Grund entfernen"
|
||||
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
||||
<Icon name="checkout" size={16} />
|
||||
</button>
|
||||
<button className="btn-icon danger" title="Löschen (Korrektur)"
|
||||
onClick={() => loeschen(it)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||
{/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */}
|
||||
<VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
|
||||
onError={onError} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button className="btn-icon" title="Mit Grund entfernen"
|
||||
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
||||
<Icon name="checkout" size={16} />
|
||||
</button>
|
||||
<button className="btn-icon danger" title="Löschen (Korrektur)"
|
||||
onClick={() => loeschen(it)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="einzel-detailrow">
|
||||
<td colSpan={8}>
|
||||
<td colSpan={9}>
|
||||
<div style={{ display: "grid", gap: "var(--sp-4)",
|
||||
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
|
||||
<div className="field-inline">
|
||||
<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>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -506,6 +527,11 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
</form>
|
||||
)}
|
||||
|
||||
{lend && (
|
||||
<VerleihForm item={lend} onError={onError} onCancel={() => setLend(null)}
|
||||
onDone={async () => { setLend(null); await nachAktion(); }} />
|
||||
)}
|
||||
|
||||
{isAdmin && remove && (
|
||||
<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>
|
||||
|
||||
@@ -62,6 +62,15 @@ const PATHS = {
|
||||
<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: (
|
||||
<>
|
||||
<circle cx="8" cy="21" r="1" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Icon from "../components/Icon";
|
||||
import { CardSkeleton } from "../components/Skeleton";
|
||||
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||
import BewegungIcon from "../components/BewegungIcon";
|
||||
import { FristBadge, verleihRowClass } from "../components/Verleih";
|
||||
import { useSettings } from "../settings";
|
||||
import {
|
||||
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() {
|
||||
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
||||
return (
|
||||
@@ -655,6 +710,18 @@ export const KARTEN = {
|
||||
config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
|
||||
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: {
|
||||
titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle",
|
||||
beschreibung: "Wer hat zuletzt was ein- oder ausgelagert",
|
||||
|
||||
@@ -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