Verwendungsstatus: Weboberflaeche zum Umstellen und eine Spalte dafuer

Auf der Scan-Zielseite /i/<uid> stehen vier Knoepfe - Verfuegbar, In
Verwendung, In Reinigung, In Reparatur -, der aktuelle hervorgehoben. Ein
Griff genuegt, genau dafuer ist die Seite da. Dazu der Verlauf mit
Zeitstempeln, wer wann umgestellt hat.

Dieselbe Bedienung in der Einzelstueck-Tabelle eines Artikels, und in der
Liste aller Einzelstuecke eine Spalte mit Plakette: gelb in Reinigung, rot
in Reparatur. Filterbar nach allen vier Zustaenden, "Verfuegbar"
eingeschlossen - "was liegt bereit" ist eine echte Frage.

Der Haken je Kategorie steht in der Kategorientabelle und im
Anlegen-Formular. Setzt man ihn an einer Oberkategorie, wandert er in die
Unterkategorien; das sieht man dort auch stehen.

Ohne den Haken erscheint nirgends etwas davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-20 21:57:27 +02:00
co-authored by Claude Opus 5
parent 98e2d18142
commit b9d097fe96
6 changed files with 226 additions and 5 deletions
+6
View File
@@ -236,6 +236,12 @@ export const api = {
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }), lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }), returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
itemLoans: (itemId) => request(`/items/${itemId}/loans`), itemLoans: (itemId) => request(`/items/${itemId}/loans`),
// Verwendungsstatus. Leerer Status = verfügbar. Eigener Endpunkt aus
// demselben Grund wie Zubehör (siehe unten) und weil updateItem Admins
// vorbehalten ist – umstellen darf jeder.
setzeVerwendung: (itemId, status) =>
request(`/items/${itemId}/verwendung`, { method: "PUT", body: { status } }),
itemVerwendungen: (itemId) => request(`/items/${itemId}/verwendungen`),
// Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle // Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle
// Felder mit, ein vergessenes Feld würde die Beziehung still kappen. // Felder mit, ein vergessenes Feld würde die Beziehung still kappen.
zubehoerZuordnen: (itemId, childItemId) => zubehoerZuordnen: (itemId, childItemId) =>
+17 -1
View File
@@ -15,6 +15,10 @@ import {
VerleihText, VerleihText,
verleihRowClass, verleihRowClass,
} from "./Verleih"; } from "./Verleih";
import {
VerwendungAktion,
VerwendungHistorie,
} from "./Verwendung";
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v; const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
@@ -322,7 +326,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</> </>
)} )}
<button type="button" className="btn-icon" <button type="button" className="btn-icon"
title={offen.has(it.id) ? "Details zuklappen" : "Kaufdaten, Preis, Belege, Verleih-Verlauf"} title={offen.has(it.id) ? "Details zuklappen" : "Kaufdaten, Preis, Belege, Verleih, Verwendung"}
aria-expanded={offen.has(it.id)} aria-expanded={offen.has(it.id)}
onClick={() => klappe(it.id)}> onClick={() => klappe(it.id)}>
<Icon name="chevron" size={18} className={offen.has(it.id) ? "" : "rot-90"} /> <Icon name="chevron" size={18} className={offen.has(it.id) ? "" : "rot-90"} />
@@ -363,6 +367,18 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<VerleihHistorie item={it} onError={onError} /> <VerleihHistorie item={it} onError={onError} />
</div> </div>
</div> </div>
{it.verwendung_moeglich && (
<div>
<span className="muted small">Verwendung</span>
<div style={{ marginTop: "var(--sp-2)" }}>
<VerwendungAktion item={it} onChanged={nachAktion}
onError={onError} />
<div style={{ marginTop: "var(--sp-2)" }}>
<VerwendungHistorie item={it} onError={onError} />
</div>
</div>
</div>
)}
<Zubehoer item={it} isAdmin={isAdmin} <Zubehoer item={it} isAdmin={isAdmin}
onChanged={nachAktion} onError={onError} /> onChanged={nachAktion} onError={onError} />
<label style={{ margin: 0 }}>Kaufpreis <label style={{ margin: 0 }}>Kaufpreis
+142
View File
@@ -0,0 +1,142 @@
import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { useToast } from "../toast";
/**
* Verwendungsstatus eines Einzelstücks – Anzeige und Umstellen, an drei Stellen
* benutzt: in der Einzelstück-Tabelle eines Artikels, in der Liste aller
* Einzelstücke und auf der Scan-Zielseite (/i/<uid>).
*
* Woran ist das Stück gerade? Der Lagerort sagt nur, wohin es gehört. Gedacht
* für eine Garderobe mit eingenähten NFC-Tags, aber bewusst allgemein: ein Auto
* in der Waschanlage ist genauso „in Reinigung".
*
* Erscheint nur bei Kategorien, die den Status führen (`verwendung_moeglich`).
* „Verfügbar" ist kein gespeicherter Wert, sondern das Fehlen eines laufenden
* Zeitraums – deshalb ist der Status hier überall `null`.
*/
/** Die vier Zustände in der Reihenfolge, in der sie als Knöpfe erscheinen. */
export const ZUSTAENDE = [
{ status: null, label: "Verfügbar" },
{ status: "in_use", label: "In Verwendung" },
{ status: "cleaning", label: "In Reinigung" },
{ status: "repair", label: "In Reparatur" },
];
const LABEL = { in_use: "In Verwendung", cleaning: "In Reinigung", repair: "In Reparatur" };
// Rot = da muss jemand ran, Gelb = ist unterwegs, schlicht = in Benutzung.
const ART = { cleaning: "warn", repair: "danger" };
export function verwendungLabel(status) {
return LABEL[status] || "Verfügbar";
}
/** Zeitpunkt kurz: „14.03.2026, 18:42". Anders als bei Daten steht hier ein
* vollständiger Zeitstempel – ein Hemd wechselt mehrmals am Tag. */
function zeitpunkt(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
/** „seit heute" / „seit 3 Tagen" – die Frage im Alltag ist die Dauer. */
function seit(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const tage = Math.floor((Date.now() - d.getTime()) / 86400000);
if (tage <= 0) return "seit heute";
return `seit ${tage} ${tage === 1 ? "Tag" : "Tagen"}`;
}
/** Der aktuelle Zustand als Plakette – für Tabellenspalten. */
export function VerwendungBadge({ item }) {
if (!item?.verwendung_moeglich) return <span className="muted">–</span>;
const status = item.verwendung?.status || null;
if (!status) return <span className="muted">Verfügbar</span>;
const art = ART[status];
return (
<span className={art ? `badge ${art}` : "badge"} title={seit(item.verwendung.started_at)}>
{verwendungLabel(status)}
</span>
);
}
/**
* Die vier Knöpfe, der aktuelle hervorgehoben. Ein Griff genügt – gedacht für
* „gerade aus der Wäsche geholt, ist wieder im Schrank" direkt nach dem Scannen.
*/
export function VerwendungAktion({ item, onChanged, onError, size = "sm" }) {
const toast = useToast();
const [busy, setBusy] = useState(false);
if (!item?.verwendung_moeglich) return null;
const aktuell = item.verwendung?.status || null;
async function setze(status) {
if (status === aktuell) return;
setBusy(true);
try {
await api.setzeVerwendung(item.id, status);
toast(`${item.uid}: ${verwendungLabel(status)}`);
await onChanged?.();
} catch (err) { onError?.(err.message); } finally { setBusy(false); }
}
return (
<div className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
{ZUSTAENDE.map(({ status, label }) => (
<button key={status || "frei"} type="button" disabled={busy}
className={`btn ${size} ${status === aktuell ? "primary" : "ghost"}`}
aria-pressed={status === aktuell}
onClick={() => setze(status)}>
{label}
</button>
))}
</div>
);
}
/** Verwendungs-Verlauf eines Stücks (neueste zuerst), bei Bedarf nachgeladen. */
export function VerwendungHistorie({ item, onError }) {
const [zeilen, setZeilen] = useState(null);
const [offen, setOffen] = useState(false);
if (!item?.verwendung_moeglich) return null;
async function laden() {
setOffen(true);
try { setZeilen(await api.itemVerwendungen(item.id)); }
catch (err) { onError?.(err.message); }
}
if (!offen) {
return (
<button type="button" className="btn sm ghost" onClick={laden}>
<Icon name="history" size={14} />Verwendungs-Verlauf
</button>
);
}
if (zeilen == null) return <span className="muted small">Wird geladen…</span>;
if (zeilen.length === 0) {
return <span className="muted small">War noch nie in Verwendung.</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">{verwendungLabel(v.status)}</span>
<span className="muted small">
{zeitpunkt(v.started_at)}
{v.ended_at ? ` – ${zeitpunkt(v.ended_at)}` : " – läuft"}
{v.username ? ` · ${v.username}` : ""}
</span>
</li>
))}
</ul>
);
}
+32 -4
View File
@@ -21,7 +21,9 @@ export default function Categories() {
const confirm = useConfirm(); const confirm = useConfirm();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" }); const [form, setForm] = useState({
name: "", parent_id: "", tracking: "", usage_tracking: false,
});
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Welche Kategorie ihre Seitenkarte zeigt und welche davon: alles in der // Welche Kategorie ihre Seitenkarte zeigt und welche davon: alles in der
// URL, damit ein Neuladen dieselbe Ansicht wiederherstellt. Gespeichert // URL, damit ein Neuladen dieselbe Ansicht wiederherstellt. Gespeichert
@@ -68,8 +70,14 @@ export default function Categories() {
name: form.name, name: form.name,
parent_id: form.parent_id === "" ? null : Number(form.parent_id), parent_id: form.parent_id === "" ? null : Number(form.parent_id),
tracking: form.tracking === "" ? null : form.tracking, tracking: form.tracking === "" ? null : form.tracking,
// Nur mitschicken, wenn angehakt: sonst erbt eine Unterkategorie die
// Markierung ihrer Oberkategorie (null = erben, siehe Backend).
usage_tracking: form.usage_tracking ? true : null,
});
setForm({
name: "", parent_id: form.parent_id, tracking: form.tracking,
usage_tracking: form.usage_tracking,
}); });
setForm({ name: "", parent_id: form.parent_id, tracking: form.tracking });
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -137,6 +145,15 @@ export default function Categories() {
<option value="object">Gegenstand</option> <option value="object">Gegenstand</option>
</select> </select>
) : <span className="badge">{MODUS[c.tracking] || c.tracking}</span>) }, ) : <span className="badge">{MODUS[c.tracking] || c.tracking}</span>) },
// Ein Haken hier wandert in den ganzen Unterbaum (der Server tut das,
// siehe routers/categories.py) – „Kleidung" anhaken genuegt also.
{ key: "verwendung", header: "Verwendungsstatus", width: 170, align: "num",
filterText: (c) => (c.usage_tracking ? "Ja" : "Nein"),
sortValue: (c) => (c.usage_tracking ? 1 : 0),
render: (c) => (isAdmin ? (
<input type="checkbox" checked={!!c.usage_tracking}
onChange={(e) => patch(c, { usage_tracking: e.target.checked })} />
) : (c.usage_tracking ? <Icon name="check" size={16} /> : <span className="muted">–</span>)) },
{ key: "parent", header: "Übergeordnet", width: 220, { key: "parent", header: "Übergeordnet", width: 220,
filterText: (c) => (c.parent_id ? nameById[c.parent_id] || "" : ""), filterText: (c) => (c.parent_id ? nameById[c.parent_id] || "" : ""),
render: (c) => { render: (c) => {
@@ -229,10 +246,21 @@ export default function Categories() {
<option value="object">Gegenstand (Menge je Lagerort)</option> <option value="object">Gegenstand (Menge je Lagerort)</option>
</select> </select>
</label> </label>
<label className="check-inline">
<input type="checkbox" checked={form.usage_tracking}
onChange={(e) => setForm({ ...form, usage_tracking: e.target.checked })} />
<span>Verwendungsstatus führen</span>
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button> <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small"> <p className="muted small">
Unterkategorien erben Art und Felder der Oberkategorie. Auf eine Unterkategorien erben Art, Felder und Verwendungsstatus der
Oberkategorie gefiltert erscheinen auch die Artikel ihrer Unterkategorien. Oberkategorie. Auf eine Oberkategorie gefiltert erscheinen auch
die Artikel ihrer Unterkategorien.
</p>
<p className="muted small">
Verwendungsstatus: Einzelstücke dieser Kategorie führen, woran
sie gerade sind – getragen, in Reinigung, in Reparatur. Gedacht
für Kleidung und Werkzeug, nicht für Vorräte.
</p> </p>
</form> </form>
)} )}
+13
View File
@@ -14,6 +14,7 @@ import {
VerleihText, VerleihText,
verleihRowClass, verleihRowClass,
} from "../components/Verleih"; } from "../components/Verleih";
import { VerwendungBadge, verwendungLabel } from "../components/Verwendung";
/** /**
* Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) – * Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) –
@@ -84,6 +85,18 @@ export default function ItemList() {
filterOptionLabel: (v) => (v === "" ? "(im Haus)" : v), filterOptionLabel: (v) => (v === "" ? "(im Haus)" : v),
sortValue: (it) => it.loan?.borrower || "", sortValue: (it) => it.loan?.borrower || "",
render: (it) => <VerleihText loan={it.loan} /> }, render: (it) => <VerleihText loan={it.loan} /> },
// Nur bei Kategorien, die ihn fuehren – sonst steht hier ein Strich.
// „Verfuegbar" ist das Fehlen eines Zeitraums, taucht in der Werteauswahl
// aber als eigener Eintrag auf: „was liegt bereit?" ist eine echte Frage.
{ key: "verwendung", header: "Verwendung", width: 150,
filterText: (it) => (it.verwendung_moeglich
? verwendungLabel(it.verwendung?.status || null) : ""),
filterValues: (it) => [it.verwendung_moeglich
? verwendungLabel(it.verwendung?.status || null) : ""],
filterOptionLabel: (v) => (v === "" ? "(ohne Verwendungsstatus)" : v),
sortValue: (it) => (it.verwendung_moeglich
? verwendungLabel(it.verwendung?.status || null) : ""),
render: (it) => <VerwendungBadge item={it} /> },
{ key: "due", header: "Zurück bis", width: 120, { key: "due", header: "Zurück bis", width: 120,
filterText: (it) => it.loan?.due_on || "", filterText: (it) => it.loan?.due_on || "",
sortValue: (it) => it.loan?.due_on || "", sortValue: (it) => it.loan?.due_on || "",
+16
View File
@@ -4,6 +4,7 @@ import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { FristBadge, VerleihForm, VerleihHistorie } from "../components/Verleih"; import { FristBadge, VerleihForm, VerleihHistorie } from "../components/Verleih";
import { VerwendungAktion, VerwendungHistorie, verwendungLabel } from "../components/Verwendung";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { locationPathById } from "../locationPath"; import { locationPathById } from "../locationPath";
@@ -64,6 +65,9 @@ export default function ItemResolve() {
<span className="strong">{uid}</span> <span className="strong">{uid}</span>
{item?.product_brand ? ` · ${item.product_brand}` : ""} {item?.product_brand ? ` · ${item.product_brand}` : ""}
{ort ? ` · ${ort}` : ""} {ort ? ` · ${ort}` : ""}
{item?.verwendung_moeglich
? ` · ${verwendungLabel(item.verwendung?.status || null)}`
: ""}
</> </>
)} )}
</div> </div>
@@ -95,6 +99,15 @@ export default function ItemResolve() {
</Link> </Link>
</div> </div>
{item.verwendung_moeglich && (
<div style={{ marginBottom: "var(--sp-3)" }}>
<div className="muted small" style={{ marginBottom: "var(--sp-1)" }}>
Verwendung
</div>
<VerwendungAktion item={item} onError={setError} onChanged={laden} />
</div>
)}
<dl className="kv"> <dl className="kv">
<dt>Lagerort</dt> <dt>Lagerort</dt>
<dd> <dd>
@@ -121,6 +134,9 @@ export default function ItemResolve() {
<div style={{ marginTop: "var(--sp-3)" }}> <div style={{ marginTop: "var(--sp-3)" }}>
<VerleihHistorie item={item} onError={setError} /> <VerleihHistorie item={item} onError={setError} />
</div> </div>
<div style={{ marginTop: "var(--sp-2)" }}>
<VerwendungHistorie item={item} onError={setError} />
</div>
</div> </div>
)} )}