Eigener Rueckfrage-Dialog statt Browsermeldung, Zeitstempel im Export, Notizen an Gruppen-Codes

Rueckfragen liefen bisher ueber window.confirm. Das reisst aus der Oberflaeche,
zeigt den Hostnamen, laesst sich nicht gestalten und die Knoepfe heissen immer
"OK/Abbrechen". Neu ist ein eigener Dialog (src/confirm.jsx) als Provider mit
dem Hook useConfirm. Der Aufruf bleibt so einfach wie vorher, weil er eine
Zusage zurueckgibt:

    if (!(await confirm({ title: "…?", message: "…" }))) return;

Damit sind alle neun Stellen umgestellt: Gruppen, Kategorien, Lagerorte,
Einheiten, Benutzer, API-Tokens, Produkt und Charge loeschen sowie das Ersetzen
beim Import. Jede Rueckfrage hat jetzt einen sprechenden Titel, einen Satz zu
den Folgen und einen benannten Knopf ("Loeschen", "Widerrufen", "Ersetzen")
statt eines nichtssagenden OK. Unwiderrufliche Schritte sind rot.
Kuenftig gilt: keine Browserdialoge mehr.

Export: Dateinamen tragen jetzt Datum und Uhrzeit
(bestand_2026-07-22_2130.csv, project-good-backup_2026-07-22_2130.json). Ohne
Zeitstempel hiessen mehrere Ausleitungen alle gleich und der Browser haengte
(1), (2) an - dann war nicht mehr erkennbar, welche die aktuelle ist. Der
Zeitstempel entsteht an beiden Enden gleich: im Content-Disposition-Kopf des
Backends und im Dateinamen, den der Browser setzt. Im JSON steht zusaetzlich
die Ortszeit neben dem bereits vorhandenen UTC-Zeitpunkt. Die CSV bleibt
inhaltlich unveraendert - eine zusaetzliche Spalte oder Kopfzeile wuerde die
Datei in Excel nur stoeren.

Notizen an Gruppen-Codes: Codes, die beim Zuordnen eines Artikels automatisch
entstehen, hatten bisher keine Moeglichkeit, eine Notiz zu bekommen - die liess
sich nur beim Anlegen von Hand mitgeben. Neu PATCH /groups/{id}/barcodes/{code}
und ein Notizfeld in der Liste, das beim Verlassen speichert. Leeren entfernt
die Notiz.

Getestet: 57 pytest-Tests unveraendert gruen, Web-Build laeuft durch. Gegen die
laufende API geprueft: Zeitstempel in beiden Content-Disposition-Koepfen und im
JSON-Inhalt; Notiz an einem automatisch angelegten Code setzen, aendern und
leeren, unbekannter Code antwortet mit 404. Die Dialoge selbst habe ich nicht
im Browser angeklickt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 21:31:45 +02:00
parent 48cc2646e8
commit dfbc62398d
16 changed files with 263 additions and 18 deletions

View File

