From 1332dd86fa532592783bd59c3258a04c1e97c59a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 17 Sep 2026 09:30:14 +0200 Subject: [PATCH] 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/ 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/, 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 --- README.md | 5 + docs/ROADMAP.md | 11 +- web/src/api.js | 17 +++ web/src/components/Einzelstuecke.jsx | 60 ++++++--- web/src/components/Icon.jsx | 9 ++ web/src/components/Verleih.jsx | 181 +++++++++++++++++++++++++++ web/src/dashboard/cards.jsx | 67 ++++++++++ web/src/pages/ItemList.jsx | 28 ++++- web/src/pages/ItemResolve.jsx | 130 ++++++++++++++++--- 9 files changed, 472 insertions(+), 36 deletions(-) create mode 100644 web/src/components/Verleih.jsx diff --git a/README.md b/README.md index a2fbfa9..be68b33 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**). - **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit **QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und 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 Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe). - **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e27670e..87fef2d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -113,7 +113,16 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. - **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne 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/`) 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/`). +- 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 aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3. diff --git a/web/src/api.js b/web/src/api.js index 7a6d987..bc09f0c 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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 }), diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx index 4c76606..400a823 100644 --- a/web/src/components/Einzelstuecke.jsx +++ b/web/src/components/Einzelstuecke.jsx @@ -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 - - + + + {items.map((it) => ( - + + - } + {items.length === 0 && }
QRUIDLagerortGekauft amGarantie bisGekauft beiNotizQRUIDLagerortVerliehen anGekauft amGarantie bisGekauft beiNotiz
{it.uid} @@ -282,6 +291,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh ) : {ortName(it.location_id) || "–"}} {isAdmin ? ( {it.note || "–"}} - {isAdmin && ( -
- - -
- )} +
+ {/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */} + + {isAdmin && ( + <> + + + + )} +
+
+
+ Verleih +
+ +
+
Noch keine Einzelstücke.
Noch keine Einzelstücke.
@@ -506,6 +527,11 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh )} + {lend && ( + setLend(null)} + onDone={async () => { setLend(null); await nachAktion(); }} /> + )} + {isAdmin && remove && (

Einzelstück {remove.item.uid} entfernen

diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index ae77689..7634c48 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -62,6 +62,15 @@ const PATHS = { ), + // Verleih: Paket auf offener Hand – gibt etwas heraus, ohne es abzugeben. + loan: ( + <> + + + + + + ), cart: ( <> diff --git a/web/src/components/Verleih.jsx b/web/src/components/Verleih.jsx new file mode 100644 index 0000000..273b7c8 --- /dev/null +++ b/web/src/components/Verleih.jsx @@ -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/). + * + * 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 + ? {text} + : {text}; +} + +/** Einzeiler „verliehen an X" mit Frist – oder ein Strich, wenn das Stück da ist. */ +export function VerleihText({ loan }) { + if (!loan) return –; + return ( + + {loan.borrower} + + + ); +} + +/** 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 ( + +
+

Einzelstück {item.uid} verleihen

+ {onCancel && ( + + )} +
+
+ + + +
+

+ Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. Als + verliehen zählt es aber nicht mehr zum Bestand. +

+ + + ); +} + +/** + * 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 ( + + ); + } + return ( + + ); +} + +/** 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 ( + + ); + } + if (zeilen == null) return Wird geladen…; + if (zeilen.length === 0) return Noch nie verliehen.; + + return ( +
    + {zeilen.map((v) => ( +
  • + {v.borrower} + + {v.lent_on} + {v.returned_on ? ` – ${v.returned_on}` : " – offen"} + {v.reason ? ` · ${v.reason}` : ""} + + {!v.returned_on && } +
  • + ))} +
+ ); +} diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 90959c0..119a53c 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -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 ( + + {zeilen.length === 0 ? ( +
{leer}
+ ) : ( +
+ + + + + + {zeilen.map((v) => ( + + + + + + ))} + +
StückVerliehen anZurück
+ + {v.product_name || v.item_uid} + + {v.product_name &&
{v.item_uid}
} +
+ {v.borrower} + {v.reason &&
{v.reason}
} +
+ {v.due_on + ? + : offen} +
+
+ )} +
+ ); +} + 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: () => , + }, + "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: () => , + }, movements: { titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle", beschreibung: "Wer hat zuletzt was ein- oder ausgelagert", diff --git a/web/src/pages/ItemList.jsx b/web/src/pages/ItemList.jsx index d35f8cd..cdc1b7f 100644 --- a/web/src/pages/ItemList.jsx +++ b/web/src/pages/ItemList.jsx @@ -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) => )} ) : (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) => }, + { 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 ? (