This commit is contained in:
2025-08-31 12:12:04 +02:00
parent f6643b712b
commit dbf53a9c30
13 changed files with 906 additions and 0 deletions

29
assets/style.css Normal file
View File

@@ -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; }

18
go.mod Normal file
View File

@@ -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
)

23
go.sum Normal file
View File

@@ -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=

BIN
kueche.db Normal file

Binary file not shown.

600
main.go Normal file
View File

@@ -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)
}

22
templates/alerts.gohtml Normal file
View File

@@ -0,0 +1,22 @@
{{define "title"}}Warnungen Kücheninventar{{end}}
{{define "content"}}
<h2>Produkte unter Mindestbestand</h2>
{{if .Below}}
<table class="table">
<thead><tr><th>Produkt</th><th>Aktuell</th><th>Min</th><th>Händler</th><th></th></tr></thead>
<tbody>
{{range .Below}}
<tr>
<td><a href="/products/{{.ID}}">{{.Name}}</a></td>
<td>{{.CurrentStock}}</td>
<td>{{.MinStock}}</td>
<td>{{.PreferredVendor}}</td>
<td><a class="btn sm" href="/shopping-list">Zur Einkaufsliste</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Alles gut, keine Unterschreitungen.</p>
{{end}}
{{end}}

29
templates/base.gohtml Normal file
View File

