diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index dddf363..abca975 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -6,6 +6,7 @@ from ..deps import get_current_user, require_admin from ..models import Barcode, Group, Product, User from ..schemas import ( BarcodeCreate, + BarcodeNoteUpdate, BarcodeOut, GroupCreate, GroupOut, @@ -171,6 +172,36 @@ def add_group_barcode( return _group_to_out(db, group) +@router.patch("/{group_id}/barcodes/{code}", response_model=GroupOut) +def update_group_barcode_note( + group_id: int, + code: str, + payload: BarcodeNoteUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> GroupOut: + """Notiz zu einem Gruppen-Code setzen. + + Betrifft ausdrücklich auch Codes, die beim Zuordnen eines Artikels + automatisch entstanden sind – die hatten bisher gar keine Möglichkeit, + eine Notiz zu bekommen. + """ + group = db.get(Group, group_id) + if group is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") + entry = ( + db.query(Barcode).filter(Barcode.group_id == group_id, Barcode.code == code).first() + ) + if entry is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden") + + note = (payload.note or "").strip() + entry.note = note or None + db.commit() + db.refresh(group) + return _group_to_out(db, group) + + @router.delete("/{group_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT) def delete_group_barcode( group_id: int, diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index e7de3d8..82eb80a 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -47,6 +47,15 @@ PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"} # -------------------------------------------------------------------------- # Export # -------------------------------------------------------------------------- +def _zeitstempel() -> str: + """Ortszeit als YYYY-MM-DD_HHMM für Dateinamen. + + Ohne Zeitstempel heißen mehrere Ausleitungen alle gleich und der Browser + haengt (1), (2) an – dann weiß niemand mehr, welche die aktuelle ist. + """ + return datetime.now().strftime("%Y-%m-%d_%H%M") + + def _article_unit(product: Product) -> tuple[float, str]: """Faktor und Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).""" unit_name, unit_factor = display_unit_info(product) @@ -104,7 +113,9 @@ def export_stock_csv( return Response( content="" + buf.getvalue(), media_type="text/csv; charset=utf-8", - headers={"Content-Disposition": 'attachment; filename="bestand.csv"'}, + headers={ + "Content-Disposition": f'attachment; filename="bestand_{_zeitstempel()}.csv"' + }, ) @@ -119,6 +130,7 @@ def export_backup_json( data = { "version": 1, "exported_at": datetime.now(timezone.utc).isoformat(), + "exported_at_local": datetime.now().isoformat(timespec="seconds"), "units": [ {"name": u.name, "kind": u.kind.value, "factor": u.factor} for u in db.query(Unit).order_by(Unit.id).all() @@ -171,7 +183,10 @@ def export_backup_json( return Response( content=json.dumps(data, ensure_ascii=False, indent=2), media_type="application/json; charset=utf-8", - headers={"Content-Disposition": 'attachment; filename="project-good-backup.json"'}, + headers={ + "Content-Disposition": + f'attachment; filename="project-good-backup_{_zeitstempel()}.json"' + }, ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1937548..885f689 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -244,6 +244,11 @@ class BarcodeCreate(BaseModel): note: str | None = Field(default=None, max_length=120) +class BarcodeNoteUpdate(BaseModel): + """Notiz zu einem Code nachtragen oder ändern.""" + note: str | None = Field(default=None, max_length=120) + + # ---- Stock movements ---- class CheckInRequest(BaseModel): product_id: int | None = None diff --git a/web/src/api.js b/web/src/api.js index 33563ad..81433d3 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -72,6 +72,14 @@ async function request(path, { method = "GET", body, form, formData } = {}) { return data; } +/** Ortszeit als YYYY-MM-DD_HHMM fuer Dateinamen. */ +function zeitstempel() { + const jetzt = new Date(); + const zwei = (n) => String(n).padStart(2, "0"); + return `${jetzt.getFullYear()}-${zwei(jetzt.getMonth() + 1)}-${zwei(jetzt.getDate())}` + + `_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}`; +} + // Datei mit Auth-Header laden und im Browser als Download anbieten. export async function downloadFile(path, filename) { const token = getToken(); @@ -148,12 +156,16 @@ export const api = { updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }), addGroupBarcode: (id, body) => request(`/groups/${id}/barcodes`, { method: "POST", body }), + updateGroupBarcodeNote: (id, code, body) => + request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "PATCH", body }), deleteGroupBarcode: (id, code) => request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }), // Export / Import - exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"), - exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"), + // Zeitstempel im Dateinamen, sonst heissen mehrere Ausleitungen alle gleich. + exportCsv: () => downloadFile("/export/stock.csv", `bestand_${zeitstempel()}.csv`), + exportJson: () => + downloadFile("/export/backup.json", `project-good-backup_${zeitstempel()}.json`), importStock: (file, mode = "add") => { const fd = new FormData(); fd.append("file", file); diff --git a/web/src/components/BarcodeList.jsx b/web/src/components/BarcodeList.jsx index bc26b4e..72a872c 100644 --- a/web/src/components/BarcodeList.jsx +++ b/web/src/components/BarcodeList.jsx @@ -12,7 +12,7 @@ import Icon from "./Icon"; * onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt. */ export default function BarcodeList({ - barcodes = [], productBarcodes = [], onAdd, onDelete, disabled = false, hint, + barcodes = [], productBarcodes = [], onAdd, onDelete, onEditNote, disabled = false, hint, }) { const [code, setCode] = useState(""); const [note, setNote] = useState(""); @@ -45,9 +45,20 @@ export default function BarcodeList({ ))} {barcodes.map((b) => (
  • - + {b.code} - {b.note && {b.note}} + {/* Notiz auch nachtraeglich aenderbar - automatisch angelegte Codes + hatten sonst nie eine Gelegenheit, eine zu bekommen. */} + {onEditNote && !disabled ? ( + { + const wert = e.target.value.trim(); + if (wert !== (b.note || "")) onEditNote(b.code, wert); + }} /> + ) : ( + b.note && {b.note} + )} {!disabled && ( + + + + + )} + + ); +} + +export function useConfirm() { + return useContext(ConfirmContext); +} diff --git a/web/src/main.jsx b/web/src/main.jsx index 4b52330..542cff3 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom"; import App from "./App"; import { AuthProvider } from "./auth"; import { SettingsProvider } from "./settings"; +import { ConfirmProvider } from "./confirm"; import "./styles.css"; import { applyFavicon } from "./branding"; @@ -15,7 +16,9 @@ ReactDOM.createRoot(document.getElementById("root")).render( - + + + diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index 8557d12..be5217a 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { asTree } from "../categoryTree"; @@ -11,6 +12,7 @@ import { asTree } from "../categoryTree"; * Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein. */ export default function Categories() { + const confirm = useConfirm(); const { isAdmin } = useAuth(); const [categories, setCategories] = useState([]); const [form, setForm] = useState({ name: "", parent_id: "" }); @@ -55,7 +57,13 @@ export default function Categories() { const hinweis = category.product_count ? ` ${category.product_count} Artikel verlieren die Zuordnung, bleiben aber erhalten.` : ""; - if (!confirm(`Kategorie "${category.name}" löschen?${hinweis}`)) return; + const ok = await confirm({ + title: `Kategorie „${category.name}“ löschen?`, + message: `Unterkategorien rücken eine Ebene nach oben.${hinweis}`, + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; try { await api.deleteCategory(category.id); load(); diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx index 9e31eab..18b2bff 100644 --- a/web/src/pages/Groups.jsx +++ b/web/src/pages/Groups.jsx @@ -1,11 +1,13 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; import { fmt } from "../units"; export default function Groups() { + const confirm = useConfirm(); const { isAdmin } = useAuth(); const [groups, setGroups] = useState([]); const [units, setUnits] = useState([]); @@ -52,7 +54,13 @@ export default function Groups() { } async function remove(group) { - if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return; + const ok = await confirm({ + title: `Gruppe „${group.name}“ löschen?`, + message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; try { await api.deleteGroup(group.id); if (selectedId === group.id) setSelectedId(null); @@ -174,6 +182,12 @@ export default function Groups() { await load(); } catch (err) { setError(err.message); } }} + onEditNote={async (code, note) => { + try { + await api.updateGroupBarcodeNote(selected.id, code, { note }); + await load(); + } catch (err) { setError(err.message); } + }} onDelete={async (code) => { try { await api.deleteGroupBarcode(selected.id, code); diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx index 2604aa8..9073631 100644 --- a/web/src/pages/Locations.jsx +++ b/web/src/pages/Locations.jsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; export default function Locations() { + const confirm = useConfirm(); const [locations, setLocations] = useState([]); const [name, setName] = useState(""); const [parentId, setParentId] = useState(""); @@ -32,7 +34,13 @@ export default function Locations() { } async function remove(id) { - if (!confirm("Lagerort löschen? Untergeordnete Orte werden dann übergeordnet.")) return; + 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(); diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 88b49d6..e599deb 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; @@ -28,6 +29,7 @@ const KIND_LABEL = { count: "Anzahl", weight: "Gewicht", volume: "Volumen" }; const KIND_ORDER = ["count", "weight", "volume"]; export default function ProductForm() { + const confirm = useConfirm(); const { id } = useParams(); const isNew = !id; const { isAdmin } = useAuth(); @@ -220,7 +222,13 @@ export default function ProductForm() { } async function remove() { - if (!confirm("Produkt wirklich löschen? Alle Chargen gehen verloren.")) return; + const ok = await confirm({ + title: "Produkt löschen?", + message: "Alle Chargen dieses Produkts gehen dabei verloren.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; try { await api.deleteProduct(id); navigate("/products"); @@ -423,6 +431,7 @@ export default function ProductForm() { /** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) { + const confirm = useConfirm(); const { formatBestBefore } = useSettings(); const [editId, setEditId] = useState(null); const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day" }); @@ -491,7 +500,13 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha } async function removeLot(l) { - if (!confirm("Charge wirklich löschen?")) return; + const ok = await confirm({ + title: "Charge löschen?", + message: "Die Menge wird als Korrektur im Verlauf protokolliert.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; try { await api.deleteLot(l.id); await onChanged(); diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index 54b77ad..65e53ab 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; import BrandingUpload from "../components/BrandingUpload"; import DangerAction from "../components/DangerAction"; @@ -109,6 +110,7 @@ export default function Settings() { /** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */ function ApiTokensCard() { + const confirm = useConfirm(); const [tokens, setTokens] = useState([]); const [name, setName] = useState(""); const [fresh, setFresh] = useState(null); @@ -138,7 +140,13 @@ function ApiTokensCard() { } async function remove(token) { - if (!confirm(`Token "${token.name}" widerrufen? Zugriffe damit funktionieren sofort nicht mehr.`)) return; + const ok = await confirm({ + title: `Token „${token.name}“ widerrufen?`, + message: "Zugriffe mit diesem Token funktionieren sofort nicht mehr.", + confirmLabel: "Widerrufen", + danger: true, + }); + if (!ok) return; try { await api.deleteApiToken(token.id); load(); diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index 5b37eb4..6ef1797 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -1,8 +1,10 @@ import { useRef, useState } from "react"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; export default function Transfer() { + const confirm = useConfirm(); const fileRef = useRef(null); const [file, setFile] = useState(null); const [mode, setMode] = useState("add"); @@ -36,7 +38,12 @@ export default function Transfer() { mode === "replace_all" ? "ALLE Bestände werden vorher gelöscht und aus der Datei neu aufgebaut. Fortfahren?" : "Für alle Produkte in der Datei werden die vorhandenen Chargen gelöscht und ersetzt. Fortfahren?"; - if (!confirm(warning)) return; + if (!(await confirm({ + title: "Vorhandene Bestände ersetzen?", + message: warning, + confirmLabel: "Ersetzen", + danger: true, + }))) return; } setError(null); setResult(null); diff --git a/web/src/pages/Units.jsx b/web/src/pages/Units.jsx index 2f4f102..0c39aea 100644 --- a/web/src/pages/Units.jsx +++ b/web/src/pages/Units.jsx @@ -1,11 +1,13 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; import { fmt } from "../units"; const KIND_LABEL = { count: "Anzahl (Stück)", weight: "Gewicht (Basis: Gramm)", volume: "Volumen (Basis: Milliliter)" }; export default function Units() { + const confirm = useConfirm(); const [units, setUnits] = useState([]); const [form, setForm] = useState({ name: "", kind: "weight", factor: "" }); const [error, setError] = useState(null); @@ -33,7 +35,13 @@ export default function Units() { } async function remove(u) { - if (!confirm(`Einheit "${u.name}" löschen?`)) return; + const ok = await confirm({ + title: `Einheit „${u.name}“ löschen?`, + message: "Produkte, die diese Einheit nutzen, fallen auf ihre Basiseinheit zurück.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; try { await api.deleteUnit(u.id); load(); diff --git a/web/src/pages/Users.jsx b/web/src/pages/Users.jsx index 3c3be3f..cb7d002 100644 --- a/web/src/pages/Users.jsx +++ b/web/src/pages/Users.jsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import { api } from "../api"; +import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; export default function Users() { + const confirm = useConfirm(); const { user: me } = useAuth(); const [users, setUsers] = useState([]); const [form, setForm] = useState({ username: "", password: "", role: "user" }); @@ -43,7 +45,13 @@ export default function Users() { } async function remove(u) { - if (!confirm(`Benutzer "${u.username}" löschen?`)) return; + const ok = await confirm({ + title: `Benutzer „${u.username}“ löschen?`, + message: "Die Anmeldung mit diesem Konto ist danach nicht mehr möglich.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; try { await api.deleteUser(u.id); load(); diff --git a/web/src/styles.css b/web/src/styles.css index 941fa60..558e54a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -305,7 +305,31 @@ td select { width: auto; min-width: 132px; max-width: 100%; } .precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; } .precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; } /* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */ - /* Gefahrenbereich: deutlich abgesetzt, damit man nicht versehentlich hineinruscht */ + /* ---------- Rueckfrage-Dialog ---------- */ +.modal-backdrop { + position: fixed; inset: 0; z-index: 50; + display: grid; place-items: center; + padding: var(--sp-4); + background: rgba(9, 12, 18, 0.55); +} +.modal { + width: 100%; max-width: 440px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--sp-5); + box-shadow: 0 12px 32px rgba(9, 12, 18, 0.28); +} +.modal-head { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-3); } +.modal-head h2 { margin: 0; } +.modal-body { margin: 0 0 var(--sp-5); color: var(--muted); font-size: 0.9rem; } +.icon-danger { color: var(--danger); } +/* Gefuellter Knopf fuer unwiderrufliche Schritte - der flache .btn.danger + geht in einem Dialog zu leicht unter. */ +.btn.danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; } +.btn.danger-solid:hover { background: #a63224; border-color: #a63224; } + +/* Gefahrenbereich: deutlich abgesetzt, damit man nicht versehentlich hineinruscht */ .danger-zone { border-color: var(--danger); } .danger-zone .card-head .icon { color: var(--danger); } .danger-action { padding: var(--sp-4) 0; border-top: 1px solid var(--border); }