Der Lagerort-QR (/l/<code>) zeigt jetzt, was am Ort (inkl. Unterorte) liegt - Artikel mit Bestand, jeweils verlinkt - statt nur den Namen. "Hier einlagern" springt ins Einlagern mit vorbelegtem Lagerort (?location=). In der Lagerorte- Verwaltung fuehrt ein neues Lupensymbol zur Inhaltsansicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
212 lines
7.5 KiB
JavaScript
212 lines
7.5 KiB
JavaScript
import { useEffect, useState } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { api } from "../api";
|
||
import { useConfirm } from "../confirm";
|
||
import Icon from "../components/Icon";
|
||
import CategorySelect from "../components/CategorySelect";
|
||
import { QrImg, printQrLabels } from "../qr";
|
||
|
||
export default function Locations() {
|
||
const confirm = useConfirm();
|
||
const [locations, setLocations] = useState([]);
|
||
const [name, setName] = useState("");
|
||
const [parentId, setParentId] = useState("");
|
||
const [error, setError] = useState(null);
|
||
// Inline-Bearbeiten (Umbenennen + Umhängen) eines vorhandenen Orts.
|
||
const [editId, setEditId] = useState(null);
|
||
const [editName, setEditName] = useState("");
|
||
const [editParent, setEditParent] = useState(null);
|
||
|
||
async function load() {
|
||
try {
|
||
setLocations(await api.listLocations());
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
useEffect(() => { load(); }, []);
|
||
|
||
async function add(e) {
|
||
e.preventDefault();
|
||
setError(null);
|
||
try {
|
||
await api.createLocation({ name, parent_id: parentId === "" ? null : parentId });
|
||
setName("");
|
||
// Übergeordneten Ort absichtlich stehen lassen – beim Anlegen vieler Orte
|
||
// unter demselben Elternteil spart das jedes Mal die Neuauswahl.
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
function startEdit(l) {
|
||
setEditId(l.id);
|
||
setEditName(l.name);
|
||
setEditParent(l.parent_id ?? null);
|
||
setError(null);
|
||
}
|
||
|
||
function cancelEdit() {
|
||
setEditId(null);
|
||
}
|
||
|
||
async function saveEdit() {
|
||
setError(null);
|
||
try {
|
||
await api.updateLocation(editId, {
|
||
name: editName.trim(),
|
||
parent_id: editParent == null ? null : editParent,
|
||
});
|
||
setEditId(null);
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
// Eigene Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen.
|
||
function descendantIds(id) {
|
||
const result = new Set();
|
||
const stack = [id];
|
||
while (stack.length) {
|
||
const cur = stack.pop();
|
||
for (const c of locations.filter((l) => l.parent_id === cur)) {
|
||
if (!result.has(c.id)) { result.add(c.id); stack.push(c.id); }
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function remove(id) {
|
||
const ok = await confirm({
|
||
title: "Lagerort löschen?",
|
||
message: "Untergeordnete Orte rücken eine Ebene nach oben.",
|
||
confirmLabel: "Löschen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.deleteLocation(id);
|
||
load();
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
const nameById = Object.fromEntries(locations.map((l) => [l.id, l.name]));
|
||
const ids = new Set(locations.map((l) => l.id));
|
||
const isRoot = (l) => !l.parent_id || !ids.has(l.parent_id);
|
||
|
||
// Rekursiv über beliebig viele Ebenen (Schrank -> Fach -> Kiste -> …).
|
||
const ordered = [];
|
||
const seen = new Set();
|
||
function walk(node, depth) {
|
||
if (seen.has(node.id)) return; // Schutz vor Zyklen
|
||
seen.add(node.id);
|
||
ordered.push({ ...node, depth });
|
||
for (const c of locations.filter((l) => l.parent_id === node.id)) walk(c, depth + 1);
|
||
}
|
||
for (const r of locations.filter(isRoot)) walk(r, 0);
|
||
|
||
// Mögliche neue Elternorte beim Bearbeiten: alle außer dem Ort selbst und
|
||
// seinen Unterorten (sonst entstünde ein Ring).
|
||
const editDesc = editId != null ? descendantIds(editId) : new Set();
|
||
const editParentNodes = ordered.filter((o) => o.id !== editId && !editDesc.has(o.id));
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Lagerorte</h1>
|
||
<div className="sub">Orte lassen sich verschachteln (z.B. Keller → Regal 2 → Fach A)</div>
|
||
</div>
|
||
{locations.length > 0 && (
|
||
<button className="btn" onClick={() => printQrLabels("Lagerort-Etiketten",
|
||
locations.map((l) => ({ text: `${window.location.origin}/l/${l.id}`, line1: l.name, line2: "Lagerort" })))}>
|
||
<Icon name="download" size={16} />QR-Etiketten drucken
|
||
</button>
|
||
)}
|
||
</div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<div className="card form-narrow">
|
||
<form onSubmit={add}>
|
||
<div className="row">
|
||
<label className="grow">
|
||
Name
|
||
<input placeholder="z.B. Speisekammer oder Regal 2" value={name}
|
||
onChange={(e) => setName(e.target.value)} required />
|
||
</label>
|
||
<label className="grow">
|
||
Übergeordneter Lagerort (optional)
|
||
<CategorySelect
|
||
value={parentId === "" ? null : parentId}
|
||
nodes={ordered}
|
||
rootLabel="– keiner (oberste Ebene) –"
|
||
onChange={(id) => setParentId(id == null ? "" : String(id))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>
|
||
</form>
|
||
|
||
<ul className="simple-list">
|
||
{ordered.map((l) => (
|
||
editId === l.id ? (
|
||
<li key={l.id}>
|
||
<div className="row" style={{ width: "100%", gap: 8, alignItems: "flex-end" }}>
|
||
<label className="grow" style={{ margin: 0 }}>
|
||
Name
|
||
<input value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||
</label>
|
||
<label className="grow" style={{ margin: 0 }}>
|
||
Übergeordnet
|
||
<CategorySelect
|
||
value={editParent}
|
||
nodes={editParentNodes}
|
||
rootLabel="– keiner (oberste Ebene) –"
|
||
onChange={(id) => setEditParent(id == null ? null : id)}
|
||
/>
|
||
</label>
|
||
<span style={{ display: "flex", gap: 6 }}>
|
||
<button className="btn primary" onClick={saveEdit} disabled={!editName.trim()}>
|
||
<Icon name="check" size={16} />Speichern
|
||
</button>
|
||
<button type="button" className="btn ghost" onClick={cancelEdit}>Abbrechen</button>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
) : (
|
||
<li key={l.id}>
|
||
<span style={{ display: "flex", alignItems: "center", gap: 8, paddingLeft: l.depth * 22 }}>
|
||
{l.depth > 0 && <span className="muted">↳</span>}
|
||
<Icon name="location" size={15} className="muted" />
|
||
{l.name}
|
||
{l.depth > 0 && l.parent_id && nameById[l.parent_id] && (
|
||
<span className="badge">in {nameById[l.parent_id]}</span>
|
||
)}
|
||
</span>
|
||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<Link className="btn-icon" to={`/l/${l.id}`} title="Inhalt ansehen">
|
||
<Icon name="search" size={16} />
|
||
</Link>
|
||
<QrImg text={`${window.location.origin}/l/${l.id}`} size={44} />
|
||
<button className="btn-icon" onClick={() => startEdit(l)} title="Bearbeiten">
|
||
<Icon name="edit" size={16} />
|
||
</button>
|
||
<button className="btn-icon danger" onClick={() => remove(l.id)} title="Löschen">
|
||
<Icon name="trash" size={16} />
|
||
</button>
|
||
</span>
|
||
</li>
|
||
)
|
||
))}
|
||
{locations.length === 0 && <li className="empty">Noch keine Lagerorte.</li>}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|