diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..9f2f120 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,29 @@ +:root { --gap: 16px; --radius: 12px; --muted: #6b7280; } +* { box-sizing: border-box; } +body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, Noto Sans, "Apple Color Emoji", "Segoe UI Emoji"; margin:0; background:#f6f7fb; } +.container { max-width: 1100px; margin: 0 auto; padding: 16px; } +header.container { display:flex; align-items:center; justify-content:space-between; } +header h1 { margin:0; font-size: 20px; } +nav a { margin-right: 12px; text-decoration:none; color:#111827; } +nav a:hover { text-decoration: underline; } +.card { background:white; padding:16px; border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.06); margin-bottom:16px; } +.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--gap); } +.product { display:flex; gap:12px; border: 1px solid #e5e7eb; border-radius: var(--radius); padding:10px; background:#fff; color:inherit; text-decoration:none; } +.product img { width:72px; height:72px; object-fit:cover; border-radius:8px; border:1px solid #eee; } +.placeholder { width:72px; height:72px; background:#eef2ff; border:1px dashed #c7d2fe; display:flex; align-items:center; justify-content:center; border-radius:8px; color:#6366f1; font-size:12px; } +.meta .name { font-weight:600; } +.muted { color: var(--muted); } +.stock.warn { color:#dc2626; font-weight:600; } +.actions { margin-top:8px; } +.btn { background:#111827; color:white; border:0; padding:8px 12px; border-radius:8px; cursor:pointer; text-decoration:none; display:inline-block; } +.btn:hover { filter: brightness(1.1); } +.btn.sm { padding:6px 10px; font-size: 14px; } +.btn.danger { background:#dc2626; } +.table { width:100%; border-collapse: collapse; background:white; } +.table th, .table td { border-bottom:1px solid #e5e7eb; padding:8px; text-align:left; } +.tinyimg img { width:42px; height:42px; object-fit:cover; border-radius:8px; border:1px solid #eee; } +.flex { display:flex; gap:12px; align-items:center; } +.flex-between { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.form-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--gap); } +.form-inline { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } +.cover { width:96px; height:96px; object-fit:cover; border-radius:8px; border:1px solid #eee; } \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..409db2c --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module git.send.nrw/sendnrw/kitcheninv + +go 1.24.4 + +require modernc.org/sqlite v1.38.2 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.34.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..057de1d --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/kueche.db b/kueche.db new file mode 100644 index 0000000..5c8d2fe Binary files /dev/null and b/kueche.db differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..4a970ee --- /dev/null +++ b/main.go @@ -0,0 +1,600 @@ +// main.go +// Einfaches Kücheninventar mit Weboberfläche und persistenter SQLite-Datenbank. +// Features: +// - Produkte: Name, Hersteller, Größe (Inhaltsmenge), Bild (Upload oder URL), bevorzugter Händler, Mindestbestand +// - Bestände entstehen aus Einheiten (Chargen) mit je einem Ablaufdatum +// - Automatischer Bestand je Produkt = Anzahl Einheiten mit Status "in" +// - Vorschlag: Nächste ablaufende Einheit (global und je Produkt) mit Button "Ausbuchen" +// - Warnungen bei Unterschreitung Mindestbestand +// - Einkaufslisten pro Händler: Welche Artikel und Menge sind zu beschaffen (Min - Ist, falls > 0) +// +// Start: +// go mod init kitcheninv +// go get modernc.org/sqlite +// go run . +// +// Danach im Browser: http://localhost:8080 +// Uploads liegen im Ordner ./uploads + +package main + +import ( + "database/sql" + "embed" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +//go:embed templates/* assets/* +var embeddedFS embed.FS + +type App struct { + DB *sql.DB + Templates *template.Template // (legacy – nicht genutzt für Rendering) + UploadDir string +} + +type Product struct { + ID int + Name string + Manufacturer string + Size string + ImagePath string // lokal gespeicherter Pfad oder externe URL + PreferredVendor string + MinStock int + CurrentStock int // berechnet +} + +type Unit struct { + ID int + ProductID int + ExpiryDate string // YYYY-MM-DD + Status string // "in" | "out" + AddedAt string + Product *Product // optional befüllt bei JOINs +} + +func main() { + if err := os.MkdirAll("uploads", 0o755); err != nil { + log.Fatalf("kann Upload-Verzeichnis nicht erstellen: %v", err) + } + + dsn := "file:kueche.db?_pragma=busy_timeout(5000)&cache=shared" + db, err := sql.Open("sqlite", dsn) + if err != nil { + log.Fatal(err) + } + if err := initDB(db); err != nil { + log.Fatal(err) + } + + // Templates werden pro View on-demand zusammengebaut, um Mehrfach-Definitionen zu vermeiden. + tmpl := template.New("legacy") // Platzhalter – global ungenutzt + + app := &App{DB: db, Templates: tmpl /* legacy */, UploadDir: "uploads"} + + mux := http.NewServeMux() + + // Routen + mux.HandleFunc("/", app.handleDashboard) + mux.HandleFunc("/products", app.handleProducts) + mux.HandleFunc("/products/create", app.handleCreateProduct) + mux.HandleFunc("/products/", app.handleProductDetail) // /products/{id} + mux.HandleFunc("/units/", app.handleUnitActions) // /units/{id}/checkout + mux.HandleFunc("/alerts", app.handleAlerts) + mux.HandleFunc("/shopping-list", app.handleShoppingList) + + // Statische Assets (CSS) + mux.Handle("/assets/", http.FileServer(http.FS(embeddedFS))) + // Uploads + uploadFS := http.FileServer(http.Dir(app.UploadDir)) + mux.Handle("/uploads/", http.StripPrefix("/uploads/", uploadFS)) + + addr := ":8080" + log.Printf("Server läuft auf http://localhost%v", addr) + if err := http.ListenAndServe(addr, securityHeaders(mux)); err != nil { + log.Fatal(err) + } +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer-when-downgrade") + w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'") + next.ServeHTTP(w, r) + }) +} + +// --- DB --- + +func initDB(db *sql.DB) error { + if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { + return err + } + // Schema + schema := ` +CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + manufacturer TEXT, + size TEXT, + image_path TEXT, + preferred_vendor TEXT, + min_stock INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS units ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, + expiry_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'in', + added_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_units_product_status ON units(product_id, status); +CREATE INDEX IF NOT EXISTS idx_units_expiry_in ON units(expiry_date) WHERE status = 'in'; +` + _, err := db.Exec(schema) + return err +} + +// Hilfsfunktionen + +func atoiDefault(s string, def int) int { + i, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return def + } + return i +} + +func (a *App) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + files := []string{"templates/base.gohtml", "templates/partials.gohtml"} + switch name { + case "dashboard.gohtml": + files = append(files, "templates/dashboard.gohtml") + case "products.gohtml": + files = append(files, "templates/products.gohtml") + case "product_create.gohtml": + files = append(files, "templates/product_create.gohtml") + case "product_detail.gohtml": + files = append(files, "templates/product_detail.gohtml") + case "alerts.gohtml": + files = append(files, "templates/alerts.gohtml") + case "shopping_list.gohtml": + files = append(files, "templates/shopping_list.gohtml") + default: + http.Error(w, "Unbekannte Ansicht", http.StatusInternalServerError) + return + } + tmpl := template.Must(template.New("base").Funcs(template.FuncMap{ + "dateHuman": func(iso string) string { + if iso == "" { + return "" + } + t, err := time.Parse("2006-01-02", iso) + if err != nil { + return iso + } + return t.Format("02.01.2006") + }, + "now": time.Now, + }).ParseFS(embeddedFS, files...)) + if err := tmpl.ExecuteTemplate(w, "base", data); err != nil { + log.Println("template error:", err) + http.Error(w, "Template-Fehler", http.StatusInternalServerError) + } +} + +func (a *App) loadProduct(id int) (*Product, error) { + row := a.DB.QueryRow(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products WHERE id=?`, id) + var p Product + if err := row.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err != nil { + return nil, err + } + stock, _ := a.productStock(p.ID) + p.CurrentStock = stock + return &p, nil +} + +func (a *App) productStock(productID int) (int, error) { + row := a.DB.QueryRow(`SELECT COUNT(1) FROM units WHERE product_id=? AND status='in'`, productID) + var c int + return c, row.Scan(&c) +} + +func (a *App) nextExpiringGlobal() (*Unit, error) { + row := a.DB.QueryRow(` +SELECT u.id, u.product_id, u.expiry_date, u.status, u.added_at, + p.id, p.name, p.manufacturer, p.size, p.image_path, p.preferred_vendor, p.min_stock +FROM units u +JOIN products p ON p.id = u.product_id +WHERE u.status='in' +ORDER BY u.expiry_date ASC +LIMIT 1 +`) + var u Unit + var p Product + if err := row.Scan(&u.ID, &u.ProductID, &u.ExpiryDate, &u.Status, &u.AddedAt, + &p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err != nil { + return nil, err + } + stock, _ := a.productStock(p.ID) + p.CurrentStock = stock + u.Product = &p + return &u, nil +} + +// --- Handler --- + +func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + // alle Produkte laden + rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + var products []Product + for rows.Next() { + var p Product + if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil { + p.CurrentStock, _ = a.productStock(p.ID) + products = append(products, p) + } + } + var next *Unit + if u, err := a.nextExpiringGlobal(); err == nil { + next = u + } + // Warnungen zählen + alerts := 0 + for _, p := range products { + if p.MinStock > 0 && p.CurrentStock < p.MinStock { + alerts++ + } + } + data := map[string]any{ + "Products": products, + "Next": next, + "Alerts": alerts, + } + a.render(w, "dashboard.gohtml", data) +} + +func (a *App) handleProducts(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + var list []Product + for rows.Next() { + var p Product + if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil { + p.CurrentStock, _ = a.productStock(p.ID) + list = append(list, p) + } + } + data := map[string]any{ + "Products": list, + } + a.render(w, "products.gohtml", data) + return + } + if r.Method == http.MethodPost { + http.Redirect(w, r, "/products/create", http.StatusSeeOther) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) +} + +func (a *App) handleCreateProduct(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + a.render(w, "product_create.gohtml", nil) + return + } + if r.Method == http.MethodPost { + if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB + http.Error(w, "Ungültiges Formular", 400) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Name ist erforderlich", 400) + return + } + manufacturer := strings.TrimSpace(r.FormValue("manufacturer")) + size := strings.TrimSpace(r.FormValue("size")) + preferredVendor := strings.TrimSpace(r.FormValue("preferred_vendor")) + minStock := atoiDefault(r.FormValue("min_stock"), 0) + + imagePath := strings.TrimSpace(r.FormValue("image_url")) + // Datei-Upload hat Priorität, falls vorhanden + file, header, err := r.FormFile("image_file") + if err == nil && header != nil { + defer file.Close() + ext := strings.ToLower(filepath.Ext(header.Filename)) + if extOK(ext) { + fname := fmt.Sprintf("%d_%s", time.Now().UnixNano(), sanitizeFilename(header.Filename)) + outPath := filepath.Join(a.UploadDir, fname) + out, err := os.Create(outPath) + if err != nil { + http.Error(w, "Upload fehlgeschlagen", 500) + return + } + defer out.Close() + if _, err := io.Copy(out, file); err != nil { + http.Error(w, "Upload fehlgeschlagen", 500) + return + } + imagePath = "/uploads/" + fname + } + } + res, err := a.DB.Exec(`INSERT INTO products(name, manufacturer, size, image_path, preferred_vendor, min_stock) VALUES(?,?,?,?,?,?)`, + name, manufacturer, size, imagePath, preferredVendor, minStock) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + id64, _ := res.LastInsertId() + http.Redirect(w, r, fmt.Sprintf("/products/%d", id64), http.StatusSeeOther) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) +} + +func sanitizeFilename(s string) string { + s = strings.ReplaceAll(s, " ", "_") + s = strings.ReplaceAll(s, ":", "-") + return s +} + +func extOK(ext string) bool { + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + return true + default: + return false + } +} + +func (a *App) handleProductDetail(w http.ResponseWriter, r *http.Request) { + // erwartetes Muster: /products/{id} oder Unterpfade /add-units + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/products/"), "/") + if len(parts) == 0 || parts[0] == "" { + http.NotFound(w, r) + return + } + id := atoiDefault(parts[0], 0) + if id == 0 { + http.NotFound(w, r) + return + } + + if len(parts) == 2 && parts[1] == "add-units" { + if r.Method == http.MethodPost { + if err := r.ParseForm(); err != nil { + http.Error(w, "Ungültiges Formular", 400) + return + } + qty := atoiDefault(r.FormValue("quantity"), 0) + exp := strings.TrimSpace(r.FormValue("expiry_date")) // YYYY-MM-DD + if qty <= 0 || len(exp) != 10 { + http.Error(w, "Menge und Datum erforderlich", 400) + return + } + now := time.Now().Format(time.RFC3339) + tx, err := a.DB.Begin() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + stmt, err := tx.Prepare(`INSERT INTO units(product_id, expiry_date, status, added_at) VALUES(?, ?, 'in', ?)`) + if err != nil { + tx.Rollback() + http.Error(w, err.Error(), 500) + return + } + for i := 0; i < qty; i++ { + if _, err := stmt.Exec(id, exp, now); err != nil { + tx.Rollback() + http.Error(w, err.Error(), 500) + return + } + } + stmt.Close() + if err := tx.Commit(); err != nil { + http.Error(w, err.Error(), 500) + return + } + http.Redirect(w, r, fmt.Sprintf("/products/%d", id), http.StatusSeeOther) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + // Details anzeigen, optional POST zur Produktbearbeitung (Min-Bestand etc.) + if r.Method == http.MethodPost { + if err := r.ParseForm(); err == nil { + min := atoiDefault(r.FormValue("min_stock"), 0) + pv := strings.TrimSpace(r.FormValue("preferred_vendor")) + size := strings.TrimSpace(r.FormValue("size")) + manufacturer := strings.TrimSpace(r.FormValue("manufacturer")) + _, err := a.DB.Exec(`UPDATE products SET min_stock=?, preferred_vendor=?, size=?, manufacturer=? WHERE id=?`, min, pv, size, manufacturer, id) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + http.Redirect(w, r, fmt.Sprintf("/products/%d", id), http.StatusSeeOther) + return + } + } + + p, err := a.loadProduct(id) + if err != nil { + http.Error(w, "Produkt nicht gefunden", 404) + return + } + + // Einheiten laden (nur "in") + rows, err := a.DB.Query(`SELECT id, product_id, expiry_date, status, added_at FROM units WHERE product_id=? AND status='in' ORDER BY expiry_date ASC, id ASC`, id) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + var units []Unit + for rows.Next() { + var u Unit + if err := rows.Scan(&u.ID, &u.ProductID, &u.ExpiryDate, &u.Status, &u.AddedAt); err == nil { + units = append(units, u) + } + } + var next *Unit + if len(units) > 0 { + n := units[0] + n.Product = p + next = &n + } + data := map[string]any{ + "Product": p, + "Units": units, + "Next": next, + } + a.render(w, "product_detail.gohtml", data) +} + +func (a *App) handleUnitActions(w http.ResponseWriter, r *http.Request) { + // /units/{id}/checkout + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/units/"), "/") + if len(parts) < 2 { + http.NotFound(w, r) + return + } + unitID := atoiDefault(parts[0], 0) + action := parts[1] + if unitID == 0 { + http.NotFound(w, r) + return + } + if action == "checkout" && r.Method == http.MethodPost { + // hole Produkt-ID für Redirect + var productID int + row := a.DB.QueryRow(`SELECT product_id FROM units WHERE id=?`, unitID) + if err := row.Scan(&productID); err != nil { + http.Error(w, "Einheit nicht gefunden", 404) + return + } + _, err := a.DB.Exec(`UPDATE units SET status='out' WHERE id=?`, unitID) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + // Entscheide Redirect: falls "from" übergeben, dorthin zurück + from := r.URL.Query().Get("from") + if from != "" { + http.Redirect(w, r, from, http.StatusSeeOther) + return + } + http.Redirect(w, r, fmt.Sprintf("/products/%d", productID), http.StatusSeeOther) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) +} + +func (a *App) handleAlerts(w http.ResponseWriter, r *http.Request) { + rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + var below []Product + for rows.Next() { + var p Product + if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil { + p.CurrentStock, _ = a.productStock(p.ID) + if p.MinStock > 0 && p.CurrentStock < p.MinStock { + below = append(below, p) + } + } + } + data := map[string]any{ + "Below": below, + } + a.render(w, "alerts.gohtml", data) +} + +func (a *App) handleShoppingList(w http.ResponseWriter, r *http.Request) { + // Lade alle Produkte mit aktuellem Bestand, gruppiere in Go nach Händler + rows, err := a.DB.Query(` +SELECT p.id, p.name, p.manufacturer, p.size, p.image_path, p.preferred_vendor, p.min_stock, + COALESCE(SUM(CASE WHEN u.status='in' THEN 1 ELSE 0 END), 0) AS stock +FROM products p +LEFT JOIN units u ON u.product_id = p.id +GROUP BY p.id +ORDER BY p.preferred_vendor, p.name COLLATE NOCASE +`) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + type Item struct { + Product + Needed int + } + group := map[string][]Item{} + for rows.Next() { + var p Product + var stock int + if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock, &stock); err == nil { + p.CurrentStock = stock + need := p.MinStock - stock + if p.MinStock > 0 && need > 0 { + key := p.PreferredVendor + if strings.TrimSpace(key) == "" { + key = "(Kein Händler)" + } + group[key] = append(group[key], Item{Product: p, Needed: need}) + } + } + } + // Für stabile Ausgabe auch Liste der Keys bauen + var vendors []string + for v := range group { + vendors = append(vendors, v) + } + // einfache Sortierung der Vendor-Namen + for i := 0; i < len(vendors); i++ { + for j := i + 1; j < len(vendors); j++ { + if strings.ToLower(vendors[j]) < strings.ToLower(vendors[i]) { + vendors[i], vendors[j] = vendors[j], vendors[i] + } + } + } + data := map[string]any{ + "Groups": group, + "Vendors": vendors, + } + a.render(w, "shopping_list.gohtml", data) +} diff --git a/templates/alerts.gohtml b/templates/alerts.gohtml new file mode 100644 index 0000000..9e037a6 --- /dev/null +++ b/templates/alerts.gohtml @@ -0,0 +1,22 @@ +{{define "title"}}Warnungen – Kücheninventar{{end}} +{{define "content"}} +
| Produkt | Aktuell | Min | Händler | |
|---|---|---|---|---|
| {{.Name}} | +{{.CurrentStock}} | +{{.MinStock}} | +{{.PreferredVendor}} | +Zur Einkaufsliste | +
Alles gut, keine Unterschreitungen.
+{{end}} +{{end}} \ No newline at end of file diff --git a/templates/base.gohtml b/templates/base.gohtml new file mode 100644 index 0000000..7fda2b7 --- /dev/null +++ b/templates/base.gohtml @@ -0,0 +1,29 @@ +{{define "base"}} + + + + + +Keine Einheiten auf Lager.
+{{end}} +Keine Einheiten vorhanden.
+{{end}} +| # | Ablaufdatum | Aktion |
|---|---|---|
| {{.ID}} | +{{dateHuman .ExpiryDate}} | ++ + | +
Noch keine Einheiten angelegt.
+{{end}} + + +| Bild | Produkt | Hersteller | Größe | Händler | Bestand | Min |
|---|---|---|---|---|---|---|
| {{if .ImagePath}} |
+{{.Name}} | +{{.Manufacturer}} | +{{.Size}} | +{{.PreferredVendor}} | +{{.CurrentStock}} | +{{.MinStock}} | +
| Produkt | Hersteller | Größe | Benötigt |
|---|---|---|---|
| {{.Product.Name}} | +{{.Product.Manufacturer}} | +{{.Product.Size}} | +{{.Needed}} | +
Keine offenen Bedarfe – alle Mindestbestände sind erfüllt.
+{{end}} +{{end}} \ No newline at end of file