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:
clawd
2026-02-20 09:59:36 +00:00
commit c19483ea81
39 changed files with 5638 additions and 0 deletions

20
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

59
frontend/src/App.css Normal file
View 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
View 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

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

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

View 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)
};
}

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

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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"
}
]
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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": ""
}
}