Compare commits

...

2 Commits

Author SHA1 Message Date
Scarriffle
01a3528227 Web: Produkte in Lebensmittel/Gegenstaende getrennt; Spalten-Labels; Produktformular aufgeraeumt
1) Zwei getrennte Sidebar-Eintraege 'Lebensmittel' und 'Gegenstaende' (Products mit fixedType) statt einem 'Produkte' mit Umschalter - so vermischt sich nichts. 2) Spalten-Waehler zeigt sprechende Labels ('Bild' statt 'thumb', 'Aktionen', ...). 3) Produktformular: leerer Grow-Platzhalter unter 'Typ' entfernt und 'Neue Kategorie' direkt neben das Kategorie-Dropdown gruppiert; Formular wieder auf Lesebreite (kein content--wide), da das Layout-Problem intern war.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:26:41 +02:00
Scarriffle
4aa6d9597b iOS: Einzelstueck-Daten einheitlich als dd.MM.yyyy
Die nativen compact-DatePicker zeigten das Jahr mal zwei-, mal vierstellig (Gekauft am vs. Garantie bis). Neue DateRow: fester Formatierer dd.MM.yyyy, Antippen klappt einen grafischen Kalender inline auf - konsistent und ohne iOS-16.4-Abhaengigkeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:26:41 +02:00
10 changed files with 75 additions and 30 deletions

View File

@@ -33,6 +33,42 @@ private func stringToDate(_ s: String) -> Date? {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s) let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
} }
/// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker
/// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig deshalb hier ein fester
/// Formatierer und ein grafischer Picker im Popover.
struct DateRow: View {
let label: String
@Binding var date: Date
@State private var open = false
private static let fmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy"
return f
}()
var body: some View {
Group {
Button { withAnimation(.easeInOut(duration: 0.15)) { open.toggle() } } label: {
HStack {
Text(label).foregroundStyle(.primary)
Spacer()
Text(Self.fmt.string(from: date))
.foregroundStyle(open ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
DatePicker(label, selection: $date, displayedComponents: .date)
.datePickerStyle(.graphical)
.labelsHidden()
}
}
}
}
// MARK: - Liste (in der Produkt-Detailansicht) // MARK: - Liste (in der Produkt-Detailansicht)
/// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die /// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die
@@ -146,9 +182,9 @@ struct ItemEditView: View {
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) } ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
} }
Toggle("Kaufdatum", isOn: $hasAcquired) Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) } if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty) Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) } if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note) LabeledField(label: "Notiz", text: $note)
} }
@@ -438,9 +474,9 @@ struct ItemAddSheet: View {
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) } ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
} }
Toggle("Kaufdatum", isOn: $hasAcquired) Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) } if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty) Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) } if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note) LabeledField(label: "Notiz", text: $note)
} footer: { } footer: {
Text("Gemeinsame Startwerte danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.") Text("Gemeinsame Startwerte danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")

View File

@@ -75,7 +75,8 @@ function Sidebar() {
))} ))}
<NavItem to="/checkin" icon="checkin" label="Einlagern" /> <NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" /> <NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/products" icon="package" label="Produkte" /> <NavItem to="/lebensmittel" icon="box" label="Lebensmittel" />
<NavItem to="/gegenstaende" icon="package" label="Gegenstände" />
<NavItem to="/items" icon="tag" label="Einzelstücke" /> <NavItem to="/items" icon="tag" label="Einzelstücke" />
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" /> <NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/history" icon="history" label="Verlauf" /> <NavItem to="/history" icon="history" label="Verlauf" />
@@ -139,10 +140,12 @@ export default function App() {
<Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} /> <Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} /> <Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} /> <Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
<Route path="/lebensmittel" element={<Protected wide><Products fixedType="food" /></Protected>} />
<Route path="/gegenstaende" element={<Protected wide><Products fixedType="object" /></Protected>} />
<Route path="/products" element={<Protected wide><Products /></Protected>} /> <Route path="/products" element={<Protected wide><Products /></Protected>} />
<Route path="/items" element={<Protected wide><ItemList /></Protected>} /> <Route path="/items" element={<Protected wide><ItemList /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly wide><ProductForm /></Protected>} /> <Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
<Route path="/products/:id" element={<Protected wide><ProductForm /></Protected>} /> <Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} /> <Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
<Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} /> <Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} />
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} /> <Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />

View File

@@ -287,7 +287,7 @@ export default function DataTable({
{allOrdered.map((c) => ( {allOrdered.map((c) => (
<label key={c.key} className="dt-pop-opt"> <label key={c.key} className="dt-pop-opt">
<input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} /> <input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} />
<span>{c.header || c.key}</span> <span>{c.label || c.header || c.key}</span>
</label> </label>
))} ))}
</div> </div>

View File

