Compare commits
2 Commits
e2d2347524
...
01a3528227
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a3528227 | ||
|
|
4aa6d9597b |
@@ -33,6 +33,42 @@ private func stringToDate(_ s: String) -> Date? {
|
||||
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)
|
||||
|
||||
/// 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)) }
|
||||
}
|
||||
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)
|
||||
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
|
||||
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
|
||||
LabeledField(label: "Notiz", text: $note)
|
||||
}
|
||||
|
||||
@@ -438,9 +474,9 @@ struct ItemAddSheet: View {
|
||||
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
|
||||
}
|
||||
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)
|
||||
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
|
||||
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
|
||||
LabeledField(label: "Notiz", text: $note)
|
||||
} footer: {
|
||||
Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
|
||||
|
||||
@@ -75,7 +75,8 @@ function Sidebar() {
|
||||
))}
|
||||
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
||||
<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="/shopping" icon="cart" label="Einkaufsliste" />
|
||||
<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="/checkin" element={<Protected><CheckIn /></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="/items" element={<Protected wide><ItemList /></Protected>} />
|
||||
<Route path="/products/new" element={<Protected adminOnly wide><ProductForm /></Protected>} />
|
||||
<Route path="/products/:id" element={<Protected wide><ProductForm /></Protected>} />
|
||||
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
|
||||
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} />
|
||||
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
|
||||
<Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} />
|
||||
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
|
||||
|
||||
@@ -287,7 +287,7 @@ export default function DataTable({
|
||||
{allOrdered.map((c) => (
|
||||
<label key={c.key} className="dt-pop-opt">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function Categories() {
|
||||
} },
|
||||
{ key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count,
|
||||
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) => (
|
||||
<span className="cell-row" style={{ justifyContent: "flex-end" }}>
|
||||
<button className="btn-icon" title="Felder verwalten"
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function Groups() {
|
||||
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
|
||||
</button>
|
||||
) },
|
||||
{ key: "actions", header: "", fixed: true, width: 56, align: "num",
|
||||
{ key: "actions", header: "", label: "Aktionen", fixed: true, width: 56, align: "num",
|
||||
render: (g) => isAdmin && (
|
||||
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ItemList() {
|
||||
? `${(it.price_cents / 100).toFixed(2)} ${it.currency || ""}`.trim() : "");
|
||||
|
||||
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} /> },
|
||||
{ key: "uid", header: "UID", width: 110, filterText: (it) => it.uid, sortValue: (it) => it.uid,
|
||||
render: (it) => <span className="strong nowrap">{it.uid}</span> },
|
||||
@@ -86,7 +86,7 @@ export default function ItemList() {
|
||||
render: (it) => preis(it) || "–" },
|
||||
{ key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "",
|
||||
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> },
|
||||
];
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function PackageTypes() {
|
||||
{ key: "beispiel", header: "Beispiel", width: 200,
|
||||
filterText: (a) => `1 ${a.singular} · 3 ${a.plural}`,
|
||||
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 ? (
|
||||
<span className="cell-row">
|
||||
<button type="button" className="btn sm primary" onClick={speichern}>Speichern</button>
|
||||
|
||||
@@ -703,7 +703,7 @@ export default function ProductForm() {
|
||||
)}
|
||||
</>)}
|
||||
<div className="row">
|
||||
<label className="grow seg-label">
|
||||
<label className="seg-label">
|
||||
Typ
|
||||
<div className="segmented">
|
||||
<button type="button" className={modus === "food" ? "active" : ""}
|
||||
@@ -716,10 +716,9 @@ export default function ProductForm() {
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div className="grow" />
|
||||
</div>
|
||||
<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.">
|
||||
Kategorie
|
||||
</span>
|
||||
@@ -732,7 +731,7 @@ export default function ProductForm() {
|
||||
/>
|
||||
</label>
|
||||
{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 }}
|
||||
onClick={() => (newCat ? setNewCat(null) : openNewCat())}>
|
||||
<Icon name="plus" size={16} />Neue Kategorie
|
||||
|
||||
@@ -26,7 +26,7 @@ function einheitText(p) {
|
||||
: basis;
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
export default function Products({ fixedType = null }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [products, setProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
@@ -43,10 +43,15 @@ export default function Products() {
|
||||
// Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
|
||||
// Unterkategorien findet und die Oberkategorie im Filter wählbar ist.
|
||||
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 = [
|
||||
{ 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} /> },
|
||||
{ key: "name", header: "Name", grow: true, min: 200,
|
||||
filterText: (p) => p.name, sortValue: (p) => p.name,
|
||||
@@ -88,7 +93,7 @@ export default function Products() {
|
||||
</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> },
|
||||
];
|
||||
|
||||
@@ -96,9 +101,9 @@ export default function Products() {
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Produkte</h1>
|
||||
<h1>{titel}</h1>
|
||||
<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>
|
||||
{isAdmin && (
|
||||
@@ -108,14 +113,16 @@ export default function Products() {
|
||||
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<div className="segmented" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<button type="button" className={typ === "" ? "active" : ""} onClick={() => setTyp("")}>Alle</button>
|
||||
<button type="button" className={typ === "food" ? "active" : ""} onClick={() => setTyp("food")}>Lebensmittel</button>
|
||||
<button type="button" className={typ === "object" ? "active" : ""} onClick={() => setTyp("object")}>Gegenstände</button>
|
||||
</div>
|
||||
{!fixedType && (
|
||||
<div className="segmented" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<button type="button" className={typ === "" ? "active" : ""} onClick={() => setTyp("")}>Alle</button>
|
||||
<button type="button" className={typ === "food" ? "active" : ""} onClick={() => setTyp("food")}>Lebensmittel</button>
|
||||
<button type="button" className={typ === "object" ? "active" : ""} onClick={() => setTyp("object")}>Gegenstände</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Units() {
|
||||
{ key: "factor", header: "Faktor", width: 150, align: "num", sortValue: (u) => u.factor,
|
||||
filterText: (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 && (
|
||||
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
|
||||
<Icon name="trash" size={16} />
|
||||
|
||||
Reference in New Issue
Block a user