Einzelstuecke: Zeile zeigt nur noch, was man im Alltag braucht

Die Tabelle trug neun Spalten, davon sechs als dauernd offene
Eingabefelder - bei einem einzigen Stueck schon eine Wand aus
Bedienelementen. Jetzt stehen in der Zeile QR, UID, Lagerort, "Verliehen
an" und die Aktionen; Kaufdatum, Garantie, Shop, Notiz, Kaufpreis, Belege
und der Verleih-Verlauf klappen je Stueck auf. Aenderbar bleibt alles,
nur nicht alles gleichzeitig sichtbar.

Dazu zwei Fehler von mir aus dem Verleih-Commit:

- Die drei Aktionssymbole standen uebereinander statt nebeneinander. Die
  Aktionszelle benutzte .field-inline, und das bricht in Tabellen bewusst
  um (styles.css) - mit dem vierten Knopf reichte der Platz nicht mehr.
  Jetzt .cell-row, das umbricht nicht.
- "ueberfaellig (vor 1 Woche)" las sich als Zeitpunkt, gemeint ist eine
  Dauer. Jetzt "7 Tage ueberfaellig".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-17 10:13:27 +02:00
co-authored by Claude Opus 5
parent b2dda78a7b
commit c924dffad6
4 changed files with 56 additions and 35 deletions
+47 -34
View File
@@ -48,6 +48,9 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
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 [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
// Aufgeklappte Stücke. Die Zeile zeigt nur, was man im Alltag braucht
// (wo liegt es, ist es verliehen) – Kaufdaten und Belege auf Wunsch.
const [offen, setOffen] = useState(() => new Set());
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
@@ -254,6 +257,14 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const ortName = (id) => locationPathById(id, locations); const ortName = (id) => locationPathById(id, locations);
function klappe(id) {
setOffen((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id); else neu.add(id);
return neu;
});
}
return ( return (
<section className="card"> <section className="card">
<div className="card-head"> <div className="card-head">
@@ -271,9 +282,7 @@ 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>Verliehen an</th> <th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</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>
@@ -292,36 +301,10 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
) : <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="Verliehen an"><VerleihText loan={it.loan} /></td>
<td data-label="Gekauft am"> {/* cell-row statt field-inline: letzteres bricht in Tabellen um
{isAdmin ? ( (styles.css) und stellte die Symbole uebereinander. */}
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
) : <span>{it.acquired_on || "–"}</span>}
</td>
<td data-label="Garantie bis">
{isAdmin ? (
<input key={it.warranty_until || "none"} type="date"
defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
) : <span>{it.warranty_until || "–"}</span>}
</td>
<td data-label="Gekauft bei">
{isAdmin ? (
<select value={it.shop_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">– unbekannt –</option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
) : <span>{it.shop_name || "–"}</span>}
</td>
<td data-label="Notiz">
{isAdmin ? (
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" style={{ marginTop: 0, minWidth: 120 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
) : <span>{it.note || "–"}</span>}
</td>
<td className="num"> <td className="num">
<div className="field-inline" style={{ justifyContent: "flex-end" }}> <div className="cell-row" style={{ justifyContent: "flex-end" }}>
{/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */} {/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */}
<VerleihAktion item={it} onLend={setLend} onChanged={nachAktion} <VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
onError={onError} /> onError={onError} />
@@ -337,13 +320,42 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</button> </button>
</> </>
)} )}
<button type="button" className="btn-icon"
title={offen.has(it.id) ? "Details zuklappen" : "Kaufdaten, Preis, Belege, Verleih-Verlauf"}
aria-expanded={offen.has(it.id)}
onClick={() => klappe(it.id)}>
<Icon name="chevron" size={18} className={offen.has(it.id) ? "" : "rot-90"} />
</button>
</div> </div>
</td> </td>
</tr> </tr>
{offen.has(it.id) && (
<tr className="einzel-detailrow"> <tr className="einzel-detailrow">
<td colSpan={9}> <td colSpan={5}>
<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 className="row" style={{ margin: 0 }}>
<label className="grow" style={{ margin: 0 }}>Gekauft am
<input type="date" defaultValue={it.acquired_on || ""} disabled={!isAdmin}
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
</label>
<label className="grow" style={{ margin: 0 }}>Garantie bis
<input key={it.warranty_until || "none"} type="date" disabled={!isAdmin}
defaultValue={it.warranty_until || ""}
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
</label>
</div>
<label style={{ margin: 0 }}>Gekauft bei
<select value={it.shop_id ?? ""} disabled={!isAdmin}
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">– unbekannt –</option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label style={{ margin: 0 }}>Notiz
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" disabled={!isAdmin}
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
</label>
<div> <div>
<span className="muted small">Verleih</span> <span className="muted small">Verleih</span>
<div style={{ marginTop: "var(--sp-2)" }}> <div style={{ marginTop: "var(--sp-2)" }}>
@@ -428,9 +440,10 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</div> </div>
</td> </td>
</tr> </tr>
)}
</Fragment> </Fragment>
))} ))}
{items.length === 0 && <tr><td colSpan={9} className="empty">Noch keine Einzelstücke.</td></tr>} {items.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Einzelstücke.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
+2
View File
@@ -119,6 +119,8 @@ const PATHS = {
</> </>
), ),
plus: <path d="M5 12h14M12 5v14" />, plus: <path d="M5 12h14M12 5v14" />,
// Aufklappen. Zeigt nach unten; zugeklappt dreht die Zelle es nach rechts.
chevron: <path d="m6 9 6 6 6-6" />,
trash: ( trash: (
<> <>
<path d="M3 6h18" /> <path d="M3 6h18" />
+3 -1
View File
@@ -18,8 +18,10 @@ export function FristBadge({ loan, warnTage = 7 }) {
if (!loan || !loan.due_on) return null; if (!loan || !loan.due_on) return null;
const tage = daysUntil(loan.due_on); const tage = daysUntil(loan.due_on);
const art = loan.days_overdue ? "danger" : (tage != null && tage <= warnTage ? "warn" : null); const art = loan.days_overdue ? "danger" : (tage != null && tage <= warnTage ? "warn" : null);
// Bei Ueberfaelligkeit die Tage ausschreiben: „vor 1 Woche" (relativeExpiry)
// liest sich als Zeitpunkt, gemeint ist aber eine Dauer.
const text = loan.days_overdue const text = loan.days_overdue
? `überfällig (${relativeExpiry(-loan.days_overdue)})` ? `${loan.days_overdue} ${loan.days_overdue === 1 ? "Tag" : "Tage"} überfällig`
: `zurück ${relativeExpiry(tage)}`; : `zurück ${relativeExpiry(tage)}`;
return art return art
? <span className={`badge ${art}`}>{text}</span> ? <span className={`badge ${art}`}>{text}</span>
+4
View File
@@ -410,6 +410,10 @@ td select { width: auto; min-width: 0; max-width: 100%; }
/* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das /* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das
Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */ Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */
.cell-row { display: flex; align-items: center; gap: 6px; } .cell-row { display: flex; align-items: center; gap: 6px; }
/* Zugeklappt zeigt der Pfeil nach rechts, aufgeklappt nach unten. Eine Drehung
statt zweier Symbole – so bleibt die Bewegung sichtbar. */
.rot-90 { transform: rotate(-90deg); transition: transform 120ms ease; }
.icon { transition: transform 120ms ease; }
/* Zwei Zeilen in einer Zelle: Steuerelement oben, Erläuterung darunter. */ /* Zwei Zeilen in einer Zelle: Steuerelement oben, Erläuterung darunter. */
.cell-col { display: flex; flex-direction: column; gap: 2px; align-items: stretch; } .cell-col { display: flex; flex-direction: column; gap: 2px; align-items: stretch; }
/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass /* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass