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

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()]
})