@@ -127,7 +127,7 @@ export default function Categories() {
} }, } },
{ key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count, { key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count,
render: (c) => <span className="muted">{c.product_count}</span> }, render: (c) => <span className="muted">{c.product_count}</span> },
{ key: "actions", header: "", fixed: true, width: 96, align: "num", { key: "actions", header: "", label: "Aktionen", fixed: true, width: 96, align: "num",
render: (c) => ( render: (c) => (
<span className="cell-row" style={{ justifyContent: "flex-end" }}> <span className="cell-row" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Felder verwalten" <button className="btn-icon" title="Felder verwalten"

View File

@@ -116,7 +116,7 @@ export default function Groups() {
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten {(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
</button> </button>
) }, ) },
{ key: "actions", header: "", fixed: true, width: 56, align: "num", { key: "actions", header: "", label: "Aktionen", fixed: true, width: 56, align: "num",
render: (g) => isAdmin && ( render: (g) => isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen"> <button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />

View File

@@ -45,7 +45,7 @@ export default function ItemList() {
? `${(it.price_cents / 100).toFixed(2)} ${it.currency || ""}`.trim() : ""); ? `${(it.price_cents / 100).toFixed(2)} ${it.currency || ""}`.trim() : "");
const columns = [ const columns = [
{ key: "thumb", header: "", fixed: true, width: 56, { key: "thumb", header: "", label: "Bild", fixed: true, width: 56,
render: (it) => <ProduktThumb productId={it.product_id} alt={it.product_name} /> }, render: (it) => <ProduktThumb productId={it.product_id} alt={it.product_name} /> },
{ key: "uid", header: "UID", width: 110, filterText: (it) => it.uid, sortValue: (it) => it.uid, { key: "uid", header: "UID", width: 110, filterText: (it) => it.uid, sortValue: (it) => it.uid,
render: (it) => <span className="strong nowrap">{it.uid}</span> }, render: (it) => <span className="strong nowrap">{it.uid}</span> },
@@ -86,7 +86,7 @@ export default function ItemList() {
render: (it) => preis(it) || "" }, render: (it) => preis(it) || "" },
{ key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "", { key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "",
render: (it) => <span className="muted">{it.note || ""}</span> }, render: (it) => <span className="muted">{it.note || ""}</span> },
{ key: "open", header: "", fixed: true, width: 84, align: "num", { key: "open", header: "", label: "Produkt öffnen", fixed: true, width: 84, align: "num",
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> }, render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
]; ];

View File

@@ -99,7 +99,7 @@ export default function PackageTypes() {
{ key: "beispiel", header: "Beispiel", width: 200, { key: "beispiel", header: "Beispiel", width: 200,
filterText: (a) => `1 ${a.singular} · 3 ${a.plural}`, filterText: (a) => `1 ${a.singular} · 3 ${a.plural}`,
render: (a) => <span className="muted">1 {a.singular} · 3 {a.plural}</span> }, render: (a) => <span className="muted">1 {a.singular} · 3 {a.plural}</span> },
{ key: "actions", header: "", fixed: true, width: 160, align: "num", { key: "actions", header: "", label: "Aktionen", fixed: true, width: 160, align: "num",
render: (a) => (bearbeitung?.id === a.id ? ( render: (a) => (bearbeitung?.id === a.id ? (
<span className="cell-row"> <span className="cell-row">
<button type="button" className="btn sm primary" onClick={speichern}>Speichern</button> <button type="button" className="btn sm primary" onClick={speichern}>Speichern</button>

View File

@@ -703,7 +703,7 @@ export default function ProductForm() {
)} )}
</>)} </>)}
<div className="row"> <div className="row">
<label className="grow seg-label"> <label className="seg-label">
Typ Typ
<div className="segmented"> <div className="segmented">
<button type="button" className={modus === "food" ? "active" : ""} <button type="button" className={modus === "food" ? "active" : ""}
@@ -716,10 +716,9 @@ export default function ProductForm() {
</button> </button>
</div> </div>
</label> </label>
<div className="grow" />
</div> </div>
<div className="row"> <div className="row">
<label className="grow"> <label className="grow" style={{ maxWidth: 460 }}>
<span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen."> <span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen.">
Kategorie Kategorie
</span> </span>
@@ -732,7 +731,7 @@ export default function ProductForm() {
/> />
</label> </label>
{isAdmin && ( {isAdmin && (
<div className="grow" style={{ display: "flex", alignItems: "flex-end" }}> <div style={{ display: "flex", alignItems: "flex-end" }}>
<button type="button" className="btn" style={{ marginBottom: 2 }} <button type="button" className="btn" style={{ marginBottom: 2 }}
onClick={() => (newCat ? setNewCat(null) : openNewCat())}> onClick={() => (newCat ? setNewCat(null) : openNewCat())}>
<Icon name="plus" size={16} />Neue Kategorie <Icon name="plus" size={16} />Neue Kategorie

View File

@@ -26,7 +26,7 @@ function einheitText(p) {
: basis; : basis;
} }
export default function Products() { export default function Products({ fixedType = null }) {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [products, setProducts] = useState([]); const [products, setProducts] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
@@ -43,10 +43,15 @@ export default function Products() {
// Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die // Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
// Unterkategorien findet und die Oberkategorie im Filter wählbar ist. // Unterkategorien findet und die Oberkategorie im Filter wählbar ist.
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]); const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
const sichtbar = products.filter((p) => !typ || p.tracking === typ); // Zwei getrennte Seiten (Lebensmittel/Gegenstände) fixieren den Typ; sonst der
// Umschalter oben.
const activeTyp = fixedType || typ;
const sichtbar = products.filter((p) => !activeTyp || p.tracking === activeTyp);
const titel = fixedType === "food" ? "Lebensmittel"
: fixedType === "object" ? "Gegenstände" : "Produkte";
const columns = [ const columns = [
{ key: "thumb", header: "", fixed: true, width: 56, { key: "thumb", header: "", label: "Bild", fixed: true, width: 56,
render: (p) => <ProduktThumb productId={p.id} alt={p.name} /> }, render: (p) => <ProduktThumb productId={p.id} alt={p.name} /> },
{ key: "name", header: "Name", grow: true, min: 200, { key: "name", header: "Name", grow: true, min: 200,
filterText: (p) => p.name, sortValue: (p) => p.name, filterText: (p) => p.name, sortValue: (p) => p.name,
@@ -88,7 +93,7 @@ export default function Products() {
</span> </span>
); );
} }, } },
{ key: "details", header: "", fixed: true, width: 84, align: "num", { key: "details", header: "", label: "Details", fixed: true, width: 84, align: "num",
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> }, render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
]; ];
@@ -96,9 +101,9 @@ export default function Products() {
<div> <div>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Produkte</h1> <h1>{titel}</h1>
<div className="sub"> <div className="sub">
{sichtbar.length} Produkte{typ ? (typ === "food" ? " · Lebensmittel" : " · Gegenstände") : " im Katalog"} {sichtbar.length} {fixedType ? titel : `Produkte${activeTyp ? (activeTyp === "food" ? " · Lebensmittel" : " · Gegenstände") : " im Katalog"}`}
</div> </div>
</div> </div>
{isAdmin && ( {isAdmin && (
@@ -108,14 +113,16 @@ export default function Products() {
{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="segmented" style={{ marginBottom: "var(--sp-3)" }}> {!fixedType && (
<button type="button" className={typ === "" ? "active" : ""} onClick={() => setTyp("")}>Alle</button> <div className="segmented" style={{ marginBottom: "var(--sp-3)" }}>
<button type="button" className={typ === "food" ? "active" : ""} onClick={() => setTyp("food")}>Lebensmittel</button> <button type="button" className={typ === "" ? "active" : ""} onClick={() => setTyp("")}>Alle</button>
<button type="button" className={typ === "object" ? "active" : ""} onClick={() => setTyp("object")}>Gegenstände</button> <button type="button" className={typ === "food" ? "active" : ""} onClick={() => setTyp("food")}>Lebensmittel</button>
</div> <button type="button" className={typ === "object" ? "active" : ""} onClick={() => setTyp("object")}>Gegenstände</button>
</div>
)}
<div className="card"> <div className="card">
<DataTable id="products" columns={columns} rows={sichtbar} <DataTable id={fixedType ? `products-${fixedType}` : "products"} columns={columns} rows={sichtbar}
getRowKey={(p) => p.id} empty="Keine Produkte gefunden." /> getRowKey={(p) => p.id} empty="Keine Produkte gefunden." />
</div> </div>
</div> </div>

View File

@@ -67,7 +67,7 @@ export default function Units() {
{ key: "factor", header: "Faktor", width: 150, align: "num", sortValue: (u) => u.factor, { key: "factor", header: "Faktor", width: 150, align: "num", sortValue: (u) => u.factor,
filterText: (u) => `${fmt(u.factor)} ${baseUnitOfKind[u.kind]}`, filterText: (u) => `${fmt(u.factor)} ${baseUnitOfKind[u.kind]}`,
render: (u) => `${fmt(u.factor)} ${baseUnitOfKind[u.kind]}` }, render: (u) => `${fmt(u.factor)} ${baseUnitOfKind[u.kind]}` },
{ key: "actions", header: "", fixed: true, width: 56, align: "num", { key: "actions", header: "", label: "Aktionen", fixed: true, width: 56, align: "num",
render: (u) => (!u.is_builtin && ( render: (u) => (!u.is_builtin && (
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen"> <button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />