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