läuft
This commit is contained in:
29
assets/style.css
Normal file
29
assets/style.css
Normal 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
18
go.mod
Normal 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
23
go.sum
Normal 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=
|
||||||
600
main.go
Normal file
600
main.go
Normal 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
22
templates/alerts.gohtml
Normal 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
29
templates/base.gohtml
Normal 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}}
|
||||||
39
templates/dashboard.gohtml
Normal file
39
templates/dashboard.gohtml
Normal 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}}
|
||||||
3
templates/partials.gohtml
Normal file
3
templates/partials.gohtml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{define "flash"}}
|
||||||
|
{{/* Platzhalter für Nachrichten */}}
|
||||||
|
{{end}}
|
||||||
18
templates/product_create.gohtml
Normal file
18
templates/product_create.gohtml
Normal 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}}
|
||||||
79
templates/product_detail.gohtml
Normal file
79
templates/product_detail.gohtml
Normal 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
20
templates/products.gohtml
Normal 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}}
|
||||||
26
templates/shopping_list.gohtml
Normal file
26
templates/shopping_list.gohtml
Normal 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}}
|
||||||
Reference in New Issue
Block a user