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:
co-authored by
Claude Opus 5
parent
98e2d18142
commit
b9d097fe96
@@ -236,6 +236,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`),
|
||||
// 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
|
||||
// Felder mit, ein vergessenes Feld würde die Beziehung still kappen.
|
||||
zubehoerZuordnen: (itemId, childItemId) =>
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
VerleihText,
|
||||
verleihRowClass,
|
||||
} from "./Verleih";
|
||||
import {
|
||||
VerwendungAktion,
|
||||
VerwendungHistorie,
|
||||
} from "./Verwendung";
|
||||
|
||||
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"
|
||||
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)}
|
||||
onClick={() => klappe(it.id)}>
|
||||
<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} />
|
||||
</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}
|
||||
onChanged={nachAktion} onError={onError} />
|
||||
<label style={{ margin: 0 }}>Kaufpreis
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,9 @@ export default function Categories() {
|
||||
const confirm = useConfirm();
|
||||
const { isAdmin } = useAuth();
|
||||
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);
|
||||
// Welche Kategorie ihre Seitenkarte zeigt und welche davon: alles in der
|
||||
// URL, damit ein Neuladen dieselbe Ansicht wiederherstellt. Gespeichert
|
||||
@@ -68,8 +70,14 @@ export default function Categories() {
|
||||
name: form.name,
|
||||
parent_id: form.parent_id === "" ? null : Number(form.parent_id),
|
||||
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();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -137,6 +145,15 @@ export default function Categories() {
|
||||
<option value="object">Gegenstand</option>
|
||||
</select>
|
||||
) : <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,
|
||||
filterText: (c) => (c.parent_id ? nameById[c.parent_id] || "" : ""),
|
||||
render: (c) => {
|
||||
@@ -229,10 +246,21 @@ export default function Categories() {
|
||||
<option value="object">Gegenstand (Menge je Lagerort)</option>
|
||||
</select>
|
||||
</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>
|
||||
<p className="muted small">
|
||||
Unterkategorien erben Art und Felder der Oberkategorie. Auf eine
|
||||
Oberkategorie gefiltert erscheinen auch die Artikel ihrer Unterkategorien.
|
||||
Unterkategorien erben Art, Felder und Verwendungsstatus der
|
||||
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>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VerleihText,
|
||||
verleihRowClass,
|
||||
} from "../components/Verleih";
|
||||
import { VerwendungBadge, verwendungLabel } from "../components/Verwendung";
|
||||
|
||||
/**
|
||||
* 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),
|
||||
sortValue: (it) => it.loan?.borrower || "",
|
||||
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,
|
||||
filterText: (it) => it.loan?.due_on || "",
|
||||
sortValue: (it) => it.loan?.due_on || "",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { ProduktThumb } from "../components/ProduktBild";
|
||||
import { FristBadge, VerleihForm, VerleihHistorie } from "../components/Verleih";
|
||||
import { VerwendungAktion, VerwendungHistorie, verwendungLabel } from "../components/Verwendung";
|
||||
import { useToast } from "../toast";
|
||||
import { locationPathById } from "../locationPath";
|
||||
|
||||
@@ -64,6 +65,9 @@ export default function ItemResolve() {
|
||||
<span className="strong">{uid}</span>
|
||||
{item?.product_brand ? ` · ${item.product_brand}` : ""}
|
||||
{ort ? ` · ${ort}` : ""}
|
||||
{item?.verwendung_moeglich
|
||||
? ` · ${verwendungLabel(item.verwendung?.status || null)}`
|
||||
: ""}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -95,6 +99,15 @@ export default function ItemResolve() {
|
||||
</Link>
|
||||
</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">
|
||||
<dt>Lagerort</dt>
|
||||
<dd>
|
||||
@@ -121,6 +134,9 @@ export default function ItemResolve() {
|
||||
<div style={{ marginTop: "var(--sp-3)" }}>
|
||||
<VerleihHistorie item={item} onError={setError} />
|
||||
</div>
|
||||
<div style={{ marginTop: "var(--sp-2)" }}>
|
||||
<VerwendungHistorie item={item} onError={setError} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user