@@ -6,6 +6,7 @@ from ..deps import get_current_user, require_admin
from ..models import Barcode, Group, Product, User from ..models import Barcode, Group, Product, User
from ..schemas import ( from ..schemas import (
BarcodeCreate, BarcodeCreate,
BarcodeNoteUpdate,
BarcodeOut, BarcodeOut,
GroupCreate, GroupCreate,
GroupOut, GroupOut,
@@ -171,6 +172,36 @@ def add_group_barcode(
return _group_to_out(db, group) 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) @router.delete("/{group_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group_barcode( def delete_group_barcode(
group_id: int, group_id: int,

View File

@@ -47,6 +47,15 @@ PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Export # 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]: def _article_unit(product: Product) -> tuple[float, str]:
"""Faktor und Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).""" """Faktor und Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit)."""
unit_name, unit_factor = display_unit_info(product) unit_name, unit_factor = display_unit_info(product)
@@ -104,7 +113,9 @@ def export_stock_csv(
return Response( return Response(
content="" + buf.getvalue(), content="" + buf.getvalue(),
media_type="text/csv; charset=utf-8", 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 = { data = {
"version": 1, "version": 1,
"exported_at": datetime.now(timezone.utc).isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [ "units": [
{"name": u.name, "kind": u.kind.value, "factor": u.factor} {"name": u.name, "kind": u.kind.value, "factor": u.factor}
for u in db.query(Unit).order_by(Unit.id).all() for u in db.query(Unit).order_by(Unit.id).all()
@@ -171,7 +183,10 @@ def export_backup_json(
return Response( return Response(
content=json.dumps(data, ensure_ascii=False, indent=2), content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json; charset=utf-8", 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"'
},
) )

View File

@@ -244,6 +244,11 @@ class BarcodeCreate(BaseModel):
note: str | None = Field(default=None, max_length=120) 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 ---- # ---- Stock movements ----
class CheckInRequest(BaseModel): class CheckInRequest(BaseModel):
product_id: int | None = None product_id: int | None = None

View File

@@ -72,6 +72,14 @@ async function request(path, { method = "GET", body, form, formData } = {}) {
return data; 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. // Datei mit Auth-Header laden und im Browser als Download anbieten.
export async function downloadFile(path, filename) { export async function downloadFile(path, filename) {
const token = getToken(); const token = getToken();
@@ -148,12 +156,16 @@ export const api = {
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }), deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
addGroupBarcode: (id, body) => request(`/groups/${id}/barcodes`, { method: "POST", body }), 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) => deleteGroupBarcode: (id, code) =>
request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }), request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
// Export / Import // Export / Import
exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"), // Zeitstempel im Dateinamen, sonst heissen mehrere Ausleitungen alle gleich.
exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"), exportCsv: () => downloadFile("/export/stock.csv", `bestand_${zeitstempel()}.csv`),
exportJson: () =>
downloadFile("/export/backup.json", `project-good-backup_${zeitstempel()}.json`),
importStock: (file, mode = "add") => { importStock: (file, mode = "add") => {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);

View File

@@ -12,7 +12,7 @@ import Icon from "./Icon";
* onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt. * onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt.
*/ */
export default function BarcodeList({ export default function BarcodeList({
barcodes = [], productBarcodes = [], onAdd, onDelete, disabled = false, hint, barcodes = [], productBarcodes = [], onAdd, onDelete, onEditNote, disabled = false, hint,
}) { }) {
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [note, setNote] = useState(""); const [note, setNote] = useState("");
@@ -45,9 +45,20 @@ export default function BarcodeList({
))} ))}
{barcodes.map((b) => ( {barcodes.map((b) => (
<li key={b.id ?? b.code}> <li key={b.id ?? b.code}>
<span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}> <span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, flex: 1 }}>
<code className="strong">{b.code}</code> <code className="strong">{b.code}</code>
{b.note && <span className="muted small">{b.note}</span>} {/* Notiz auch nachtraeglich aenderbar - automatisch angelegte Codes
hatten sonst nie eine Gelegenheit, eine zu bekommen. */}
{onEditNote && !disabled ? (
<input defaultValue={b.note || ""} placeholder="Notiz…"
style={{ marginTop: 0, maxWidth: 220 }}
onBlur={(e) => {
const wert = e.target.value.trim();
if (wert !== (b.note || "")) onEditNote(b.code, wert);
}} />
) : (
b.note && <span className="muted small">{b.note}</span>
)}
</span> </span>
{!disabled && ( {!disabled && (
<button className="btn-icon danger" title="Code entfernen" <button className="btn-icon danger" title="Code entfernen"

68
web/src/confirm.jsx Normal file
View File

@@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import Icon from "./components/Icon";
/**
* Rückfragen innerhalb der Anwendung statt über den Browser.
*
* `window.confirm` reißt den Benutzer aus der Oberfläche, zeigt den Hostnamen
* und lässt sich weder gestalten noch beschriften. Der Dialog hier sieht aus wie
* der Rest der Anwendung, kann eine Aktion benennen ("Löschen" statt "OK") und
* gefährliche Schritte rot hervorheben.
*
* Aufruf bleibt so einfach wie vorher:
*
* const confirm = useConfirm();
* if (!(await confirm({ title: "Gruppe löschen?", message: "…" }))) return;
*/
const ConfirmContext = createContext(async () => false);
export function ConfirmProvider({ children }) {
const [frage, setFrage] = useState(null);
// Die Zusage des offenen Dialogs, damit `await confirm(...)` funktioniert.
const antwort = useRef(null);
const confirm = useCallback((optionen) => {
setFrage(typeof optionen === "string" ? { message: optionen } : optionen);
return new Promise((resolve) => {
antwort.current = resolve;
});
}, []);
function schliessen(ergebnis) {
setFrage(null);
antwort.current?.(ergebnis);
antwort.current = null;
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{frage && (
<div className="modal-backdrop"
onClick={(e) => e.target === e.currentTarget && schliessen(false)}>
<div className="modal" role="dialog" aria-modal="true">
<div className="modal-head">
<Icon name="alert" size={18} className={frage.danger ? "icon-danger" : ""} />
<h2>{frage.title || "Bitte bestätigen"}</h2>
</div>
<p className="modal-body">{frage.message}</p>
<div className="btn-pair">
<button type="button" className="btn" autoFocus onClick={() => schliessen(false)}>
{frage.cancelLabel || "Abbrechen"}
</button>
<button type="button"
className={frage.danger ? "btn danger-solid" : "btn primary"}
onClick={() => schliessen(true)}>
{frage.confirmLabel || "Fortfahren"}
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
);
}
export function useConfirm() {
return useContext(ConfirmContext);
}

View File

@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { AuthProvider } from "./auth"; import { AuthProvider } from "./auth";
import { SettingsProvider } from "./settings"; import { SettingsProvider } from "./settings";
import { ConfirmProvider } from "./confirm";
import "./styles.css"; import "./styles.css";
import { applyFavicon } from "./branding"; import { applyFavicon } from "./branding";
@@ -15,7 +16,9 @@ ReactDOM.createRoot(document.getElementById("root")).render(
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<SettingsProvider> <SettingsProvider>
<ConfirmProvider>
<App /> <App />
</ConfirmProvider>
</SettingsProvider> </SettingsProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
@@ -11,6 +12,7 @@ import { asTree } from "../categoryTree";
* Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein. * Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein.
*/ */
export default function Categories() { export default function Categories() {
const confirm = useConfirm();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [form, setForm] = useState({ name: "", parent_id: "" }); const [form, setForm] = useState({ name: "", parent_id: "" });
@@ -55,7 +57,13 @@ export default function Categories() {
const hinweis = category.product_count const hinweis = category.product_count
? ` ${category.product_count} Artikel verlieren die Zuordnung, bleiben aber erhalten.` ? ` ${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 { try {
await api.deleteCategory(category.id); await api.deleteCategory(category.id);
load(); load();

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList"; import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { fmt } from "../units"; import { fmt } from "../units";
export default function Groups() { export default function Groups() {
const confirm = useConfirm();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
@@ -52,7 +54,13 @@ export default function Groups() {
} }
async function remove(group) { 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 { try {
await api.deleteGroup(group.id); await api.deleteGroup(group.id);
if (selectedId === group.id) setSelectedId(null); if (selectedId === group.id) setSelectedId(null);
@@ -174,6 +182,12 @@ export default function Groups() {
await load(); await load();
} catch (err) { setError(err.message); } } 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) => { onDelete={async (code) => {
try { try {
await api.deleteGroupBarcode(selected.id, code); await api.deleteGroupBarcode(selected.id, code);

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
export default function Locations() { export default function Locations() {
const confirm = useConfirm();
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [parentId, setParentId] = useState(""); const [parentId, setParentId] = useState("");
@@ -32,7 +34,13 @@ export default function Locations() {
} }
async function remove(id) { 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 { try {
await api.deleteLocation(id); await api.deleteLocation(id);
load(); load();

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList"; import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
@@ -28,6 +29,7 @@ const KIND_LABEL = { count: "Anzahl", weight: "Gewicht", volume: "Volumen" };
const KIND_ORDER = ["count", "weight", "volume"]; const KIND_ORDER = ["count", "weight", "volume"];
export default function ProductForm() { export default function ProductForm() {
const confirm = useConfirm();
const { id } = useParams(); const { id } = useParams();
const isNew = !id; const isNew = !id;
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
@@ -220,7 +222,13 @@ export default function ProductForm() {
} }
async function remove() { 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 { try {
await api.deleteProduct(id); await api.deleteProduct(id);
navigate("/products"); navigate("/products");
@@ -423,6 +431,7 @@ export default function ProductForm() {
/** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */ /** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */
function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) { function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
const confirm = useConfirm();
const { formatBestBefore } = useSettings(); const { formatBestBefore } = useSettings();
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day" }); 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) { 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 { try {
await api.deleteLot(l.id); await api.deleteLot(l.id);
await onChanged(); await onChanged();

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import BrandingUpload from "../components/BrandingUpload"; import BrandingUpload from "../components/BrandingUpload";
import DangerAction from "../components/DangerAction"; import DangerAction from "../components/DangerAction";
@@ -109,6 +110,7 @@ export default function Settings() {
/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */ /** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */
function ApiTokensCard() { function ApiTokensCard() {
const confirm = useConfirm();
const [tokens, setTokens] = useState([]); const [tokens, setTokens] = useState([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [fresh, setFresh] = useState(null); const [fresh, setFresh] = useState(null);
@@ -138,7 +140,13 @@ function ApiTokensCard() {
} }
async function remove(token) { 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 { try {
await api.deleteApiToken(token.id); await api.deleteApiToken(token.id);
load(); load();

View File

@@ -1,8 +1,10 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
export default function Transfer() { export default function Transfer() {
const confirm = useConfirm();
const fileRef = useRef(null); const fileRef = useRef(null);
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [mode, setMode] = useState("add"); const [mode, setMode] = useState("add");
@@ -36,7 +38,12 @@ export default function Transfer() {
mode === "replace_all" mode === "replace_all"
? "ALLE Bestände werden vorher gelöscht und aus der Datei neu aufgebaut. Fortfahren?" ? "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?"; : "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); setError(null);
setResult(null); setResult(null);

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { fmt } from "../units"; import { fmt } from "../units";
const KIND_LABEL = { count: "Anzahl (Stück)", weight: "Gewicht (Basis: Gramm)", volume: "Volumen (Basis: Milliliter)" }; const KIND_LABEL = { count: "Anzahl (Stück)", weight: "Gewicht (Basis: Gramm)", volume: "Volumen (Basis: Milliliter)" };
export default function Units() { export default function Units() {
const confirm = useConfirm();
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [form, setForm] = useState({ name: "", kind: "weight", factor: "" }); const [form, setForm] = useState({ name: "", kind: "weight", factor: "" });
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -33,7 +35,13 @@ export default function Units() {
} }
async function remove(u) { 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 { try {
await api.deleteUnit(u.id); await api.deleteUnit(u.id);
load(); load();

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
export default function Users() { export default function Users() {
const confirm = useConfirm();
const { user: me } = useAuth(); const { user: me } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [form, setForm] = useState({ username: "", password: "", role: "user" }); const [form, setForm] = useState({ username: "", password: "", role: "user" });
@@ -43,7 +45,13 @@ export default function Users() {
} }
async function remove(u) { 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 { try {
await api.deleteUser(u.id); await api.deleteUser(u.id);
load(); load();

View File

@@ -305,6 +305,30 @@ 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 { 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; } .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 */ /* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */
/* ---------- 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 */ /* Gefahrenbereich: deutlich abgesetzt, damit man nicht versehentlich hineinruscht */
.danger-zone { border-color: var(--danger); } .danger-zone { border-color: var(--danger); }
.danger-zone .card-head .icon { color: var(--danger); } .danger-zone .card-head .icon { color: var(--danger); }