@@ -0,0 +1,29 @@
{{define "base"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{block "title" .}}Kücheninventar{{end}}</title>
<link rel="stylesheet" href="/assets/style.css"/>
</head>
<body>
<header class="container">
<h1>🍳 Kücheninventar</h1>
<nav>
<a href="/">Dashboard</a>
<a href="/products">Produkte</a>
<a href="/alerts">Warnungen{{if .Alerts}} ({{.Alerts}}){{end}}</a>
<a href="/shopping-list">Einkaufsliste</a>
</nav>
</header>
<main class="container">
{{template "flash" .}}
{{block "content" .}}{{end}}
</main>
<footer class="container muted">
<p>© {{now | printf "%d"}} Lokale Demo-App (Go + SQLite). Bilder bleiben lokal.</p>
</footer>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,39 @@
{{define "title"}}Dashboard Kücheninventar{{end}}
{{define "content"}}
<section class="card">
<h2>Nächste ablaufende Einheit</h2>
{{if .Next}}
<div class="flex-between">
<div>
<strong>{{.Next.Product.Name}}</strong>
{{if .Next.Product.Manufacturer}}<span class="muted"> {{.Next.Product.Manufacturer}}</span>{{end}}<br/>
<span>Ablaufdatum: {{dateHuman .Next.ExpiryDate}}</span>
</div>
<form method="post" action="/units/{{.Next.ID}}/checkout?from=/">
<button class="btn danger">Ausbuchen</button>
</form>
</div>
{{else}}
<p class="muted">Keine Einheiten auf Lager.</p>
{{end}}
</section>
<section class="card">
<h2>Produkte</h2>
<div class="grid">
{{range .Products}}
<a class="product" href="/products/{{.ID}}">
{{if .ImagePath}}<img src="{{.ImagePath}}" alt="{{.Name}}"/>
{{else}}<div class="placeholder">Kein Bild</div>{{end}}
<div class="meta">
<div class="name">{{.Name}}</div>
{{if .Manufacturer}}<div class="muted">{{.Manufacturer}}</div>{{end}}
<div class="stock {{if and (gt .MinStock 0) (lt .CurrentStock .MinStock)}}warn{{end}}">Bestand: {{.CurrentStock}}{{if gt .MinStock 0}} / Min {{.MinStock}}{{end}}</div>
</div>
</a>
{{end}}
</div>
<div class="actions"><a class="btn" href="/products/create">+ Produkt anlegen</a></div>
</section>
{{end}}

View File

@@ -0,0 +1,3 @@
{{define "flash"}}
{{/* Platzhalter für Nachrichten */}}
{{end}}

View File

@@ -0,0 +1,18 @@
{{define "title"}}Produkt anlegen Kücheninventar{{end}}
{{define "content"}}
<section class="card">
<h2>Neues Produkt</h2>
<form method="post" enctype="multipart/form-data" action="/products/create">
<div class="form-grid">
<label>Name*<input required name="name"/></label>
<label>Hersteller<input name="manufacturer"/></label>
<label>Größe / Inhalt<input name="size" placeholder="z.B. 500 g"/></label>
<label>Bevorzugter Händler<input name="preferred_vendor" placeholder="z.B. REWE"/></label>
<label>Mindestbestand<input type="number" min="0" name="min_stock" value="0"/></label>
<label>Bild (Datei)<input type="file" name="image_file" accept="image/*"/></label>
<label>Oder Bild-URL<input type="url" name="image_url" placeholder="https://…"/></label>
</div>
<button class="btn">Anlegen</button>
</form>
</section>
{{end}}

View File

@@ -0,0 +1,79 @@
{{define "title"}}Produkt Kücheninventar{{end}}
{{define "content"}}
<section class="card">
<div class="flex-between">
<div class="flex">
{{if .Product.ImagePath}}<img class="cover" src="{{.Product.ImagePath}}" alt="{{.Product.Name}}"/>
{{else}}<div class="cover placeholder">Kein Bild</div>{{end}}
<div>
<h2>{{.Product.Name}}</h2>
{{if .Product.Manufacturer}}<div class="muted">{{.Product.Manufacturer}}</div>{{end}}
{{if .Product.Size}}<div>Größe: {{.Product.Size}}</div>{{end}}
<div>Bevorzugter Händler: {{if .Product.PreferredVendor}}{{.Product.PreferredVendor}}{{else}}{{end}}</div>
<div>Bestand: <strong>{{.Product.CurrentStock}}</strong>{{if gt .Product.MinStock 0}} / Min {{.Product.MinStock}}{{end}}</div>
</div>
</div>
<form method="post" action="/products/{{.Product.ID}}">
<div class="form-inline">
<label>Min:<input type="number" min="0" name="min_stock" value="{{.Product.MinStock}}"/></label>
<label>Händler:<input name="preferred_vendor" value="{{.Product.PreferredVendor}}"/></label>
<label>Größe:<input name="size" value="{{.Product.Size}}"/></label>
<label>Hersteller:<input name="manufacturer" value="{{.Product.Manufacturer}}"/></label>
<button class="btn">Speichern</button>
</div>
</form>
</div>
</section>
<section class="card">
<h3>Nächste ablaufende Einheit</h3>
{{if .Next}}
<div class="flex-between">
<div><span>Ablaufdatum: {{dateHuman .Next.ExpiryDate}}</span></div>
<form method="post" action="/units/{{.Next.ID}}/checkout?from=/products/{{.Product.ID}}">
<button class="btn danger">Ausbuchen</button>
</form>
</div>
{{else}}
<p class="muted">Keine Einheiten vorhanden.</p>
{{end}}
</section>
<section class="card">
<h3>Einheiten</h3>
{{if .Units}}
<table class="table">
<thead><tr><th>#</th><th>Ablaufdatum</th><th>Aktion</th></tr></thead>
<tbody>
{{range .Units}}
<tr>
<td>{{.ID}}</td>
<td>{{dateHuman .ExpiryDate}}</td>
<td>
<form method="post" action="/units/{{.ID}}/checkout?from=/products/{{$.Product.ID}}">
<button class="btn sm">Ausbuchen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">Noch keine Einheiten angelegt.</p>
{{end}}
<details>
<summary>Einheiten hinzufügen</summary>
<form method="post" action="/products/{{.Product.ID}}/add-units">
<div class="form-inline">
<label>Menge:<input type="number" min="1" value="1" name="quantity"/></label>
<label>Ablaufdatum:<input type="date" name="expiry_date" required/></label>
<button class="btn">Hinzufügen</button>
</div>
</form>
</details>
</section>
{{end}}

20
templates/products.gohtml Normal file
View File

@@ -0,0 +1,20 @@
{{define "title"}}Produkte Kücheninventar{{end}}
{{define "content"}}
<div class="actions"><a class="btn" href="/products/create">+ Produkt anlegen</a></div>
<table class="table">
<thead><tr><th>Bild</th><th>Produkt</th><th>Hersteller</th><th>Größe</th><th>Händler</th><th>Bestand</th><th>Min</th></tr></thead>
<tbody>
{{range .Products}}
<tr>
<td class="tinyimg">{{if .ImagePath}}<img src="{{.ImagePath}}" alt="{{.Name}}"/>{{end}}</td>
<td><a href="/products/{{.ID}}">{{.Name}}</a></td>
<td>{{.Manufacturer}}</td>
<td>{{.Size}}</td>
<td>{{.PreferredVendor}}</td>
<td>{{.CurrentStock}}</td>
<td>{{.MinStock}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,26 @@
{{define "title"}}Einkaufsliste Kücheninventar{{end}}
{{define "content"}}
<h2>Einkaufsliste nach Händler</h2>
{{if .Vendors}}
{{range .Vendors}}
<section class="card">
<h3>{{.}}</h3>
<table class="table">
<thead><tr><th>Produkt</th><th>Hersteller</th><th>Größe</th><th>Benötigt</th></tr></thead>
<tbody>
{{range (index $.Groups .)}}
<tr>
<td><a href="/products/{{.Product.ID}}">{{.Product.Name}}</a></td>
<td>{{.Product.Manufacturer}}</td>
<td>{{.Product.Size}}</td>
<td><strong>{{.Needed}}</strong></td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{else}}
<p class="muted">Keine offenen Bedarfe alle Mindestbestände sind erfüllt.</p>
{{end}}
{{end}}