v0.1.0 — Phase 1: Go Backend + SQLite + Seed Data
- Wails project setup (Go + React-TS) - SQLite schema (allergens, additives, products, week_plans, plan_entries, special_days) - 14 EU allergens (LMIV 1169/2011) - 24 German food additives - 99 products imported from Excel with allergen/additive mappings - Full Wails bindings (CRUD for products, week plans, entries, special days) - OTA updater stub (version check against HTTPS endpoint) - Pure Go SQLite (no CGO) for easy Windows cross-compilation
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Build
|
||||
build/
|
||||
speiseplan
|
||||
speiseplan.exe
|
||||
speiseplan.db
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.test
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Wails generated
|
||||
frontend/wailsjs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
19
README.md
Normal file
19
README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# README
|
||||
|
||||
## About
|
||||
|
||||
This is the official Wails React-TS template.
|
||||
|
||||
You can configure the project by editing `wails.json`. More information about the project settings can be found
|
||||
here: https://wails.io/docs/reference/project-config
|
||||
|
||||
## Live Development
|
||||
|
||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
||||
to this in your browser, and you can call your Go code from devtools.
|
||||
|
||||
## Building
|
||||
|
||||
To build a redistributable, production mode package, use `wails build`.
|
||||
466
app.go
Normal file
466
app.go
Normal file
@@ -0,0 +1,466 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
updater *Updater
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
func NewApp() *App {
|
||||
return &App{
|
||||
updater: NewUpdater(),
|
||||
}
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
// Greet returns a greeting for the given name
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hello %s, It's show time!", name)
|
||||
}
|
||||
|
||||
// PRODUKTE
|
||||
|
||||
// GetProducts gibt alle Produkte zurück
|
||||
func (a *App) GetProducts() ([]Product, error) {
|
||||
query := `
|
||||
SELECT p.id, p.name, p.multiline
|
||||
FROM products p
|
||||
ORDER BY p.name
|
||||
`
|
||||
|
||||
var products []Product
|
||||
err := db.Select(&products, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get products: %w", err)
|
||||
}
|
||||
|
||||
// Allergene und Zusatzstoffe für jedes Produkt laden
|
||||
for i := range products {
|
||||
if err := loadProductRelations(&products[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return products, nil
|
||||
}
|
||||
|
||||
// GetProduct gibt ein einzelnes Produkt zurück
|
||||
func (a *App) GetProduct(id int) (*Product, error) {
|
||||
var product Product
|
||||
query := "SELECT id, name, multiline FROM products WHERE id = ?"
|
||||
err := db.Get(&product, query, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get product: %w", err)
|
||||
}
|
||||
|
||||
if err := loadProductRelations(&product); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
// CreateProduct erstellt ein neues Produkt
|
||||
func (a *App) CreateProduct(name string, multiline bool, allergenIDs []string, additiveIDs []string) (*Product, error) {
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Produkt einfügen
|
||||
result, err := tx.Exec("INSERT INTO products (name, multiline) VALUES (?, ?)", name, multiline)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to insert product: %w", err)
|
||||
}
|
||||
|
||||
productID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get product ID: %w", err)
|
||||
}
|
||||
|
||||
// Allergene zuordnen
|
||||
for _, allergenID := range allergenIDs {
|
||||
_, err := tx.Exec("INSERT INTO product_allergens (product_id, allergen_id) VALUES (?, ?)", productID, allergenID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to link allergen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Zusatzstoffe zuordnen
|
||||
for _, additiveID := range additiveIDs {
|
||||
_, err := tx.Exec("INSERT INTO product_additives (product_id, additive_id) VALUES (?, ?)", productID, additiveID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to link additive: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return a.GetProduct(int(productID))
|
||||
}
|
||||
|
||||
// UpdateProduct aktualisiert ein Produkt
|
||||
func (a *App) UpdateProduct(id int, name string, multiline bool, allergenIDs []string, additiveIDs []string) (*Product, error) {
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Produkt aktualisieren
|
||||
_, err = tx.Exec("UPDATE products SET name = ?, multiline = ? WHERE id = ?", name, multiline, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update product: %w", err)
|
||||
}
|
||||
|
||||
// Alte Zuordnungen löschen
|
||||
_, err = tx.Exec("DELETE FROM product_allergens WHERE product_id = ?", id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete old allergen links: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec("DELETE FROM product_additives WHERE product_id = ?", id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete old additive links: %w", err)
|
||||
}
|
||||
|
||||
// Neue Allergene zuordnen
|
||||
for _, allergenID := range allergenIDs {
|
||||
_, err := tx.Exec("INSERT INTO product_allergens (product_id, allergen_id) VALUES (?, ?)", id, allergenID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to link allergen: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Neue Zusatzstoffe zuordnen
|
||||
for _, additiveID := range additiveIDs {
|
||||
_, err := tx.Exec("INSERT INTO product_additives (product_id, additive_id) VALUES (?, ?)", id, additiveID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to link additive: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return a.GetProduct(id)
|
||||
}
|
||||
|
||||
// DeleteProduct löscht ein Produkt
|
||||
func (a *App) DeleteProduct(id int) error {
|
||||
_, err := db.Exec("DELETE FROM products WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete product: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ALLERGENE & ZUSATZSTOFFE
|
||||
|
||||
// GetAllergens gibt alle Allergene zurück
|
||||
func (a *App) GetAllergens() ([]Allergen, error) {
|
||||
var allergens []Allergen
|
||||
err := db.Select(&allergens, "SELECT id, name, category FROM allergens ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get allergens: %w", err)
|
||||
}
|
||||
return allergens, nil
|
||||
}
|
||||
|
||||
// GetAdditives gibt alle Zusatzstoffe zurück
|
||||
func (a *App) GetAdditives() ([]Additive, error) {
|
||||
var additives []Additive
|
||||
err := db.Select(&additives, "SELECT id, name FROM additives ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get additives: %w", err)
|
||||
}
|
||||
return additives, nil
|
||||
}
|
||||
|
||||
// WOCHENPLÄNE
|
||||
|
||||
// GetWeekPlan gibt einen Wochenplan zurück
|
||||
func (a *App) GetWeekPlan(year int, week int) (*WeekPlan, error) {
|
||||
var plan WeekPlan
|
||||
query := "SELECT id, year, week, created_at FROM week_plans WHERE year = ? AND week = ?"
|
||||
err := db.Get(&plan, query, year, week)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get week plan: %w", err)
|
||||
}
|
||||
|
||||
// Einträge laden
|
||||
entries, err := a.loadPlanEntries(plan.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.Entries = entries
|
||||
|
||||
// Sondertage laden
|
||||
specialDays, err := a.loadSpecialDays(plan.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.SpecialDays = specialDays
|
||||
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// CreateWeekPlan erstellt einen neuen Wochenplan
|
||||
func (a *App) CreateWeekPlan(year int, week int) (*WeekPlan, error) {
|
||||
_, err := db.Exec("INSERT INTO week_plans (year, week) VALUES (?, ?)", year, week)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create week plan: %w", err)
|
||||
}
|
||||
|
||||
return a.GetWeekPlan(year, week)
|
||||
}
|
||||
|
||||
// CopyWeekPlan kopiert einen Wochenplan
|
||||
func (a *App) CopyWeekPlan(sourceYear int, sourceWeek int, targetYear int, targetWeek int) (*WeekPlan, error) {
|
||||
// Erst Zielplan erstellen
|
||||
targetPlan, err := a.CreateWeekPlan(targetYear, targetWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Quellplan laden
|
||||
sourcePlan, err := a.GetWeekPlan(sourceYear, sourceWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Einträge kopieren
|
||||
for _, entry := range sourcePlan.Entries {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO plan_entries (week_plan_id, day, meal, slot, product_id, custom_text, group_label)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, targetPlan.ID, entry.Day, entry.Meal, entry.Slot, entry.ProductID, entry.CustomText, entry.GroupLabel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to copy plan entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sondertage kopieren
|
||||
for _, special := range sourcePlan.SpecialDays {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO special_days (week_plan_id, day, type, label)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, targetPlan.ID, special.Day, special.Type, special.Label)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to copy special day: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return a.GetWeekPlan(targetYear, targetWeek)
|
||||
}
|
||||
|
||||
// PLAN-EINTRÄGE
|
||||
|
||||
// AddPlanEntry fügt einen Planeintrag hinzu
|
||||
func (a *App) AddPlanEntry(weekPlanID int, day int, meal string, productID *int, customText *string, groupLabel *string) (*PlanEntry, error) {
|
||||
// Nächste Slot-Nummer ermitteln
|
||||
var maxSlot int
|
||||
err := db.Get(&maxSlot, "SELECT COALESCE(MAX(slot), -1) FROM plan_entries WHERE week_plan_id = ? AND day = ? AND meal = ?", weekPlanID, day, meal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get max slot: %w", err)
|
||||
}
|
||||
|
||||
slot := maxSlot + 1
|
||||
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO plan_entries (week_plan_id, day, meal, slot, product_id, custom_text, group_label)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, weekPlanID, day, meal, slot, productID, customText, groupLabel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add plan entry: %w", err)
|
||||
}
|
||||
|
||||
entryID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get entry ID: %w", err)
|
||||
}
|
||||
|
||||
return a.getPlanEntry(int(entryID))
|
||||
}
|
||||
|
||||
// RemovePlanEntry entfernt einen Planeintrag
|
||||
func (a *App) RemovePlanEntry(id int) error {
|
||||
_, err := db.Exec("DELETE FROM plan_entries WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove plan entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePlanEntry aktualisiert einen Planeintrag
|
||||
func (a *App) UpdatePlanEntry(id int, productID *int, customText *string, groupLabel *string) (*PlanEntry, error) {
|
||||
_, err := db.Exec(`
|
||||
UPDATE plan_entries
|
||||
SET product_id = ?, custom_text = ?, group_label = ?
|
||||
WHERE id = ?
|
||||
`, productID, customText, groupLabel, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update plan entry: %w", err)
|
||||
}
|
||||
|
||||
return a.getPlanEntry(id)
|
||||
}
|
||||
|
||||
// SONDERTAGE
|
||||
|
||||
// SetSpecialDay setzt einen Sondertag
|
||||
func (a *App) SetSpecialDay(weekPlanID int, day int, dtype string, label string) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT OR REPLACE INTO special_days (week_plan_id, day, type, label)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, weekPlanID, day, dtype, label)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set special day: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSpecialDay entfernt einen Sondertag
|
||||
func (a *App) RemoveSpecialDay(weekPlanID int, day int) error {
|
||||
_, err := db.Exec("DELETE FROM special_days WHERE week_plan_id = ? AND day = ?", weekPlanID, day)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove special day: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OTA UPDATE
|
||||
|
||||
// CheckForUpdate prüft auf verfügbare Updates
|
||||
func (a *App) CheckForUpdate() (*UpdateInfo, error) {
|
||||
return a.updater.CheckForUpdate()
|
||||
}
|
||||
|
||||
// HILFSFUNKTIONEN
|
||||
|
||||
// loadProductRelations lädt Allergene und Zusatzstoffe für ein Produkt
|
||||
func loadProductRelations(product *Product) error {
|
||||
// Allergene laden
|
||||
allergenQuery := `
|
||||
SELECT a.id, a.name, a.category
|
||||
FROM allergens a
|
||||
JOIN product_allergens pa ON a.id = pa.allergen_id
|
||||
WHERE pa.product_id = ?
|
||||
ORDER BY a.id
|
||||
`
|
||||
err := db.Select(&product.Allergens, allergenQuery, product.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load allergens for product %d: %w", product.ID, err)
|
||||
}
|
||||
|
||||
// Zusatzstoffe laden
|
||||
additiveQuery := `
|
||||
SELECT a.id, a.name
|
||||
FROM additives a
|
||||
JOIN product_additives pa ON a.id = pa.additive_id
|
||||
WHERE pa.product_id = ?
|
||||
ORDER BY a.id
|
||||
`
|
||||
err = db.Select(&product.Additives, additiveQuery, product.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load additives for product %d: %w", product.ID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPlanEntries lädt Einträge für einen Wochenplan
|
||||
func (a *App) loadPlanEntries(weekPlanID int) ([]PlanEntry, error) {
|
||||
query := `
|
||||
SELECT pe.id, pe.week_plan_id, pe.day, pe.meal, pe.slot,
|
||||
pe.product_id, pe.custom_text, pe.group_label
|
||||
FROM plan_entries pe
|
||||
WHERE pe.week_plan_id = ?
|
||||
ORDER BY pe.day, pe.meal, pe.slot
|
||||
`
|
||||
|
||||
var entries []PlanEntry
|
||||
err := db.Select(&entries, query, weekPlanID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load plan entries: %w", err)
|
||||
}
|
||||
|
||||
// Produkte laden
|
||||
for i := range entries {
|
||||
if entries[i].ProductID != nil {
|
||||
product, err := a.GetProduct(*entries[i].ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries[i].Product = product
|
||||
}
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// loadSpecialDays lädt Sondertage für einen Wochenplan
|
||||
func (a *App) loadSpecialDays(weekPlanID int) ([]SpecialDay, error) {
|
||||
var specialDays []SpecialDay
|
||||
query := `
|
||||
SELECT id, week_plan_id, day, type, label
|
||||
FROM special_days
|
||||
WHERE week_plan_id = ?
|
||||
ORDER BY day
|
||||
`
|
||||
err := db.Select(&specialDays, query, weekPlanID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load special days: %w", err)
|
||||
}
|
||||
return specialDays, nil
|
||||
}
|
||||
|
||||
// getPlanEntry lädt einen einzelnen Planeintrag
|
||||
func (a *App) getPlanEntry(id int) (*PlanEntry, error) {
|
||||
var entry PlanEntry
|
||||
query := `
|
||||
SELECT id, week_plan_id, day, meal, slot, product_id, custom_text, group_label
|
||||
FROM plan_entries
|
||||
WHERE id = ?
|
||||
`
|
||||
err := db.Get(&entry, query, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get plan entry: %w", err)
|
||||
}
|
||||
|
||||
// Produkt laden wenn vorhanden
|
||||
if entry.ProductID != nil {
|
||||
product, err := a.GetProduct(*entry.ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.Product = product
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
127
db.go
Normal file
127
db.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var db *sqlx.DB
|
||||
|
||||
// InitDatabase initialisiert die SQLite-Datenbank
|
||||
func InitDatabase() error {
|
||||
// DB-Datei im User-Home-Verzeichnis
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(homeDir, "speiseplan.db")
|
||||
|
||||
// Verbindung zur Datenbank herstellen
|
||||
database, err := sqlx.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
db = database
|
||||
|
||||
// Schema erstellen
|
||||
if err := createSchema(); err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
// Seed-Daten einfügen
|
||||
if err := SeedDatabase(); err != nil {
|
||||
return fmt.Errorf("failed to seed database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSchema erstellt das Datenbankschema
|
||||
func createSchema() error {
|
||||
schema := `
|
||||
-- 14 EU-Allergene (gesetzlich vorgeschrieben, LMIV Verordnung)
|
||||
CREATE TABLE IF NOT EXISTS allergens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'allergen'
|
||||
);
|
||||
|
||||
-- Zusatzstoffe (deutsche Lebensmittelkennzeichnung)
|
||||
CREATE TABLE IF NOT EXISTS additives (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Produkte
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
multiline BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Produkt-Allergen-Zuordnung (n:m)
|
||||
CREATE TABLE IF NOT EXISTS product_allergens (
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
|
||||
allergen_id TEXT REFERENCES allergens(id),
|
||||
PRIMARY KEY (product_id, allergen_id)
|
||||
);
|
||||
|
||||
-- Produkt-Zusatzstoff-Zuordnung (n:m)
|
||||
CREATE TABLE IF NOT EXISTS product_additives (
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
|
||||
additive_id TEXT REFERENCES additives(id),
|
||||
PRIMARY KEY (product_id, additive_id)
|
||||
);
|
||||
|
||||
-- Wochenpläne
|
||||
CREATE TABLE IF NOT EXISTS week_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
year INTEGER NOT NULL,
|
||||
week INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(year, week)
|
||||
);
|
||||
|
||||
-- Einträge im Wochenplan
|
||||
CREATE TABLE IF NOT EXISTS plan_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
week_plan_id INTEGER REFERENCES week_plans(id) ON DELETE CASCADE,
|
||||
day INTEGER NOT NULL,
|
||||
meal TEXT NOT NULL,
|
||||
slot INTEGER NOT NULL DEFAULT 0,
|
||||
product_id INTEGER REFERENCES products(id),
|
||||
custom_text TEXT,
|
||||
group_label TEXT
|
||||
);
|
||||
|
||||
-- Sondertage
|
||||
CREATE TABLE IF NOT EXISTS special_days (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
week_plan_id INTEGER REFERENCES week_plans(id) ON DELETE CASCADE,
|
||||
day INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
label TEXT
|
||||
);`
|
||||
|
||||
_, err := db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDB gibt die Datenbankverbindung zurück
|
||||
func GetDB() *sqlx.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// CloseDatabase schließt die Datenbankverbindung
|
||||
func CloseDatabase() error {
|
||||
if db != nil {
|
||||
return db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>speiseplan</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1566
frontend/package-lock.json
generated
Normal file
1566
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwindcss": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^2.0.1",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
59
frontend/src/App.css
Normal file
59
frontend/src/App.css
Normal file
@@ -0,0 +1,59 @@
|
||||
#app {
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#logo {
|
||||
display: block;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
margin: auto;
|
||||
padding: 10% 0 0;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
background-origin: content-box;
|
||||
}
|
||||
|
||||
.result {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
.input-box .btn {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
margin: 0 0 0 20px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-box .btn:hover {
|
||||
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.input-box .input {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
padding: 0 10px;
|
||||
background-color: rgba(240, 240, 240, 1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.input-box .input:hover {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.input-box .input:focus {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
28
frontend/src/App.tsx
Normal file
28
frontend/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import {useState} from 'react';
|
||||
import logo from './assets/images/logo-universal.png';
|
||||
import './App.css';
|
||||
import {Greet} from "../wailsjs/go/main/App";
|
||||
|
||||
function App() {
|
||||
const [resultText, setResultText] = useState("Please enter your name below 👇");
|
||||
const [name, setName] = useState('');
|
||||
const updateName = (e: any) => setName(e.target.value);
|
||||
const updateResultText = (result: string) => setResultText(result);
|
||||
|
||||
function greet() {
|
||||
Greet(name).then(updateResultText);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="App">
|
||||
<img src={logo} id="logo" alt="logo"/>
|
||||
<div id="result" className="result">{resultText}</div>
|
||||
<div id="input" className="input-box">
|
||||
<input id="name" className="input" onChange={updateName} autoComplete="off" name="input" type="text"/>
|
||||
<button className="btn" onClick={greet}>Greet</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
93
frontend/src/assets/fonts/OFL.txt
Normal file
93
frontend/src/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/images/logo-universal.png
Normal file
BIN
frontend/src/assets/images/logo-universal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
129
frontend/src/components/AdditivePicker.tsx
Normal file
129
frontend/src/components/AdditivePicker.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Additive } from '../types';
|
||||
|
||||
interface AdditivePickerProps {
|
||||
additives: Additive[];
|
||||
selectedIds: string[];
|
||||
onChange: (selectedIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Deutsche Namen für häufige Zusatzstoffe (E-Nummern)
|
||||
const ADDITIVE_NAMES: Record<string, string> = {
|
||||
'1': 'Farbstoff',
|
||||
'2': 'Konservierungsstoff',
|
||||
'3': 'Antioxidationsmittel',
|
||||
'4': 'Geschmacksverstärker',
|
||||
'5': 'Geschwefelt',
|
||||
'6': 'Geschwärzt',
|
||||
'7': 'Gewachst',
|
||||
'8': 'Phosphat',
|
||||
'9': 'Süßungsmittel',
|
||||
'10': 'Phenylalaninquelle',
|
||||
'11': 'Koffeinhaltig',
|
||||
'12': 'Chininhaltig',
|
||||
'13': 'Alkoholhaltig',
|
||||
'14': 'Nitritpökelsalz',
|
||||
'15': 'Milchsäure',
|
||||
'16': 'Citronensäure',
|
||||
'17': 'Ascorbinsäure',
|
||||
'18': 'Tocopherol',
|
||||
'19': 'Lecithin',
|
||||
'20': 'Johannisbrotkernmehl',
|
||||
'21': 'Guarkernmehl',
|
||||
'22': 'Xanthan',
|
||||
'23': 'Carrageen',
|
||||
'24': 'Agar'
|
||||
};
|
||||
|
||||
export function AdditivePicker({ additives, selectedIds, onChange, className = '' }: AdditivePickerProps) {
|
||||
const handleToggle = (additiveId: string) => {
|
||||
if (selectedIds.includes(additiveId)) {
|
||||
onChange(selectedIds.filter(id => id !== additiveId));
|
||||
} else {
|
||||
onChange([...selectedIds, additiveId]);
|
||||
}
|
||||
};
|
||||
|
||||
// Sortierte Zusatzstoffe
|
||||
const sortedAdditives = [...additives].sort((a, b) => {
|
||||
// Numerisch sortieren falls möglich, sonst alphabetisch
|
||||
const aNum = parseInt(a.id);
|
||||
const bNum = parseInt(b.id);
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return (
|
||||
<fieldset className={`border border-gray-300 rounded-md p-4 ${className}`}>
|
||||
<legend className="text-sm font-medium text-gray-900 px-2">
|
||||
Zusatzstoffe auswählen
|
||||
</legend>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-3 max-h-60 overflow-y-auto"
|
||||
role="group"
|
||||
aria-labelledby="additive-picker-legend"
|
||||
>
|
||||
{sortedAdditives.map(additive => {
|
||||
const isSelected = selectedIds.includes(additive.id);
|
||||
const displayName = ADDITIVE_NAMES[additive.id] || additive.name;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={additive.id}
|
||||
className={`flex items-center space-x-2 p-2 rounded-md border cursor-pointer transition-colors min-h-[44px] ${
|
||||
isSelected
|
||||
? 'bg-orange-100 text-orange-900 border-orange-300'
|
||||
: 'bg-white hover:bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggle(additive.id)}
|
||||
className="w-4 h-4 text-orange-600 bg-gray-100 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
|
||||
aria-describedby={`additive-${additive.id}-description`}
|
||||
/>
|
||||
|
||||
<span className="flex-1 text-sm">
|
||||
<span className="font-medium">{additive.id}</span>
|
||||
{displayName && (
|
||||
<span className="text-gray-600 ml-1">
|
||||
- {displayName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
id={`additive-${additive.id}-description`}
|
||||
className="sr-only"
|
||||
>
|
||||
Zusatzstoff {additive.id}: {displayName}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-orange-50 rounded-md border border-orange-200">
|
||||
<p className="text-sm text-orange-800">
|
||||
<strong>Ausgewählte Zusatzstoffe:</strong>{' '}
|
||||
<span className="font-mono">
|
||||
{selectedIds.sort((a, b) => {
|
||||
const aNum = parseInt(a);
|
||||
const bNum = parseInt(b);
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
}).join(', ')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/AllergenBadge.tsx
Normal file
79
frontend/src/components/AllergenBadge.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Allergen } from '../types';
|
||||
|
||||
interface AllergenBadgeProps {
|
||||
allergen: Allergen;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Farbkodierung für Allergene (a-n)
|
||||
const ALLERGEN_COLORS: Record<string, string> = {
|
||||
'a': 'bg-red-500', // Glutenhaltige Getreide
|
||||
'b': 'bg-orange-500', // Krebstiere
|
||||
'c': 'bg-yellow-500', // Eier
|
||||
'd': 'bg-green-500', // Fisch
|
||||
'e': 'bg-blue-500', // Erdnüsse
|
||||
'f': 'bg-indigo-500', // Soja
|
||||
'g': 'bg-purple-500', // Milch/Laktose
|
||||
'h': 'bg-pink-500', // Schalenfrüchte
|
||||
'i': 'bg-red-400', // Sellerie
|
||||
'j': 'bg-orange-400', // Senf
|
||||
'k': 'bg-yellow-400', // Sesam
|
||||
'l': 'bg-green-400', // Schwefeldioxid
|
||||
'm': 'bg-blue-400', // Lupinen
|
||||
'n': 'bg-indigo-400', // Weichtiere
|
||||
};
|
||||
|
||||
export function AllergenBadge({ allergen, size = 'md', className = '' }: AllergenBadgeProps) {
|
||||
const colorClass = ALLERGEN_COLORS[allergen.id] || 'bg-gray-500';
|
||||
const sizeClass = size === 'sm'
|
||||
? 'text-xs px-1.5 py-0.5'
|
||||
: 'text-xs px-2 py-1';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center font-medium text-white rounded ${colorClass} ${sizeClass} ${className}`}
|
||||
title={`${allergen.id}: ${allergen.name}`}
|
||||
aria-label={`Allergen ${allergen.id}: ${allergen.name}`}
|
||||
>
|
||||
{allergen.id}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface AllergenListProps {
|
||||
allergens: Allergen[];
|
||||
size?: 'sm' | 'md';
|
||||
maxVisible?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AllergenList({ allergens, size = 'md', maxVisible, className = '' }: AllergenListProps) {
|
||||
if (!allergens?.length) return null;
|
||||
|
||||
const visible = maxVisible ? allergens.slice(0, maxVisible) : allergens;
|
||||
const remaining = maxVisible && allergens.length > maxVisible
|
||||
? allergens.length - maxVisible
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1 ${className}`}>
|
||||
{visible.map(allergen => (
|
||||
<AllergenBadge
|
||||
key={allergen.id}
|
||||
allergen={allergen}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span
|
||||
className={`inline-flex items-center font-medium text-gray-600 bg-gray-200 rounded ${size === 'sm' ? 'text-xs px-1.5 py-0.5' : 'text-xs px-2 py-1'}`}
|
||||
title={`${remaining} weitere Allergene`}
|
||||
aria-label={`${remaining} weitere Allergene`}
|
||||
>
|
||||
+{remaining}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/AllergenPicker.tsx
Normal file
106
frontend/src/components/AllergenPicker.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Allergen } from '../types';
|
||||
|
||||
interface AllergenPickerProps {
|
||||
allergens: Allergen[];
|
||||
selectedIds: string[];
|
||||
onChange: (selectedIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Deutsche Namen für Allergene (nach EU-Verordnung)
|
||||
const ALLERGEN_NAMES: Record<string, string> = {
|
||||
'a': 'Glutenhaltige Getreide',
|
||||
'b': 'Krebstiere',
|
||||
'c': 'Eier',
|
||||
'd': 'Fisch',
|
||||
'e': 'Erdnüsse',
|
||||
'f': 'Soja',
|
||||
'g': 'Milch/Laktose',
|
||||
'h': 'Schalenfrüchte',
|
||||
'i': 'Sellerie',
|
||||
'j': 'Senf',
|
||||
'k': 'Sesam',
|
||||
'l': 'Schwefeldioxid',
|
||||
'm': 'Lupinen',
|
||||
'n': 'Weichtiere'
|
||||
};
|
||||
|
||||
export function AllergenPicker({ allergens, selectedIds, onChange, className = '' }: AllergenPickerProps) {
|
||||
const handleToggle = (allergenId: string) => {
|
||||
if (selectedIds.includes(allergenId)) {
|
||||
onChange(selectedIds.filter(id => id !== allergenId));
|
||||
} else {
|
||||
onChange([...selectedIds, allergenId]);
|
||||
}
|
||||
};
|
||||
|
||||
// Sortierte Allergene (a-n)
|
||||
const sortedAllergens = [...allergens].sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return (
|
||||
<fieldset className={`border border-gray-300 rounded-md p-4 ${className}`}>
|
||||
<legend className="text-sm font-medium text-gray-900 px-2">
|
||||
Allergene auswählen
|
||||
</legend>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 mt-3"
|
||||
role="group"
|
||||
aria-labelledby="allergen-picker-legend"
|
||||
>
|
||||
{sortedAllergens.map(allergen => {
|
||||
const isSelected = selectedIds.includes(allergen.id);
|
||||
const displayName = ALLERGEN_NAMES[allergen.id] || allergen.name;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={allergen.id}
|
||||
className={`flex items-center space-x-3 p-3 rounded-md border cursor-pointer transition-colors min-h-[44px] ${
|
||||
isSelected
|
||||
? 'bg-primary text-white border-primary'
|
||||
: 'bg-white hover:bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggle(allergen.id)}
|
||||
className="hidden"
|
||||
aria-describedby={`allergen-${allergen.id}-description`}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={`flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs font-bold rounded ${
|
||||
isSelected ? 'bg-white text-primary' : 'bg-danger text-white'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{allergen.id}
|
||||
</span>
|
||||
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
<span
|
||||
id={`allergen-${allergen.id}-description`}
|
||||
className="sr-only"
|
||||
>
|
||||
Allergen {allergen.id}: {displayName}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Ausgewählte Allergene:</strong>{' '}
|
||||
{selectedIds.sort().join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
244
frontend/src/components/ProductForm.tsx
Normal file
244
frontend/src/components/ProductForm.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Product, ProductFormData, Allergen, Additive } from '../types';
|
||||
import { AllergenPicker } from './AllergenPicker';
|
||||
import { AdditivePicker } from './AdditivePicker';
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product; // Für Bearbeitung
|
||||
allergens: Allergen[];
|
||||
additives: Additive[];
|
||||
onSubmit: (data: ProductFormData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ProductForm({
|
||||
product,
|
||||
allergens,
|
||||
additives,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false
|
||||
}: ProductFormProps) {
|
||||
const [formData, setFormData] = useState<ProductFormData>({
|
||||
name: '',
|
||||
multiline: false,
|
||||
allergenIds: [],
|
||||
additiveIds: []
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Initialize form with product data for editing
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormData({
|
||||
name: product.name,
|
||||
multiline: product.multiline,
|
||||
allergenIds: product.allergens?.map(a => a.id) || [],
|
||||
additiveIds: product.additives?.map(a => a.id) || []
|
||||
});
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// Validation
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Produktname ist erforderlich';
|
||||
} else if (formData.name.length > 100) {
|
||||
newErrors.name = 'Produktname darf maximal 100 Zeichen lang sein';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} catch (error) {
|
||||
// Error handling is done in parent component
|
||||
console.error('Fehler beim Speichern des Produkts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
multiline: false,
|
||||
allergenIds: [],
|
||||
additiveIds: []
|
||||
});
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const isEditing = !!product;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEditing ? 'Produkt bearbeiten' : 'Neues Produkt'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
aria-label="Dialog schließen"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Product Name */}
|
||||
<div>
|
||||
<label htmlFor="product-name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Produktname *
|
||||
</label>
|
||||
<input
|
||||
id="product-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className={`input ${errors.name ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||
placeholder="z.B. Vollkornbrot mit Käse"
|
||||
maxLength={100}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
required
|
||||
/>
|
||||
{errors.name && (
|
||||
<p id="name-error" className="mt-1 text-sm text-red-600" role="alert">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{formData.name.length}/100 Zeichen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Multiline Option */}
|
||||
<div>
|
||||
<label className="flex items-center space-x-3 cursor-pointer min-h-[44px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.multiline}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, multiline: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary bg-gray-100 border-gray-300 rounded focus:ring-primary focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Mehrzeiliges Produkt
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-gray-500 ml-7">
|
||||
Aktivieren, wenn das Produkt in mehreren Zeilen angezeigt werden soll
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Allergens */}
|
||||
<AllergenPicker
|
||||
allergens={allergens}
|
||||
selectedIds={formData.allergenIds}
|
||||
onChange={(ids) => setFormData(prev => ({ ...prev, allergenIds: ids }))}
|
||||
/>
|
||||
|
||||
{/* Additives */}
|
||||
<AdditivePicker
|
||||
additives={additives}
|
||||
selectedIds={formData.additiveIds}
|
||||
onChange={(ids) => setFormData(prev => ({ ...prev, additiveIds: ids }))}
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
{formData.name.trim() && (
|
||||
<div className="bg-gray-50 p-4 rounded-md border">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Vorschau</h4>
|
||||
<div className="bg-white p-3 rounded border">
|
||||
<div className="font-medium">{formData.name}</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{formData.allergenIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{formData.allergenIds.sort().map(id => (
|
||||
<span key={id} className="allergen-badge bg-danger">
|
||||
{id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{formData.additiveIds.length > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
Zusatzstoffe: {formData.additiveIds.sort((a, b) => {
|
||||
const aNum = parseInt(a);
|
||||
const bNum = parseInt(b);
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
|
||||
return a.localeCompare(b);
|
||||
}).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Speichern...
|
||||
</span>
|
||||
) : (
|
||||
isEditing ? 'Änderungen speichern' : 'Produkt erstellen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
frontend/src/components/ProductList.tsx
Normal file
293
frontend/src/components/ProductList.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useState } from 'react';
|
||||
import { Product, Allergen, Additive } from '../types';
|
||||
import { AllergenList } from './AllergenBadge';
|
||||
import { ProductForm } from './ProductForm';
|
||||
|
||||
interface ProductListProps {
|
||||
products: Product[];
|
||||
allergens: Allergen[];
|
||||
additives: Additive[];
|
||||
onCreateProduct: (data: any) => Promise<void>;
|
||||
onUpdateProduct: (id: number, data: any) => Promise<void>;
|
||||
onDeleteProduct: (id: number) => Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ProductList({
|
||||
products,
|
||||
allergens,
|
||||
additives,
|
||||
onCreateProduct,
|
||||
onUpdateProduct,
|
||||
onDeleteProduct,
|
||||
loading = false
|
||||
}: ProductListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [deletingProductId, setDeletingProductId] = useState<number | null>(null);
|
||||
|
||||
// Filter products based on search
|
||||
const filteredProducts = products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
product.allergens?.some(a =>
|
||||
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
a.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) ||
|
||||
product.additives?.some(a =>
|
||||
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
a.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// Handle create product
|
||||
const handleCreateProduct = async (data: any) => {
|
||||
try {
|
||||
await onCreateProduct(data);
|
||||
setShowForm(false);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Produkts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle update product
|
||||
const handleUpdateProduct = async (data: any) => {
|
||||
if (!editingProduct) return;
|
||||
|
||||
try {
|
||||
await onUpdateProduct(editingProduct.id, data);
|
||||
setEditingProduct(null);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Bearbeiten des Produkts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete product with confirmation
|
||||
const handleDeleteProduct = async (product: Product) => {
|
||||
if (deletingProductId === product.id) {
|
||||
// Second click - confirm deletion
|
||||
try {
|
||||
await onDeleteProduct(product.id);
|
||||
setDeletingProductId(null);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Produkts:', error);
|
||||
setDeletingProductId(null);
|
||||
}
|
||||
} else {
|
||||
// First click - show confirmation
|
||||
setDeletingProductId(product.id);
|
||||
|
||||
// Auto-cancel after 3 seconds
|
||||
setTimeout(() => {
|
||||
setDeletingProductId(prev => prev === product.id ? null : prev);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProduct = (product: Product) => {
|
||||
setEditingProduct(product);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingProduct(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Produktverwaltung
|
||||
</h1>
|
||||
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neues Produkt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-md">
|
||||
<label htmlFor="product-search" className="sr-only">
|
||||
Produkte durchsuchen
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="product-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Produkte suchen..."
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchQuery && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{filteredProducts.length} von {products.length} Produkten
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Products Table */}
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
{products.length === 0 ? (
|
||||
<div>
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Keine Produkte</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Erstellen Sie Ihr erstes Produkt, um zu beginnen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Keine Suchergebnisse</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Versuchen Sie es mit anderen Suchbegriffen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Produkt
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Allergene
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Zusatzstoffe
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredProducts.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {product.id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<AllergenList
|
||||
allergens={product.allergens || []}
|
||||
size="sm"
|
||||
maxVisible={5}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{product.additives?.length > 0 ? (
|
||||
<span className="text-xs text-gray-600 font-mono">
|
||||
{product.additives.map(a => a.id).sort().join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
product.multiline
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{product.multiline ? 'Mehrzeilig' : 'Einzeilig'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-medium space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditProduct(product)}
|
||||
className="text-primary hover:text-blue-700 focus:outline-none focus:underline"
|
||||
aria-label={`${product.name} bearbeiten`}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product)}
|
||||
className={`focus:outline-none focus:underline ${
|
||||
deletingProductId === product.id
|
||||
? 'text-red-600 hover:text-red-800 font-medium'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
aria-label={
|
||||
deletingProductId === product.id
|
||||
? `${product.name} wirklich löschen`
|
||||
: `${product.name} löschen`
|
||||
}
|
||||
>
|
||||
{deletingProductId === product.id ? 'Bestätigen?' : 'Löschen'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-gray-900">{products.length}</div>
|
||||
<div className="text-sm text-gray-600">Produkte gesamt</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{products.filter(p => (p.allergens?.length || 0) > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Mit Allergenen</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{products.filter(p => (p.additives?.length || 0) > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Mit Zusatzstoffen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Form Modal */}
|
||||
{(showForm || editingProduct) && (
|
||||
<ProductForm
|
||||
product={editingProduct || undefined}
|
||||
allergens={allergens}
|
||||
additives={additives}
|
||||
onSubmit={editingProduct ? handleUpdateProduct : handleCreateProduct}
|
||||
onCancel={handleCancelEdit}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
frontend/src/components/ProductSearch.tsx
Normal file
243
frontend/src/components/ProductSearch.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Product } from '../types';
|
||||
import { AllergenList } from './AllergenBadge';
|
||||
|
||||
interface ProductSearchProps {
|
||||
products: Product[];
|
||||
onSelect: (product: Product) => void;
|
||||
onCustomText?: (text: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
allowCustom?: boolean;
|
||||
}
|
||||
|
||||
export function ProductSearch({
|
||||
products,
|
||||
onSelect,
|
||||
onCustomText,
|
||||
placeholder = 'Produkt suchen oder eingeben...',
|
||||
className = '',
|
||||
allowCustom = true
|
||||
}: ProductSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Filter products based on search query
|
||||
const filteredProducts = query.trim()
|
||||
? products.filter(product =>
|
||||
product.name.toLowerCase().includes(query.toLowerCase())
|
||||
).slice(0, 10) // Limit to 10 results for performance
|
||||
: [];
|
||||
|
||||
// Handle product selection
|
||||
const handleSelect = (product: Product) => {
|
||||
setQuery(product.name);
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
onSelect(product);
|
||||
};
|
||||
|
||||
// Handle custom text entry
|
||||
const handleCustomEntry = () => {
|
||||
if (query.trim() && allowCustom && onCustomText) {
|
||||
onCustomText(query.trim());
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < filteredProducts.length - (allowCustom && query.trim() ? 0 : 1)
|
||||
? prev + 1
|
||||
: prev
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > -1 ? prev - 1 : prev);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < filteredProducts.length) {
|
||||
handleSelect(filteredProducts[selectedIndex]);
|
||||
} else if (selectedIndex === filteredProducts.length && allowCustom && query.trim()) {
|
||||
handleCustomEntry();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && listRef.current) {
|
||||
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const showResults = isOpen && (filteredProducts.length > 0 || (allowCustom && query.trim()));
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} role="combobox" aria-expanded={isOpen}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Produktsuche"
|
||||
/>
|
||||
|
||||
{showResults && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
aria-label="Suchergebnisse"
|
||||
>
|
||||
{filteredProducts.map((product, index) => (
|
||||
<li
|
||||
key={product.id}
|
||||
className={`px-3 py-2 cursor-pointer border-b border-gray-100 last:border-b-0 ${
|
||||
index === selectedIndex ? 'bg-primary text-white' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => handleSelect(product)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{product.name}</span>
|
||||
{product.allergens?.length > 0 && (
|
||||
<AllergenList
|
||||
allergens={product.allergens}
|
||||
size="sm"
|
||||
maxVisible={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.additives?.length > 0 && (
|
||||
<div className="text-xs mt-1 opacity-75">
|
||||
Zusatzstoffe: {product.additives.map(a => a.id).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{allowCustom && query.trim() && (
|
||||
<li
|
||||
className={`px-3 py-2 cursor-pointer border-t border-gray-200 italic ${
|
||||
selectedIndex === filteredProducts.length
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === filteredProducts.length}
|
||||
onClick={handleCustomEntry}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">✏️</span>
|
||||
Als Freitext eingeben: "{query}"
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{filteredProducts.length === 0 && (!allowCustom || !query.trim()) && (
|
||||
<li className="px-3 py-2 text-gray-500 italic">
|
||||
Keine Produkte gefunden
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vereinfachte Version für reine Anzeige
|
||||
interface ProductDisplayProps {
|
||||
product: Product;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProductDisplay({ product, onRemove, className = '' }: ProductDisplayProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between p-2 bg-white border border-gray-200 rounded-md ${className}`}>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{product.name}</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{product.allergens?.length > 0 && (
|
||||
<AllergenList allergens={product.allergens} size="sm" maxVisible={5} />
|
||||
)}
|
||||
|
||||
{product.additives?.length > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Zusatzstoffe: {product.additives.map(a => a.id).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-red-600 focus:text-red-600"
|
||||
aria-label={`${product.name} entfernen`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
frontend/src/hooks/useProducts.ts
Normal file
234
frontend/src/hooks/useProducts.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Product, Allergen, Additive, ProductFormData } from '../types';
|
||||
|
||||
// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein)
|
||||
// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert
|
||||
import { GetProducts, GetProduct, CreateProduct, UpdateProduct, DeleteProduct, GetAllergens, GetAdditives } from '../../wailsjs/go/main/App';
|
||||
|
||||
export function useProducts() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [allergens, setAllergens] = useState<Allergen[]>([]);
|
||||
const [additives, setAdditives] = useState<Additive[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Alle Produkte laden
|
||||
const loadProducts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const productList = await GetProducts();
|
||||
setProducts(productList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Produkte');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stammdaten laden (Allergene und Zusatzstoffe)
|
||||
const loadMasterData = useCallback(async () => {
|
||||
try {
|
||||
const [allergenList, additiveList] = await Promise.all([
|
||||
GetAllergens(),
|
||||
GetAdditives()
|
||||
]);
|
||||
|
||||
setAllergens(allergenList);
|
||||
setAdditives(additiveList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Stammdaten');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Einzelnes Produkt laden
|
||||
const getProduct = async (id: number): Promise<Product | null> => {
|
||||
try {
|
||||
const product = await GetProduct(id);
|
||||
return product;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden des Produkts');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Neues Produkt erstellen
|
||||
const createProduct = async (data: ProductFormData): Promise<Product | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newProduct = await CreateProduct(
|
||||
data.name,
|
||||
data.multiline,
|
||||
data.allergenIds,
|
||||
data.additiveIds
|
||||
);
|
||||
|
||||
setProducts(prev => [...prev, newProduct]);
|
||||
return newProduct;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Produkts');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Produkt bearbeiten
|
||||
const updateProduct = async (id: number, data: ProductFormData): Promise<Product | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedProduct = await UpdateProduct(
|
||||
id,
|
||||
data.name,
|
||||
data.multiline,
|
||||
data.allergenIds,
|
||||
data.additiveIds
|
||||
);
|
||||
|
||||
setProducts(prev => prev.map(p => p.id === id ? updatedProduct : p));
|
||||
return updatedProduct;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Bearbeiten des Produkts');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Produkt löschen
|
||||
const deleteProduct = async (id: number): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await DeleteProduct(id);
|
||||
setProducts(prev => prev.filter(p => p.id !== id));
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Löschen des Produkts');
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Produkte durchsuchen
|
||||
const searchProducts = (query: string): Product[] => {
|
||||
if (!query.trim()) return products;
|
||||
|
||||
const searchTerm = query.toLowerCase();
|
||||
return products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchTerm) ||
|
||||
product.allergens.some(a => a.name.toLowerCase().includes(searchTerm)) ||
|
||||
product.additives.some(a => a.name.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
};
|
||||
|
||||
// Produkte nach Allergenen filtern
|
||||
const filterByAllergen = (allergenId: string): Product[] => {
|
||||
return products.filter(product =>
|
||||
product.allergens.some(a => a.id === allergenId)
|
||||
);
|
||||
};
|
||||
|
||||
// Produkte nach Zusatzstoffen filtern
|
||||
const filterByAdditive = (additiveId: string): Product[] => {
|
||||
return products.filter(product =>
|
||||
product.additives.some(a => a.id === additiveId)
|
||||
);
|
||||
};
|
||||
|
||||
// Allergen nach ID suchen
|
||||
const getAllergenById = (id: string): Allergen | undefined => {
|
||||
return allergens.find(a => a.id === id);
|
||||
};
|
||||
|
||||
// Zusatzstoff nach ID suchen
|
||||
const getAdditiveById = (id: string): Additive | undefined => {
|
||||
return additives.find(a => a.id === id);
|
||||
};
|
||||
|
||||
// Prüfen ob Produkt Allergene enthält
|
||||
const hasAllergens = (product: Product): boolean => {
|
||||
return product.allergens && product.allergens.length > 0;
|
||||
};
|
||||
|
||||
// Prüfen ob Produkt Zusatzstoffe enthält
|
||||
const hasAdditives = (product: Product): boolean => {
|
||||
return product.additives && product.additives.length > 0;
|
||||
};
|
||||
|
||||
// Initial laden
|
||||
useEffect(() => {
|
||||
loadProducts();
|
||||
loadMasterData();
|
||||
}, [loadProducts, loadMasterData]);
|
||||
|
||||
return {
|
||||
// State
|
||||
products,
|
||||
allergens,
|
||||
additives,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Aktionen
|
||||
loadProducts,
|
||||
loadMasterData,
|
||||
getProduct,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
|
||||
// Suche/Filter
|
||||
searchProducts,
|
||||
filterByAllergen,
|
||||
filterByAdditive,
|
||||
|
||||
// Helper
|
||||
getAllergenById,
|
||||
getAdditiveById,
|
||||
hasAllergens,
|
||||
hasAdditives,
|
||||
|
||||
// Clear error
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
}
|
||||
|
||||
// Separater Hook für Autocomplete/Search
|
||||
export function useProductSearch(initialQuery = '') {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||
const { products, searchProducts } = useProducts();
|
||||
|
||||
const results = searchProducts(query);
|
||||
const selectedProduct = selectedProductId
|
||||
? products.find(p => p.id === selectedProductId)
|
||||
: null;
|
||||
|
||||
const selectProduct = (product: Product) => {
|
||||
setSelectedProductId(product.id);
|
||||
setQuery(product.name);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedProductId(null);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
results,
|
||||
selectedProduct,
|
||||
selectProduct,
|
||||
clearSelection,
|
||||
hasSelection: selectedProductId !== null
|
||||
};
|
||||
}
|
||||
224
frontend/src/hooks/useWeekPlan.ts
Normal file
224
frontend/src/hooks/useWeekPlan.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { WeekPlan, PlanEntry, SpecialDay, MealType, WeekDay, GroupLabel } from '../types';
|
||||
|
||||
// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein)
|
||||
// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert
|
||||
import { GetWeekPlan, CreateWeekPlan, CopyWeekPlan, AddPlanEntry, RemovePlanEntry, UpdatePlanEntry, SetSpecialDay, RemoveSpecialDay } from '../../wailsjs/go/main/App';
|
||||
|
||||
export function useWeekPlan(year: number, week: number) {
|
||||
const [weekPlan, setWeekPlan] = useState<WeekPlan | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Wochenplan laden
|
||||
const loadWeekPlan = useCallback(async () => {
|
||||
if (!year || !week) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const plan = await GetWeekPlan(year, week);
|
||||
setWeekPlan(plan);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden des Wochenplans');
|
||||
setWeekPlan(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, week]);
|
||||
|
||||
// Neuen Wochenplan erstellen
|
||||
const createWeekPlan = async (): Promise<WeekPlan | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newPlan = await CreateWeekPlan(year, week);
|
||||
setWeekPlan(newPlan);
|
||||
return newPlan;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Wochenplans');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Wochenplan kopieren
|
||||
const copyWeekPlan = async (srcYear: number, srcWeek: number): Promise<WeekPlan | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const copiedPlan = await CopyWeekPlan(srcYear, srcWeek, year, week);
|
||||
setWeekPlan(copiedPlan);
|
||||
return copiedPlan;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Kopieren des Wochenplans');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Eintrag hinzufügen
|
||||
const addEntry = async (
|
||||
day: WeekDay,
|
||||
meal: MealType,
|
||||
productId?: number,
|
||||
customText?: string,
|
||||
groupLabel?: GroupLabel
|
||||
): Promise<PlanEntry | null> => {
|
||||
if (!weekPlan) return null;
|
||||
|
||||
try {
|
||||
const newEntry = await AddPlanEntry(weekPlan.id, day, meal, productId, customText, groupLabel);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
entries: [...prev.entries, newEntry]
|
||||
} : null);
|
||||
|
||||
return newEntry;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Hinzufügen des Eintrags');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Eintrag entfernen
|
||||
const removeEntry = async (entryId: number): Promise<boolean> => {
|
||||
try {
|
||||
await RemovePlanEntry(entryId);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
entries: prev.entries.filter(e => e.id !== entryId)
|
||||
} : null);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Entfernen des Eintrags');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Eintrag bearbeiten
|
||||
const updateEntry = async (
|
||||
entryId: number,
|
||||
day: WeekDay,
|
||||
meal: MealType,
|
||||
slot: number,
|
||||
productId?: number,
|
||||
customText?: string,
|
||||
groupLabel?: GroupLabel
|
||||
): Promise<PlanEntry | null> => {
|
||||
try {
|
||||
const updatedEntry = await UpdatePlanEntry(entryId, day, meal, slot, productId, customText, groupLabel);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
entries: prev.entries.map(e => e.id === entryId ? updatedEntry : e)
|
||||
} : null);
|
||||
|
||||
return updatedEntry;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Bearbeiten des Eintrags');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Sondertag setzen
|
||||
const setSpecialDay = async (day: WeekDay, type: string, label?: string): Promise<boolean> => {
|
||||
if (!weekPlan) return false;
|
||||
|
||||
try {
|
||||
await SetSpecialDay(weekPlan.id, day, type, label);
|
||||
|
||||
// State aktualisieren
|
||||
const newSpecialDay: SpecialDay = {
|
||||
id: Date.now(), // Temporäre ID - wird vom Backend überschrieben
|
||||
week_plan_id: weekPlan.id,
|
||||
day,
|
||||
type,
|
||||
label
|
||||
};
|
||||
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
special_days: [...prev.special_days.filter(s => s.day !== day), newSpecialDay]
|
||||
} : null);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Setzen des Sondertags');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Sondertag entfernen
|
||||
const removeSpecialDay = async (day: WeekDay): Promise<boolean> => {
|
||||
if (!weekPlan) return false;
|
||||
|
||||
try {
|
||||
await RemoveSpecialDay(weekPlan.id, day);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
special_days: prev.special_days.filter(s => s.day !== day)
|
||||
} : null);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Entfernen des Sondertags');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper-Funktionen für UI
|
||||
const getEntriesForDay = (day: WeekDay, meal: MealType): PlanEntry[] => {
|
||||
if (!weekPlan) return [];
|
||||
return weekPlan.entries
|
||||
.filter(e => e.day === day && e.meal === meal)
|
||||
.sort((a, b) => a.slot - b.slot);
|
||||
};
|
||||
|
||||
const getSpecialDay = (day: WeekDay): SpecialDay | undefined => {
|
||||
if (!weekPlan) return undefined;
|
||||
return weekPlan.special_days.find(s => s.day === day);
|
||||
};
|
||||
|
||||
const isDaySpecial = (day: WeekDay): boolean => {
|
||||
return !!getSpecialDay(day);
|
||||
};
|
||||
|
||||
// Initial laden
|
||||
useEffect(() => {
|
||||
loadWeekPlan();
|
||||
}, [loadWeekPlan]);
|
||||
|
||||
return {
|
||||
weekPlan,
|
||||
loading,
|
||||
error,
|
||||
loadWeekPlan,
|
||||
createWeekPlan,
|
||||
copyWeekPlan,
|
||||
addEntry,
|
||||
removeEntry,
|
||||
updateEntry,
|
||||
setSpecialDay,
|
||||
removeSpecialDay,
|
||||
// Helper
|
||||
getEntriesForDay,
|
||||
getSpecialDay,
|
||||
isDaySpecial,
|
||||
// Clear error
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
}
|
||||
123
frontend/src/lib/weekHelper.ts
Normal file
123
frontend/src/lib/weekHelper.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Utility-Funktionen für Kalenderwochen-Berechnungen
|
||||
* Deutsche Kalenderwoche-Standards (ISO 8601)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Berechnet die aktuelle Kalenderwoche
|
||||
*/
|
||||
export function getCurrentWeek(): { year: number; week: number } {
|
||||
const now = new Date();
|
||||
return getWeekFromDate(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Kalenderwoche aus einem Datum
|
||||
*/
|
||||
export function getWeekFromDate(date: Date): { year: number; week: number } {
|
||||
const tempDate = new Date(date.valueOf());
|
||||
const dayNum = (tempDate.getDay() + 6) % 7; // Montag = 0
|
||||
|
||||
tempDate.setDate(tempDate.getDate() - dayNum + 3);
|
||||
const firstThursday = tempDate.valueOf();
|
||||
tempDate.setMonth(0, 1);
|
||||
|
||||
if (tempDate.getDay() !== 4) {
|
||||
tempDate.setMonth(0, 1 + ((4 - tempDate.getDay()) + 7) % 7);
|
||||
}
|
||||
|
||||
const week = 1 + Math.ceil((firstThursday - tempDate.valueOf()) / 604800000); // 7 * 24 * 3600 * 1000
|
||||
|
||||
return {
|
||||
year: tempDate.getFullYear(),
|
||||
week: week
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet das erste Datum einer Kalenderwoche
|
||||
*/
|
||||
export function getDateFromWeek(year: number, week: number): Date {
|
||||
const date = new Date(year, 0, 1);
|
||||
const dayOfWeek = date.getDay();
|
||||
const daysToMonday = dayOfWeek <= 4 ? dayOfWeek - 1 : dayOfWeek - 8;
|
||||
|
||||
date.setDate(date.getDate() - daysToMonday);
|
||||
date.setDate(date.getDate() + (week - 1) * 7);
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet alle Tage einer Kalenderwoche (Mo-Fr)
|
||||
*/
|
||||
export function getWeekDays(year: number, week: number): Date[] {
|
||||
const monday = getDateFromWeek(year, week);
|
||||
const days: Date[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) { // Nur Mo-Fr
|
||||
const day = new Date(monday);
|
||||
day.setDate(monday.getDate() + i);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert ein Datum für die Anzeige (DD.MM.)
|
||||
*/
|
||||
export function formatDateShort(date: Date): string {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${day}.${month}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigiert zur nächsten Kalenderwoche
|
||||
*/
|
||||
export function getNextWeek(year: number, week: number): { year: number; week: number } {
|
||||
const weeksInYear = getWeeksInYear(year);
|
||||
|
||||
if (week < weeksInYear) {
|
||||
return { year, week: week + 1 };
|
||||
} else {
|
||||
return { year: year + 1, week: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigiert zur vorherigen Kalenderwoche
|
||||
*/
|
||||
export function getPrevWeek(year: number, week: number): { year: number; week: number } {
|
||||
if (week > 1) {
|
||||
return { year, week: week - 1 };
|
||||
} else {
|
||||
const prevYear = year - 1;
|
||||
const weeksInPrevYear = getWeeksInYear(prevYear);
|
||||
return { year: prevYear, week: weeksInPrevYear };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Anzahl Kalenderwochen in einem Jahr
|
||||
*/
|
||||
export function getWeeksInYear(year: number): number {
|
||||
const dec31 = new Date(year, 11, 31);
|
||||
const week = getWeekFromDate(dec31);
|
||||
return week.year === year ? week.week : week.week - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Kalenderwoche für Anzeige
|
||||
*/
|
||||
export function formatWeek(year: number, week: number): string {
|
||||
return `KW ${week} ${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob eine Kalenderwoche existiert
|
||||
*/
|
||||
export function isValidWeek(year: number, week: number): boolean {
|
||||
return week >= 1 && week <= getWeeksInYear(year);
|
||||
}
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './style.css'
|
||||
import App from './App'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
const root = createRoot(container!)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
)
|
||||
26
frontend/src/style.css
Normal file
26
frontend/src/style.css
Normal file
@@ -0,0 +1,26 @@
|
||||
html {
|
||||
background-color: rgba(27, 38, 54, 1);
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(""),
|
||||
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
75
frontend/src/styles/globals.css
Normal file
75
frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,75 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Focus indicators für Barrierefreiheit */
|
||||
*:focus {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip-to-content für Screenreader */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-base font-medium rounded-md border border-transparent focus:outline-none focus:ring-2 focus:ring-offset-2 min-h-[44px] min-w-[44px];
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary text-white hover:bg-blue-700 focus:ring-primary;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger text-white hover:bg-red-700 focus:ring-danger;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 text-base border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary focus:border-primary min-h-[44px];
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.allergen-badge {
|
||||
@apply inline-flex items-center px-2 py-1 text-xs font-medium rounded text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-contrast-aa {
|
||||
color: #1f2937; /* Mindestens 4.5:1 Kontrast auf weißem Hintergrund */
|
||||
}
|
||||
}
|
||||
102
frontend/src/types/index.ts
Normal file
102
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// TypeScript-Interfaces basierend auf Go-Models
|
||||
|
||||
export interface Allergen {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface Additive {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
multiline: boolean;
|
||||
allergens: Allergen[];
|
||||
additives: Additive[];
|
||||
}
|
||||
|
||||
export interface WeekPlan {
|
||||
id: number;
|
||||
year: number;
|
||||
week: number;
|
||||
created_at: string;
|
||||
entries: PlanEntry[];
|
||||
special_days: SpecialDay[];
|
||||
}
|
||||
|
||||
export interface PlanEntry {
|
||||
id: number;
|
||||
week_plan_id: number;
|
||||
day: number; // 1=Montag, 2=Dienstag, ..., 5=Freitag
|
||||
meal: string; // 'fruehstueck' | 'vesper'
|
||||
slot: number;
|
||||
product_id?: number;
|
||||
product?: Product;
|
||||
custom_text?: string;
|
||||
group_label?: string; // 'Krippe' | 'Kita' | 'Hort'
|
||||
}
|
||||
|
||||
export interface SpecialDay {
|
||||
id: number;
|
||||
week_plan_id: number;
|
||||
day: number; // 1-5 (Mo-Fr)
|
||||
type: string; // 'feiertag' | 'schliesstag'
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
available: boolean;
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
download_url?: string;
|
||||
release_notes?: string;
|
||||
}
|
||||
|
||||
// UI-spezifische Types
|
||||
export type MealType = 'fruehstueck' | 'vesper';
|
||||
export type GroupLabel = 'Krippe' | 'Kita' | 'Hort';
|
||||
export type SpecialDayType = 'feiertag' | 'schliesstag';
|
||||
export type WeekDay = 1 | 2 | 3 | 4 | 5; // Mo-Fr
|
||||
|
||||
// Navigation
|
||||
export type NavRoute = 'wochenplan' | 'produkte' | 'info';
|
||||
|
||||
// Form States
|
||||
export interface ProductFormData {
|
||||
name: string;
|
||||
multiline: boolean;
|
||||
allergenIds: string[];
|
||||
additiveIds: string[];
|
||||
}
|
||||
|
||||
export interface AddEntryFormData {
|
||||
meal: MealType;
|
||||
productId?: number;
|
||||
customText?: string;
|
||||
groupLabel?: GroupLabel;
|
||||
}
|
||||
|
||||
// Konstanten für Deutsche Texte
|
||||
export const DAY_NAMES: Record<WeekDay, string> = {
|
||||
1: 'Montag',
|
||||
2: 'Dienstag',
|
||||
3: 'Mittwoch',
|
||||
4: 'Donnerstag',
|
||||
5: 'Freitag'
|
||||
};
|
||||
|
||||
export const MEAL_NAMES: Record<MealType, string> = {
|
||||
fruehstueck: 'Frühstück',
|
||||
vesper: 'Vesper'
|
||||
};
|
||||
|
||||
export const GROUP_LABELS: GroupLabel[] = ['Krippe', 'Kita', 'Hort'];
|
||||
|
||||
export const SPECIAL_DAY_NAMES: Record<SpecialDayType, string> = {
|
||||
feiertag: 'Feiertag',
|
||||
schliesstag: 'Schließtag'
|
||||
};
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
frontend/tailwind.config.js
Normal file
20
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#2563EB',
|
||||
danger: '#DC2626',
|
||||
success: '#059669',
|
||||
},
|
||||
fontSize: {
|
||||
'base': ['16px', '24px'], // Mindestens 16px für Barrierefreiheit
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
50
go.mod
Normal file
50
go.mod
Normal file
@@ -0,0 +1,50 @@
|
||||
module speiseplan
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
// replace github.com/wailsapp/wails/v2 v2.11.0 => /home/clawd/go/pkg/mod
|
||||
137
go.sum
Normal file
137
go.sum
Normal file
@@ -0,0 +1,137 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
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/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
54
main.go
Normal file
54
main.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"log"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
// Datenbank initialisieren
|
||||
if err := InitDatabase(); err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
|
||||
// Sicherstellen, dass die Datenbank ordnungsgemäß geschlossen wird
|
||||
defer func() {
|
||||
if err := CloseDatabase(); err != nil {
|
||||
log.Printf("Error closing database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create an instance of the app structure
|
||||
app := NewApp()
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Speiseplan App",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
OnStartup: app.startup,
|
||||
OnShutdown: func(ctx context.Context) {
|
||||
// Cleanup beim Beenden
|
||||
CloseDatabase()
|
||||
},
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error running application: %v", err)
|
||||
}
|
||||
}
|
||||
76
models.go
Normal file
76
models.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Allergen repräsentiert ein EU-Allergen (a-n)
|
||||
type Allergen struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Category string `json:"category" db:"category"`
|
||||
}
|
||||
|
||||
// Additive repräsentiert einen Zusatzstoff (A, B, E, F, etc.)
|
||||
type Additive struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
}
|
||||
|
||||
// Product repräsentiert ein Produkt mit Allergenen und Zusatzstoffen
|
||||
type Product struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Multiline bool `json:"multiline" db:"multiline"`
|
||||
Allergens []Allergen `json:"allergens"`
|
||||
Additives []Additive `json:"additives"`
|
||||
}
|
||||
|
||||
// WeekPlan repräsentiert einen Wochenplan
|
||||
type WeekPlan struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
Year int `json:"year" db:"year"`
|
||||
Week int `json:"week" db:"week"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
Entries []PlanEntry `json:"entries"`
|
||||
SpecialDays []SpecialDay `json:"special_days"`
|
||||
}
|
||||
|
||||
// PlanEntry repräsentiert einen Eintrag im Wochenplan
|
||||
type PlanEntry struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
WeekPlanID int `json:"week_plan_id" db:"week_plan_id"`
|
||||
Day int `json:"day" db:"day"` // 0=Mo, 1=Di, 2=Mi, 3=Do, 4=Fr
|
||||
Meal string `json:"meal" db:"meal"` // 'breakfast' oder 'snack'
|
||||
Slot int `json:"slot" db:"slot"` // Reihenfolge innerhalb des Tages
|
||||
ProductID *int `json:"product_id" db:"product_id"`
|
||||
Product *Product `json:"product,omitempty"`
|
||||
CustomText *string `json:"custom_text" db:"custom_text"`
|
||||
GroupLabel *string `json:"group_label" db:"group_label"` // 'Krippe', 'Kita', 'Hort', etc.
|
||||
}
|
||||
|
||||
// SpecialDay repräsentiert einen Sondertag (Feiertag, Schließtag, etc.)
|
||||
type SpecialDay struct {
|
||||
ID int `json:"id" db:"id"`
|
||||
WeekPlanID int `json:"week_plan_id" db:"week_plan_id"`
|
||||
Day int `json:"day" db:"day"` // 0=Mo, 1=Di, ...
|
||||
Type string `json:"type" db:"type"` // 'holiday' oder 'closed'
|
||||
Label *string `json:"label" db:"label"` // z.B. "Neujahr", "Teamtag"
|
||||
}
|
||||
|
||||
// UpdateInfo repräsentiert Informationen über verfügbare Updates
|
||||
type UpdateInfo struct {
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||
}
|
||||
|
||||
// ProductImport repräsentiert ein Produkt beim Import aus JSON
|
||||
type ProductImport struct {
|
||||
Name string `json:"name"`
|
||||
Multiline bool `json:"multiline"`
|
||||
Allergens1 string `json:"allergens1"`
|
||||
Allergens2 string `json:"allergens2"`
|
||||
}
|
||||
596
products_export.json
Normal file
596
products_export.json
Normal file
@@ -0,0 +1,596 @@
|
||||
[
|
||||
{
|
||||
"name": "Apfel-/Aprikosenmus",
|
||||
"multiline": false,
|
||||
"allergens1": "",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Apfel-/Bananenmus",
|
||||
"multiline": false,
|
||||
"allergens1": "",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Apfelmus & Vanillesoße",
|
||||
"multiline": true,
|
||||
"allergens1": "c, g, A, E, F, G, MS, S, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Bag. m. Wiener od. Salami-Sticks",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, i, j, k, A, E, F, GV, K, R,",
|
||||
"allergens2": "SCH, SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Baguette mit Wiener",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, i, j, k, E, F, GV, K, R, SCH,",
|
||||
"allergens2": "SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Baumkuchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, e, f, g, h, k,",
|
||||
"allergens2": "E, FH, SR, SÜ"
|
||||
},
|
||||
{
|
||||
"name": "belegte Baguettes",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, i, j, k,",
|
||||
"allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "belegte Brötchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, i, j, k,",
|
||||
"allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "belegtes Baguette",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, i, j, k,",
|
||||
"allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "belegtes Toast",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, i, j, k,",
|
||||
"allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Bemmchen",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, f, g, h, i, j, k, m, B",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Blätterteig-Zimtschnecken",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, B, E, S, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Brötchen mit süßem Aufstrich",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, B, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Butter",
|
||||
"multiline": false,
|
||||
"allergens1": "g",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Cornflakes",
|
||||
"multiline": false,
|
||||
"allergens1": "a, e, g, f, h, k, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Donuts",
|
||||
"multiline": false,
|
||||
"allergens1": "a, h, B, E, ST, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Eier",
|
||||
"multiline": false,
|
||||
"allergens1": "c",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Eierplätzchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, B",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Eis",
|
||||
"multiline": false,
|
||||
"allergens1": "e, f, g, h, E, ST, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Erdbeerquark",
|
||||
"multiline": false,
|
||||
"allergens1": "g, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Filinchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, f, g, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Filinchen / Knäckebrot",
|
||||
"multiline": false,
|
||||
"allergens1": "a, f, g, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Filinchen mit Marmelade",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, f, g, B, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Frischkäse",
|
||||
"multiline": false,
|
||||
"allergens1": "g",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Fruchtjoghurt",
|
||||
"multiline": false,
|
||||
"allergens1": "g, F, MS, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Fruchtquark",
|
||||
"multiline": false,
|
||||
"allergens1": "g, F, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Fruchtreiswaffeln",
|
||||
"multiline": false,
|
||||
"allergens1": "f, g, h, k, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Fruchtriegel",
|
||||
"multiline": false,
|
||||
"allergens1": "e, f, g, h, k, F, S, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Fruchtzwerge",
|
||||
"multiline": false,
|
||||
"allergens1": "g, F, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Götterspeise mit Vanillesoße",
|
||||
"multiline": true,
|
||||
"allergens1": "g, SÜ, G, S, F, SV",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Haferflocken",
|
||||
"multiline": false,
|
||||
"allergens1": "a",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "IKEA-Hot-Dogs",
|
||||
"multiline": true,
|
||||
"allergens1": "a, i, j, GV, K, S, ST, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Joghurt",
|
||||
"multiline": false,
|
||||
"allergens1": "g, E, MS, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Joghurt mit Obstsalat",
|
||||
"multiline": true,
|
||||
"allergens1": "g, E, MS, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Kekse",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, e, f, g, i, j, k,",
|
||||
"allergens2": "B, E, F, SÜ, TM"
|
||||
},
|
||||
{
|
||||
"name": "kleine Puddings",
|
||||
"multiline": false,
|
||||
"allergens1": "c, g, F, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "kleine Quarks",
|
||||
"multiline": false,
|
||||
"allergens1": "g, MS, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Kuchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, h, B, E, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Laugenbrezeln",
|
||||
"multiline": false,
|
||||
"allergens1": "a, g, k, SR",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Laugengebäck",
|
||||
"multiline": false,
|
||||
"allergens1": "a, g, k, SR",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Laugenstangen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, g, k, SR",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Leckermäulchen",
|
||||
"multiline": false,
|
||||
"allergens1": "g, F, G, MS, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Madeleines",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, B, FH, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Maisstangen (glutenfrei)",
|
||||
"multiline": false,
|
||||
"allergens1": "",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Milch",
|
||||
"multiline": false,
|
||||
"allergens1": "g",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Milch / Joghurt",
|
||||
"multiline": false,
|
||||
"allergens1": "g",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Milchbrötchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, B, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Milchreis",
|
||||
"multiline": false,
|
||||
"allergens1": "g, MS, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "mini Paula",
|
||||
"multiline": false,
|
||||
"allergens1": "g, F, MS, ST, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Amerikaner",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, h, B, E, MS, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Berliner",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, h,",
|
||||
"allergens2": "B, E, SR, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Mini-Donuts",
|
||||
"multiline": false,
|
||||
"allergens1": "a, h, B, E, ST, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Eclairs",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, f, g, h, E, MS, ST, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Geflügelfrikadellen",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, j, B, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Götterspeise / Vanillesoße",
|
||||
"multiline": true,
|
||||
"allergens1": "g, SÜ, G, S, F, SV",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Muffins",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, f, g, B, E, FH, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Quarkbällchen",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, h, B, E, St, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Mini-Windbeutel",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, h,",
|
||||
"allergens2": "B, E, SR, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Monte",
|
||||
"multiline": false,
|
||||
"allergens1": "g, h, MS, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Nutella",
|
||||
"multiline": false,
|
||||
"allergens1": "g, f, h, E, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Nutellabaguette",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, f, g,",
|
||||
"allergens2": "E, SR, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Nutellabrot",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, f, g,",
|
||||
"allergens2": "E, SR, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Nutella-Toast",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, f, g,",
|
||||
"allergens2": "E, SR, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Obst",
|
||||
"multiline": false,
|
||||
"allergens1": "",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Obst / Gemüse",
|
||||
"multiline": false,
|
||||
"allergens1": "",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Obstpause",
|
||||
"multiline": false,
|
||||
"allergens1": "",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Obstsalat",
|
||||
"multiline": false,
|
||||
"allergens1": "",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Pizzabrötchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, B",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Plinse mit Zucker & Zimt",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, B, MS, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Pudding",
|
||||
"multiline": false,
|
||||
"allergens1": "c, g, F, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Quarkkuchen",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, h, B, E, MS, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Quarkspeise",
|
||||
"multiline": false,
|
||||
"allergens1": "g, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Reiswaffeln",
|
||||
"multiline": false,
|
||||
"allergens1": "f, g, k",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Reiswaffeln mit Schokolade",
|
||||
"multiline": true,
|
||||
"allergens1": "f, g, k, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Rote Grütze & Vanillesoße",
|
||||
"multiline": true,
|
||||
"allergens1": "g, E, F, G, MS, S, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Rührei",
|
||||
"multiline": false,
|
||||
"allergens1": "c, g",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Rührkuchen",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, h, B, E, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Salzgebäck",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, k, B, SR",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Schmierkäse",
|
||||
"multiline": false,
|
||||
"allergens1": "g, SCH, SR",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Schnitt- & Schmierkäse",
|
||||
"multiline": false,
|
||||
"allergens1": "g, SCH, SR",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Schnittkäse",
|
||||
"multiline": false,
|
||||
"allergens1": "g",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Schokobrötchen",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, k, B ,E, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Schokopudding & Vanillesoße",
|
||||
"multiline": true,
|
||||
"allergens1": "c, g, E, F, G, MS, S, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Stracciatella-Joghurt",
|
||||
"multiline": true,
|
||||
"allergens1": "g, MS, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "süßer Aufstrich",
|
||||
"multiline": false,
|
||||
"allergens1": "a, e, g, E, G, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "süßer Quark",
|
||||
"multiline": false,
|
||||
"allergens1": "g, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "süsses Baguette",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, f, g,",
|
||||
"allergens2": "E, G, SR, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "süßes Toastbrot",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, B, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Toast Hawai",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, GV, K, SR, ST, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Toast mit Wiener",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, g, i, j, k, GV, K, SR, ST, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Toast überbacken",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, g, i, j, k,",
|
||||
"allergens2": "E, F, GV, K, R, SCH, SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Vanillesoße",
|
||||
"multiline": true,
|
||||
"allergens1": "c, g, A, E, F, G, MS, S, SR, SÜ, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "verschiedenes Brot",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, k, B",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Waffeln",
|
||||
"multiline": false,
|
||||
"allergens1": "a, c, m, B, E, FH, SR, SÜ",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Wiener",
|
||||
"multiline": false,
|
||||
"allergens1": "i, j, E, F, GV,",
|
||||
"allergens2": "K, SCH, SR, ST, V"
|
||||
},
|
||||
{
|
||||
"name": "Wiener & Toastbrot",
|
||||
"multiline": true,
|
||||
"allergens1": "a, c, i, j, k, E, F, GV, K, R, SCH,",
|
||||
"allergens2": "SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Wurst",
|
||||
"multiline": false,
|
||||
"allergens1": "i, j, E, F, GV, K, SR, ST, V",
|
||||
"allergens2": ""
|
||||
},
|
||||
{
|
||||
"name": "Würstchen im Schlafrock (Blätter-",
|
||||
"multiline": true,
|
||||
"allergens1": "teig) (a, c, g, i, j, E, F, GV,",
|
||||
"allergens2": "B, E, K, S, SCH, SR, ST, SÜ, V"
|
||||
},
|
||||
{
|
||||
"name": "Zwieback",
|
||||
"multiline": false,
|
||||
"allergens1": "a, B, M, SÜ",
|
||||
"allergens2": ""
|
||||
}
|
||||
]
|
||||
221
seed.go
Normal file
221
seed.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed products_export.json
|
||||
var productsJSON []byte
|
||||
|
||||
// SeedDatabase fügt Seed-Daten in die Datenbank ein
|
||||
func SeedDatabase() error {
|
||||
// Prüfen ob bereits Seeds vorhanden sind
|
||||
var count int
|
||||
err := db.Get(&count, "SELECT COUNT(*) FROM allergens")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for existing allergens: %w", err)
|
||||
}
|
||||
|
||||
// Nur seeden wenn noch keine Daten vorhanden sind
|
||||
if count > 0 {
|
||||
return nil // Bereits geseedet
|
||||
}
|
||||
|
||||
// Allergene seeden
|
||||
if err := seedAllergens(); err != nil {
|
||||
return fmt.Errorf("failed to seed allergens: %w", err)
|
||||
}
|
||||
|
||||
// Zusatzstoffe seeden
|
||||
if err := seedAdditives(); err != nil {
|
||||
return fmt.Errorf("failed to seed additives: %w", err)
|
||||
}
|
||||
|
||||
// Produkte seeden
|
||||
if err := seedProducts(); err != nil {
|
||||
return fmt.Errorf("failed to seed products: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedAllergens fügt die 14 EU-Allergene ein
|
||||
func seedAllergens() error {
|
||||
allergens := []Allergen{
|
||||
{"a", "glutenhaltiges Getreide (Weizen, Roggen, Gerste, Hafer, Dinkel, Kamut)", "allergen"},
|
||||
{"b", "Krebstiere", "allergen"},
|
||||
{"c", "Eier", "allergen"},
|
||||
{"d", "Fisch", "allergen"},
|
||||
{"e", "Erdnüsse", "allergen"},
|
||||
{"f", "Soja(bohnen)", "allergen"},
|
||||
{"g", "Milch (einschließlich Laktose)", "allergen"},
|
||||
{"h", "Schalenfrüchte (Mandeln, Haselnüsse, Walnüsse, Cashew, Pecan, Paranüsse, Pistazien, Macadamia)", "allergen"},
|
||||
{"i", "Sellerie", "allergen"},
|
||||
{"j", "Senf", "allergen"},
|
||||
{"k", "Sesamsamen", "allergen"},
|
||||
{"l", "Schwefeldioxid und Sulfite (> 10 mg/kg oder mg/l)", "allergen"},
|
||||
{"m", "Lupine", "allergen"},
|
||||
{"n", "Weichtiere", "allergen"},
|
||||
}
|
||||
|
||||
for _, allergen := range allergens {
|
||||
_, err := db.NamedExec(
|
||||
"INSERT OR IGNORE INTO allergens (id, name, category) VALUES (:id, :name, :category)",
|
||||
allergen,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert allergen %s: %w", allergen.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedAdditives fügt die deutschen Zusatzstoffe ein
|
||||
func seedAdditives() error {
|
||||
additives := []Additive{
|
||||
{"A", "Antioxidationsmittel"},
|
||||
{"B", "Backtriebmittel"},
|
||||
{"E", "Emulgator"},
|
||||
{"F", "Farbstoff"},
|
||||
{"FM", "Festigungsmittel"},
|
||||
{"FH", "Feuchthaltemittel"},
|
||||
{"FÜ", "Füllstoff"},
|
||||
{"G", "Geliermittel"},
|
||||
{"GV", "Geschmacksverstärker"},
|
||||
{"K", "Konservierungsstoff"},
|
||||
{"M", "Mehlbehandlungsmittel"},
|
||||
{"MS", "Modifizierte Stärke"},
|
||||
{"R", "Rieselhilfe"},
|
||||
{"S", "Säuerungsmittel"},
|
||||
{"SR", "Säureregulator"},
|
||||
{"SV", "Schaumverhüter"},
|
||||
{"SCH", "Schmelzsalz"},
|
||||
{"ST", "Stabilisator"},
|
||||
{"SÜ", "Süßungsmittel"},
|
||||
{"T", "Trägerstoff"},
|
||||
{"TG", "Treibgas"},
|
||||
{"TM", "Trennmittel"},
|
||||
{"Ü", "Überzugsmittel"},
|
||||
{"V", "Verdickungsmittel"},
|
||||
}
|
||||
|
||||
for _, additive := range additives {
|
||||
_, err := db.NamedExec(
|
||||
"INSERT OR IGNORE INTO additives (id, name) VALUES (:id, :name)",
|
||||
additive,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert additive %s: %w", additive.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedProducts importiert Produkte aus der JSON-Datei
|
||||
func seedProducts() error {
|
||||
var imports []ProductImport
|
||||
if err := json.Unmarshal(productsJSON, &imports); err != nil {
|
||||
return fmt.Errorf("failed to parse products JSON: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, product := range imports {
|
||||
// Produkt einfügen
|
||||
result, err := tx.NamedExec(
|
||||
"INSERT OR IGNORE INTO products (name, multiline) VALUES (:name, :multiline)",
|
||||
map[string]interface{}{
|
||||
"name": product.Name,
|
||||
"multiline": product.Multiline,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert product %s: %w", product.Name, err)
|
||||
}
|
||||
|
||||
// Produkt-ID ermitteln
|
||||
var productID int64
|
||||
if productID, err = result.LastInsertId(); err != nil {
|
||||
// Wenn INSERT OR IGNORE nichts eingefügt hat, ID über SELECT holen
|
||||
err = tx.Get(&productID, "SELECT id FROM products WHERE name = ?", product.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get product ID for %s: %w", product.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Allergene und Zusatzstoffe parsen und zuordnen
|
||||
allergenIDs, additiveIDs := parseAllergenData(product.Allergens1, product.Allergens2)
|
||||
|
||||
// Allergene zuordnen
|
||||
for _, allergenID := range allergenIDs {
|
||||
_, err := tx.Exec(
|
||||
"INSERT OR IGNORE INTO product_allergens (product_id, allergen_id) VALUES (?, ?)",
|
||||
productID, allergenID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to link allergen %s to product %s: %w", allergenID, product.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Zusatzstoffe zuordnen
|
||||
for _, additiveID := range additiveIDs {
|
||||
_, err := tx.Exec(
|
||||
"INSERT OR IGNORE INTO product_additives (product_id, additive_id) VALUES (?, ?)",
|
||||
productID, additiveID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to link additive %s to product %s: %w", additiveID, product.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// parseAllergenData parst Allergen- und Zusatzstoff-Daten aus den Import-Feldern
|
||||
func parseAllergenData(allergens1, allergens2 string) ([]string, []string) {
|
||||
// Beide Felder zusammenführen
|
||||
combined := strings.TrimSpace(allergens1 + ", " + allergens2)
|
||||
if combined == ", " {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Regex für Allergene (einzelne Kleinbuchstaben a-n)
|
||||
allergenRegex := regexp.MustCompile(`\b[a-n]\b`)
|
||||
// Regex für Zusatzstoffe (Großbuchstaben und Kürzel)
|
||||
additiveRegex := regexp.MustCompile(`\b[A-Z]{1,3}\b`)
|
||||
|
||||
allergenMatches := allergenRegex.FindAllString(combined, -1)
|
||||
additiveMatches := additiveRegex.FindAllString(combined, -1)
|
||||
|
||||
// Duplikate entfernen
|
||||
allergenIDs := removeDuplicates(allergenMatches)
|
||||
additiveIDs := removeDuplicates(additiveMatches)
|
||||
|
||||
return allergenIDs, additiveIDs
|
||||
}
|
||||
|
||||
// removeDuplicates entfernt Duplikate aus einem String-Slice
|
||||
func removeDuplicates(slice []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
result := []string{}
|
||||
|
||||
for _, item := range slice {
|
||||
if !seen[item] {
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
105
updater.go
Normal file
105
updater.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Aktuelle Version der App
|
||||
CurrentVersion = "1.0.0"
|
||||
// Update-Check URL
|
||||
UpdateURL = "https://speiseplan.supertoll.xyz/version.json"
|
||||
)
|
||||
|
||||
// Updater verwaltet App-Updates
|
||||
type Updater struct {
|
||||
CurrentVersion string
|
||||
UpdateURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewUpdater erstellt einen neuen Updater
|
||||
func NewUpdater() *Updater {
|
||||
return &Updater{
|
||||
CurrentVersion: CurrentVersion,
|
||||
UpdateURL: UpdateURL,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VersionResponse repräsentiert die Antwort vom Update-Server
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
}
|
||||
|
||||
// CheckForUpdate prüft ob ein Update verfügbar ist
|
||||
func (u *Updater) CheckForUpdate() (*UpdateInfo, error) {
|
||||
resp, err := u.HTTPClient.Get(u.UpdateURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check for updates: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("update server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var versionResp VersionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse version response: %w", err)
|
||||
}
|
||||
|
||||
// Versions-Vergleich (vereinfacht)
|
||||
available := compareVersions(u.CurrentVersion, versionResp.Version) < 0
|
||||
|
||||
return &UpdateInfo{
|
||||
Available: available,
|
||||
CurrentVersion: u.CurrentVersion,
|
||||
LatestVersion: versionResp.Version,
|
||||
DownloadURL: versionResp.DownloadURL,
|
||||
ReleaseNotes: versionResp.ReleaseNotes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DownloadUpdate lädt ein verfügbares Update herunter (Stub)
|
||||
func (u *Updater) DownloadUpdate(downloadURL string) error {
|
||||
// TODO: Implementierung für das Herunterladen und Installieren von Updates
|
||||
// Für Phase 1 nur ein Stub
|
||||
return fmt.Errorf("update download not implemented yet")
|
||||
}
|
||||
|
||||
// compareVersions vergleicht zwei Versionsstrings
|
||||
// Gibt -1 zurück wenn v1 < v2, 0 wenn v1 == v2, 1 wenn v1 > v2
|
||||
func compareVersions(v1, v2 string) int {
|
||||
// Vereinfachter Versionsvergleich (nur für grundlegende Semantic Versioning)
|
||||
v1Parts := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||
v2Parts := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||
|
||||
// Normalisiere auf gleiche Länge
|
||||
for len(v1Parts) < 3 {
|
||||
v1Parts = append(v1Parts, "0")
|
||||
}
|
||||
for len(v2Parts) < 3 {
|
||||
v2Parts = append(v2Parts, "0")
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
// Einfacher String-Vergleich (funktioniert für einstellige Zahlen)
|
||||
if v1Parts[i] < v2Parts[i] {
|
||||
return -1
|
||||
}
|
||||
if v1Parts[i] > v2Parts[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
13
wails.json
Normal file
13
wails.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "speiseplan",
|
||||
"outputfilename": "speiseplan",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"author": {
|
||||
"name": "",
|
||||
"email": ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user