Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.
Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.
In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.
Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.
ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.
Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.
Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.
Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.
In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.
12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca4e9a2264
commit
a3fd9819c4
@@ -27,6 +27,7 @@ import Units from "./pages/Units";
|
||||
import Users from "./pages/Users";
|
||||
import ShoppingList from "./pages/ShoppingList";
|
||||
import History from "./pages/History";
|
||||
import Verliehen from "./pages/Verliehen";
|
||||
import Transfer from "./pages/Transfer";
|
||||
import Settings from "./pages/Settings";
|
||||
import Konto from "./pages/Konto";
|
||||
@@ -87,6 +88,7 @@ function Sidebar() {
|
||||
<NavItem to="/chargen" icon="box" label="Chargen" />
|
||||
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||||
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
|
||||
<NavItem to="/verliehen" icon="loan" label="Verliehen" />
|
||||
<NavItem to="/history" icon="history" label="Verlauf" />
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -190,6 +192,9 @@ export default function App() {
|
||||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
|
||||
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
|
||||
und Auslagern, keine Stammdatenänderung. */}
|
||||
<Route path="/verliehen" element={<Protected><Verliehen /></Protected>} />
|
||||
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
|
||||
<Route path="/konto" element={<Protected><Konto /></Protected>} />
|
||||
|
||||
@@ -232,6 +232,12 @@ export const api = {
|
||||
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`),
|
||||
// Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle
|
||||
// Felder mit, ein vergessenes Feld würde die Beziehung still kappen.
|
||||
zubehoerZuordnen: (itemId, childItemId) =>
|
||||
request(`/items/${itemId}/zubehoer`, { method: "POST", body: { child_item_id: childItemId } }),
|
||||
zubehoerLoesen: (itemId, childItemId) =>
|
||||
request(`/items/${itemId}/zubehoer/${childItemId}`, { method: "DELETE" }),
|
||||
// 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 } = {}) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api, authorizedObjectUrl } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "./Icon";
|
||||
import Zubehoer from "./Zubehoer";
|
||||
import { REASONS } from "./ObjektBestand";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { itemDeepLink } from "../qr";
|
||||
@@ -362,6 +363,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
<VerleihHistorie item={it} onError={onError} />
|
||||
</div>
|
||||
</div>
|
||||
<Zubehoer item={it} isAdmin={isAdmin}
|
||||
onChanged={nachAktion} onError={onError} />
|
||||
<label style={{ margin: 0 }}>Kaufpreis
|
||||
<div className="field-inline">
|
||||
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useToast } from "../toast";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { daysUntil, relativeExpiry } from "../units";
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,14 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(LEER);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Zubehör geht nur mit, wenn es angehakt ist. Vorausgewählt sind die Teile,
|
||||
// die gerade im Haus sind – ein schon anderweitig verliehenes kann nicht mit.
|
||||
const verfuegbaresZubehoer = (item.zubehoer || []).filter((t) => !t.verliehen);
|
||||
const [mit, setMit] = useState(() => verfuegbaresZubehoer.map((t) => t.id));
|
||||
|
||||
function umschalten(id) {
|
||||
setMit((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
@@ -68,6 +77,7 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
borrower: form.borrower.trim(),
|
||||
reason: form.reason.trim() || null,
|
||||
due_on: form.due_on || null,
|
||||
mit_zubehoer: mit,
|
||||
});
|
||||
setForm(LEER);
|
||||
toast(`${item.uid} an ${form.borrower.trim()} verliehen.`);
|
||||
@@ -100,6 +110,19 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
onChange={(e) => setForm({ ...form, due_on: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
{verfuegbaresZubehoer.length > 0 && (
|
||||
<fieldset className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<legend className="small strong">Zubehör mitgeben?</legend>
|
||||
{verfuegbaresZubehoer.map((t) => (
|
||||
<label key={t.id} className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<input type="checkbox" checked={mit.includes(t.id)}
|
||||
onChange={() => umschalten(t.id)} />
|
||||
<span>{t.product_name || "Einzelstück"}</span>
|
||||
<span className="muted small">{t.uid}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<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.
|
||||
@@ -117,29 +140,45 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
*/
|
||||
export function VerleihAktion({ item, onLend, onChanged, onError, size = 16 }) {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function zurueck() {
|
||||
// Mitverliehenes Zubehör kommt zusammen mit dem Gerät zurück – sonst bliebe
|
||||
// das Netzteil ewig „verliehen“, weil niemand mehr danach sucht.
|
||||
const teile = (item.zubehoer || []).filter((t) => t.verliehen);
|
||||
const ok = await confirm({
|
||||
title: `„${item.uid}“ zurücknehmen?`,
|
||||
message: teile.length
|
||||
? `Von ${item.loan.borrower}. Das mitverliehene Zubehör kommt mit zurück: `
|
||||
+ teile.map((t) => t.uid).join(", ") + "."
|
||||
: `Von ${item.loan.borrower}.`,
|
||||
confirmLabel: "Zurücknehmen",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.returnItem(item.id);
|
||||
await api.returnItem(item.id, { mit_zubehoer: teile.map((t) => t.id) });
|
||||
toast(`${item.uid} ist zurück.`);
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Bewusst mit Text statt als nacktes Symbol: In einer breiten Tabelle war der
|
||||
// Knopf sonst ein unbeschriftetes Bildchen ganz rechts, und das title-Attribut
|
||||
// erreicht auf einem Touchgerät niemanden.
|
||||
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 type="button" className="btn sm" disabled={busy} onClick={zurueck}
|
||||
aria-label={`„${item.uid}“ von ${item.loan.borrower} zurücknehmen`}>
|
||||
<Icon name="checkin" size={size} />{busy ? "Moment…" : "Zurücknehmen"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className="btn-icon" title="Verleihen"
|
||||
onClick={() => onLend(item)}>
|
||||
<Icon name="loan" size={size} />
|
||||
<button type="button" className="btn sm" onClick={() => onLend(item)}
|
||||
aria-label={`„${item.uid}“ verleihen`}>
|
||||
<Icon name="loan" size={size} />Verleihen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useConfirm } from "../confirm";
|
||||
|
||||
/**
|
||||
* Zubehör eines Einzelstücks: das Netzteil, das zum Laptop gehört.
|
||||
*
|
||||
* Ein Teil hängt an genau einem Gerät, ein Gerät trägt beliebig viele Teile.
|
||||
* Die Zuordnung läuft über eigene Endpunkte und nicht über `updateItem` – dessen
|
||||
* Schema sendet immer alle Felder mit, ein vergessenes Feld würde die Beziehung
|
||||
* still kappen.
|
||||
*/
|
||||
export default function Zubehoer({ item, isAdmin, onChanged, onError }) {
|
||||
const confirm = useConfirm();
|
||||
const [alle, setAlle] = useState(null);
|
||||
const [waehlen, setWaehlen] = useState(false);
|
||||
const [suche, setSuche] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waehlen || alle != null) return;
|
||||
api.listAllItems().then(setAlle).catch((err) => onError(err.message));
|
||||
}, [waehlen, alle, onError]);
|
||||
|
||||
async function zuordnen(teil) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.zubehoerZuordnen(item.id, teil.id);
|
||||
setWaehlen(false);
|
||||
setSuche("");
|
||||
setAlle(null); // nach dem Umhängen stimmen die Zuordnungen nicht mehr
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loesen(teil) {
|
||||
const ok = await confirm({
|
||||
title: `„${teil.uid}“ vom Gerät lösen?`,
|
||||
message: "Das Einzelstück bleibt bestehen, es gehört danach nur zu nichts mehr.",
|
||||
confirmLabel: "Lösen",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.zubehoerLoesen(item.id, teil.id);
|
||||
setAlle(null);
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const teile = item.zubehoer || [];
|
||||
// Weder sich selbst noch die eigenen Teile erneut anbieten. Ringe über mehrere
|
||||
// Stufen weist der Server ab – hier geht es nur darum, Unsinn gar nicht erst
|
||||
// zur Auswahl zu stellen.
|
||||
const kandidaten = (alle || []).filter((k) => {
|
||||
if (k.id === item.id) return false;
|
||||
if (k.gehoert_zu && k.gehoert_zu.id === item.id) return false;
|
||||
const q = suche.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return [k.uid, k.product_name, k.product_brand, k.note]
|
||||
.filter(Boolean).join(" ").toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="muted small">Zubehör</span>
|
||||
|
||||
{item.gehoert_zu && (
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-1)" }}>
|
||||
Gehört zu <a href={`/i/${item.gehoert_zu.uid}`}>
|
||||
{item.gehoert_zu.product_name || "Einzelstück"} ({item.gehoert_zu.uid})
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{teile.length === 0 && !item.gehoert_zu && (
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-1)" }}>Kein Zubehör.</p>
|
||||
)}
|
||||
|
||||
{teile.length > 0 && (
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{teile.map((t) => (
|
||||
<li key={t.id} className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<span>{t.product_name || "Einzelstück"}</span>
|
||||
<span className="muted small">{t.uid}</span>
|
||||
{t.verliehen && <span className="badge warn">verliehen</span>}
|
||||
{isAdmin && (
|
||||
<button type="button" className="btn-icon danger" disabled={busy}
|
||||
onClick={() => loesen(t)} aria-label={`„${t.uid}“ vom Gerät lösen`}
|
||||
title="Vom Gerät lösen">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{isAdmin && !waehlen && (
|
||||
<button type="button" className="btn sm ghost" style={{ marginTop: "var(--sp-2)" }}
|
||||
onClick={() => setWaehlen(true)}>
|
||||
<Icon name="plus" size={14} />Zubehör hinzufügen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isAdmin && waehlen && (
|
||||
<div className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<div className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<input autoFocus value={suche} placeholder="UID oder Artikelname"
|
||||
onChange={(e) => setSuche(e.target.value)} />
|
||||
<button type="button" className="btn-icon" title="Abbrechen"
|
||||
onClick={() => { setWaehlen(false); setSuche(""); }}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{alle == null && <span className="muted small">Wird geladen…</span>}
|
||||
{alle != null && kandidaten.length === 0 && (
|
||||
<span className="muted small">Nichts gefunden.</span>
|
||||
)}
|
||||
<ul className="clean-list">
|
||||
{kandidaten.slice(0, 25).map((k) => (
|
||||
<li key={k.id}>
|
||||
<button type="button" className="btn sm ghost" disabled={busy}
|
||||
onClick={() => zuordnen(k)}>
|
||||
{k.product_name || "Einzelstück"} <span className="muted">{k.uid}</span>
|
||||
{k.gehoert_zu && (
|
||||
<span className="muted small"> · hängt an {k.gehoert_zu.uid}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{alle != null && kandidaten.length > 25 && (
|
||||
<span className="muted small">
|
||||
{kandidaten.length - 25} weitere – bitte genauer suchen.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,7 +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",
|
||||
// Überschrift und Breite sind Absicht: Vorher stand hier ein unbeschriftetes
|
||||
// 16-px-Symbol in einer 52 px schmalen Spalte ohne Titel, ganz rechts in
|
||||
// einer breiten Tabelle – es war schlicht nicht zu finden.
|
||||
{ key: "actions", header: "Verleih", label: "Verleihen", fixed: true, width: 150, align: "num",
|
||||
render: (it) => (
|
||||
<VerleihAktion item={it} onLend={setLend} onChanged={load} onError={setError} />
|
||||
) },
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { FristBadge, verleihRowClass } from "../components/Verleih";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
/**
|
||||
* „Was ist gerade draußen?" – alle offenen Verleihe an einem Ort, mit Rückgabe
|
||||
* direkt aus der Liste.
|
||||
*
|
||||
* Bis hierher gab es diese Frage nur über zwei Dashboard-Karten, und die sind
|
||||
* nicht im Standard-Layout (routers/dashboard.py BUILTIN_LAYOUT) – wer sie nicht
|
||||
* selbst hinzufügte, hatte keine Übersicht. Die Einzelstück-Liste beantwortet
|
||||
* sie auch, aber erst nach Filtern über eine Spalte.
|
||||
*/
|
||||
export default function Verliehen() {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [zeilen, setZeilen] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
try {
|
||||
setZeilen(await api.loans({ openOnly: true }));
|
||||
setError(null);
|
||||
} catch (err) { setError(err.message); setZeilen([]); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { laden(); }, [laden]);
|
||||
|
||||
async function zurueck(loan) {
|
||||
const ok = await confirm({
|
||||
title: `„${loan.item_uid}“ zurücknehmen?`,
|
||||
message: `Von ${loan.borrower}.`,
|
||||
confirmLabel: "Zurücknehmen",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusyId(loan.id);
|
||||
try {
|
||||
// Das Zubehör hängt am Einzelstück, nicht am Verleih – diese Liste kennt
|
||||
// es nicht. Wer ein Gerät samt Teilen zurücknimmt, tut das über das
|
||||
// Stück selbst (/i/<uid>), wo beides zusammen angeboten wird.
|
||||
await api.returnItem(loan.item_id);
|
||||
toast(`${loan.item_uid} ist zurück.`);
|
||||
await laden();
|
||||
} catch (err) { setError(err.message); } finally { setBusyId(null); }
|
||||
}
|
||||
|
||||
const ueberfaellig = (zeilen || []).filter((l) => l.days_overdue).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Verliehen</h1>
|
||||
{zeilen && zeilen.length > 0 && (
|
||||
<span className="muted">
|
||||
{zeilen.length} {zeilen.length === 1 ? "Stück" : "Stücke"} draußen
|
||||
{ueberfaellig > 0 && `, ${ueberfaellig} überfällig`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
{zeilen == null && <div className="muted">Lädt…</div>}
|
||||
|
||||
{zeilen && zeilen.length === 0 && !error && (
|
||||
<div className="card center" style={{ padding: "var(--sp-5)" }}>
|
||||
<Icon name="loan" size={28} />
|
||||
<p className="muted">Nichts verliehen – alles im Haus.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zeilen && zeilen.length > 0 && (
|
||||
<div className="card table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Einzelstück</th>
|
||||
<th>Artikel</th>
|
||||
<th>Verliehen an</th>
|
||||
<th>Seit</th>
|
||||
<th>Zurück bis</th>
|
||||
<th className="num">Verleih</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zeilen.map((l) => (
|
||||
<tr key={l.id} className={verleihRowClass(l)}>
|
||||
<td data-label="Einzelstück">
|
||||
<Link to={`/i/${l.item_uid}`}>{l.item_uid}</Link>
|
||||
</td>
|
||||
<td data-label="Artikel">
|
||||
{l.product_id
|
||||
? <Link to={`/products/${l.product_id}`}>{l.product_name || "Artikel"}</Link>
|
||||
: <span className="muted">–</span>}
|
||||
{l.product_brand && <span className="muted small"> · {l.product_brand}</span>}
|
||||
</td>
|
||||
<td data-label="Verliehen an"><span className="strong">{l.borrower}</span></td>
|
||||
<td data-label="Seit" className="muted">{l.lent_on}</td>
|
||||
<td data-label="Zurück bis">
|
||||
{l.due_on ? <FristBadge loan={l} /> : <span className="muted">ohne Frist</span>}
|
||||
</td>
|
||||
<td data-label="Verleih" className="num">
|
||||
<button type="button" className="btn sm" disabled={busyId === l.id}
|
||||
onClick={() => zurueck(l)}
|
||||
aria-label={`„${l.item_uid}“ von ${l.borrower} zurücknehmen`}>
|
||||
<Icon name="checkin" size={16} />
|
||||
{busyId === l.id ? "Moment…" : "Zurücknehmen"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user