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
@@ -28,6 +28,11 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
|
|||||||
- **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit
|
- **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit
|
||||||
**QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und
|
**QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und
|
||||||
Entfernen mit Grund. Chargen und Einzelstücke sind einem Lagerort zugeordnet.
|
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
|
- **Kategorien** verschachtelt und pro Typ (Lebensmittel/Gegenstand); für
|
||||||
Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe).
|
Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe).
|
||||||
- **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg
|
- **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg
|
||||||
|
|||||||
+10
-1
@@ -113,7 +113,16 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
|
|||||||
- **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den
|
- **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den
|
||||||
Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne
|
Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne
|
||||||
Gegenbuchung würde diese Kurve verfälschen.
|
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/<uid>`) 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/<code>`).
|
||||||
|
- 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
|
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
|
aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die
|
||||||
Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3.
|
Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3.
|
||||||
|
|||||||
@@ -227,6 +227,23 @@ export const api = {
|
|||||||
deleteItemDocument: (itemId, docId) =>
|
deleteItemDocument: (itemId, docId) =>
|
||||||
request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }),
|
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
|
// Bestand
|
||||||
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||||||
checkInBatch: (body) => request("/stock/checkin/batch", { 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 { REASONS } from "./ObjektBestand";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
import { itemDeepLink } from "../qr";
|
import { itemDeepLink } from "../qr";
|
||||||
|
import {
|
||||||
|
VerleihAktion,
|
||||||
|
VerleihForm,
|
||||||
|
VerleihHistorie,
|
||||||
|
VerleihText,
|
||||||
|
verleihRowClass,
|
||||||
|
} from "./Verleih";
|
||||||
|
|
||||||
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
|
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 [items, setItems] = useState([]);
|
||||||
const [addForm, setAddForm] = useState(EMPTY_ADD);
|
const [addForm, setAddForm] = useState(EMPTY_ADD);
|
||||||
const [remove, setRemove] = useState(null); // { item, reason, note } | null
|
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 [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
|
||||||
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
|
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
|
||||||
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
|
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">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Gekauft am</th>
|
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th>
|
||||||
<th>Garantie bis</th><th>Gekauft bei</th><th>Notiz</th><th></th>
|
<th>Gekauft am</th><th>Garantie bis</th><th>Gekauft bei</th>
|
||||||
|
<th>Notiz</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<Fragment key={it.id}>
|
<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><QrImg text={itemDeepLink(it.uid)} size={56} /></td>
|
||||||
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||||
<td data-label="Lagerort">
|
<td data-label="Lagerort">
|
||||||
@@ -282,6 +291,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
</select>
|
</select>
|
||||||
) : <span>{ortName(it.location_id) || "–"}</span>}
|
) : <span>{ortName(it.location_id) || "–"}</span>}
|
||||||
</td>
|
</td>
|
||||||
|
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
|
||||||
<td data-label="Gekauft am">
|
<td data-label="Gekauft am">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
|
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
|
||||||
@@ -311,8 +321,12 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
) : <span>{it.note || "–"}</span>}
|
) : <span>{it.note || "–"}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
{isAdmin && (
|
|
||||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
<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"
|
<button className="btn-icon" title="Mit Grund entfernen"
|
||||||
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
|
||||||
<Icon name="checkout" size={16} />
|
<Icon name="checkout" size={16} />
|
||||||
@@ -321,14 +335,21 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
onClick={() => loeschen(it)}>
|
onClick={() => loeschen(it)}>
|
||||||
<Icon name="trash" size={16} />
|
<Icon name="trash" size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="einzel-detailrow">
|
<tr className="einzel-detailrow">
|
||||||
<td colSpan={8}>
|
<td colSpan={9}>
|
||||||
<div style={{ display: "grid", gap: "var(--sp-4)",
|
<div style={{ display: "grid", gap: "var(--sp-4)",
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
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
|
<label style={{ margin: 0 }}>Kaufpreis
|
||||||
<div className="field-inline">
|
<div className="field-inline">
|
||||||
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
|
<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>
|
</tr>
|
||||||
</Fragment>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,6 +527,11 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{lend && (
|
||||||
|
<VerleihForm item={lend} onError={onError} onCancel={() => setLend(null)}
|
||||||
|
onDone={async () => { setLend(null); await nachAktion(); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdmin && remove && (
|
{isAdmin && remove && (
|
||||||
<form onSubmit={doRemove} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
|
<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>
|
<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" />
|
<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: (
|
cart: (
|
||||||
<>
|
<>
|
||||||
<circle cx="8" cy="21" r="1" />
|
<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 { CardSkeleton } from "../components/Skeleton";
|
||||||
import ShoppingNeedText from "../components/ShoppingNeedText";
|
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||||
import BewegungIcon from "../components/BewegungIcon";
|
import BewegungIcon from "../components/BewegungIcon";
|
||||||
|
import { FristBadge, verleihRowClass } from "../components/Verleih";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import {
|
import {
|
||||||
articlePrimary, articleSecondary, fmt, relativeExpiry,
|
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() {
|
function KarteBewegungen() {
|
||||||
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
||||||
return (
|
return (
|
||||||
@@ -655,6 +710,18 @@ export const KARTEN = {
|
|||||||
config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
|
config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
|
||||||
komponente: KarteEinkaufsliste,
|
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: {
|
movements: {
|
||||||
titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle",
|
titel: "Letzte Bewegungen", icon: "history", vorschau: "tabelle",
|
||||||
beschreibung: "Wer hat zuletzt was ein- oder ausgelagert",
|
beschreibung: "Wer hat zuletzt was ein- oder ausgelagert",
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { ProduktThumb } from "../components/ProduktBild";
|
|||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
import { categoryInfoMap } from "../categoryPath";
|
import { categoryInfoMap } from "../categoryPath";
|
||||||
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
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) –
|
* Liste aller Einzelstücke (physische Exemplare, jedes mit eigener UID/Lagerort) –
|
||||||
@@ -22,6 +28,7 @@ export default function ItemList() {
|
|||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -71,6 +78,16 @@ export default function ItemList() {
|
|||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : (locationPathById(it.location_id, locations) || "–")) },
|
) : (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 || "",
|
{ key: "shop", header: "Gekauft bei", width: 150, filterText: (it) => it.shop_name || "",
|
||||||
render: (it) => (isAdmin ? (
|
render: (it) => (isAdmin ? (
|
||||||
<select value={it.shop_id ?? ""} style={{ marginTop: 0 }}
|
<select value={it.shop_id ?? ""} style={{ marginTop: 0 }}
|
||||||
@@ -90,6 +107,10 @@ export default function ItemList() {
|
|||||||
render: (it) => <span className="muted">{it.note || "–"}</span> },
|
render: (it) => <span className="muted">{it.note || "–"}</span> },
|
||||||
{ key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
|
{ key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
|
||||||
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
|
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 (
|
return (
|
||||||
@@ -103,8 +124,13 @@ export default function ItemList() {
|
|||||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<DataTable id="items" columns={columns} rows={items} loading={loading}
|
<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>
|
</div>
|
||||||
|
{lend && (
|
||||||
|
<VerleihForm item={lend} onError={setError} onCancel={() => setLend(null)}
|
||||||
|
onDone={async () => { setLend(null); await load(); }} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-17
@@ -1,37 +1,133 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
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
|
* Ziel eines gescannten Einzelstück-QR bzw. NFC-Tags (/i/<uid>) – zeigt, was man
|
||||||
* zugehörigen Artikel mit dem Einzelstück im Blick.
|
* 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() {
|
export default function ItemResolve() {
|
||||||
const { uid } = useParams();
|
const { uid } = useParams();
|
||||||
const navigate = useNavigate();
|
const toast = useToast();
|
||||||
|
const [item, setItem] = useState(null);
|
||||||
|
const [locations, setLocations] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lend, setLend] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const laden = useCallback(async () => {
|
||||||
let ok = true;
|
setLoading(true);
|
||||||
api.itemByUid(uid)
|
try {
|
||||||
.then((item) => {
|
const gefunden = await api.itemByUid(uid);
|
||||||
if (ok) navigate(`/products/${item.product_id}?item=${encodeURIComponent(item.uid)}`,
|
setItem(gefunden);
|
||||||
{ replace: true });
|
setError(null);
|
||||||
})
|
// Nur für den vollen Lagerort-Pfad („Keller → Regal 2"). Scheitert es,
|
||||||
.catch((err) => { if (ok) setError(err.message); });
|
// bleibt der Name aus dem Stück selbst stehen.
|
||||||
return () => { ok = false; };
|
try { setLocations(await api.listLocations()); } catch { /* nicht kritisch */ }
|
||||||
}, [uid, navigate]);
|
} 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Einzelstück {uid}</h1>
|
<h1><Icon name="tag" size={20} /> {item?.product_name || `Einzelstück ${uid}`}</h1>
|
||||||
<div className="sub">{error ? error : "Wird geöffnet…"}</div>
|
<div className="sub">
|
||||||
|
{loading ? "Wird geladen…" : (
|
||||||
|
<>
|
||||||
|
<span className="strong">{uid}</span>
|
||||||
|
{item?.product_brand ? ` · ${item.product_brand}` : ""}
|
||||||
|
{ort ? ` · ${ort}` : ""}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user