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:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>speiseplan</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1566
frontend/package-lock.json
generated
Normal file
1566
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwindcss": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^2.0.1",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
59
frontend/src/App.css
Normal file
59
frontend/src/App.css
Normal file
@@ -0,0 +1,59 @@
|
||||
#app {
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#logo {
|
||||
display: block;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
margin: auto;
|
||||
padding: 10% 0 0;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
background-origin: content-box;
|
||||
}
|
||||
|
||||
.result {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
.input-box .btn {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
margin: 0 0 0 20px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-box .btn:hover {
|
||||
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.input-box .input {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
padding: 0 10px;
|
||||
background-color: rgba(240, 240, 240, 1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.input-box .input:hover {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.input-box .input:focus {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
28
frontend/src/App.tsx
Normal file
28
frontend/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import {useState} from 'react';
|
||||
import logo from './assets/images/logo-universal.png';
|
||||
import './App.css';
|
||||
import {Greet} from "../wailsjs/go/main/App";
|
||||
|
||||
function App() {
|
||||
const [resultText, setResultText] = useState("Please enter your name below 👇");
|
||||
const [name, setName] = useState('');
|
||||
const updateName = (e: any) => setName(e.target.value);
|
||||
const updateResultText = (result: string) => setResultText(result);
|
||||
|
||||
function greet() {
|
||||
Greet(name).then(updateResultText);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="App">
|
||||
<img src={logo} id="logo" alt="logo"/>
|
||||
<div id="result" className="result">{resultText}</div>
|
||||
<div id="input" className="input-box">
|
||||
<input id="name" className="input" onChange={updateName} autoComplete="off" name="input" type="text"/>
|
||||
<button className="btn" onClick={greet}>Greet</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
93
frontend/src/assets/fonts/OFL.txt
Normal file
93
frontend/src/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
BIN
frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/images/logo-universal.png
Normal file
BIN
frontend/src/assets/images/logo-universal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
129
frontend/src/components/AdditivePicker.tsx
Normal file
129
frontend/src/components/AdditivePicker.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Additive } from '../types';
|
||||
|
||||
interface AdditivePickerProps {
|
||||
additives: Additive[];
|
||||
selectedIds: string[];
|
||||
onChange: (selectedIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Deutsche Namen für häufige Zusatzstoffe (E-Nummern)
|
||||
const ADDITIVE_NAMES: Record<string, string> = {
|
||||
'1': 'Farbstoff',
|
||||
'2': 'Konservierungsstoff',
|
||||
'3': 'Antioxidationsmittel',
|
||||
'4': 'Geschmacksverstärker',
|
||||
'5': 'Geschwefelt',
|
||||
'6': 'Geschwärzt',
|
||||
'7': 'Gewachst',
|
||||
'8': 'Phosphat',
|
||||
'9': 'Süßungsmittel',
|
||||
'10': 'Phenylalaninquelle',
|
||||
'11': 'Koffeinhaltig',
|
||||
'12': 'Chininhaltig',
|
||||
'13': 'Alkoholhaltig',
|
||||
'14': 'Nitritpökelsalz',
|
||||
'15': 'Milchsäure',
|
||||
'16': 'Citronensäure',
|
||||
'17': 'Ascorbinsäure',
|
||||
'18': 'Tocopherol',
|
||||
'19': 'Lecithin',
|
||||
'20': 'Johannisbrotkernmehl',
|
||||
'21': 'Guarkernmehl',
|
||||
'22': 'Xanthan',
|
||||
'23': 'Carrageen',
|
||||
'24': 'Agar'
|
||||
};
|
||||
|
||||
export function AdditivePicker({ additives, selectedIds, onChange, className = '' }: AdditivePickerProps) {
|
||||
const handleToggle = (additiveId: string) => {
|
||||
if (selectedIds.includes(additiveId)) {
|
||||
onChange(selectedIds.filter(id => id !== additiveId));
|
||||
} else {
|
||||
onChange([...selectedIds, additiveId]);
|
||||
}
|
||||
};
|
||||
|
||||
// Sortierte Zusatzstoffe
|
||||
const sortedAdditives = [...additives].sort((a, b) => {
|
||||
// Numerisch sortieren falls möglich, sonst alphabetisch
|
||||
const aNum = parseInt(a.id);
|
||||
const bNum = parseInt(b.id);
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return (
|
||||
<fieldset className={`border border-gray-300 rounded-md p-4 ${className}`}>
|
||||
<legend className="text-sm font-medium text-gray-900 px-2">
|
||||
Zusatzstoffe auswählen
|
||||
</legend>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-3 max-h-60 overflow-y-auto"
|
||||
role="group"
|
||||
aria-labelledby="additive-picker-legend"
|
||||
>
|
||||
{sortedAdditives.map(additive => {
|
||||
const isSelected = selectedIds.includes(additive.id);
|
||||
const displayName = ADDITIVE_NAMES[additive.id] || additive.name;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={additive.id}
|
||||
className={`flex items-center space-x-2 p-2 rounded-md border cursor-pointer transition-colors min-h-[44px] ${
|
||||
isSelected
|
||||
? 'bg-orange-100 text-orange-900 border-orange-300'
|
||||
: 'bg-white hover:bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggle(additive.id)}
|
||||
className="w-4 h-4 text-orange-600 bg-gray-100 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
|
||||
aria-describedby={`additive-${additive.id}-description`}
|
||||
/>
|
||||
|
||||
<span className="flex-1 text-sm">
|
||||
<span className="font-medium">{additive.id}</span>
|
||||
{displayName && (
|
||||
<span className="text-gray-600 ml-1">
|
||||
- {displayName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
id={`additive-${additive.id}-description`}
|
||||
className="sr-only"
|
||||
>
|
||||
Zusatzstoff {additive.id}: {displayName}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-orange-50 rounded-md border border-orange-200">
|
||||
<p className="text-sm text-orange-800">
|
||||
<strong>Ausgewählte Zusatzstoffe:</strong>{' '}
|
||||
<span className="font-mono">
|
||||
{selectedIds.sort((a, b) => {
|
||||
const aNum = parseInt(a);
|
||||
const bNum = parseInt(b);
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
}).join(', ')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/AllergenBadge.tsx
Normal file
79
frontend/src/components/AllergenBadge.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Allergen } from '../types';
|
||||
|
||||
interface AllergenBadgeProps {
|
||||
allergen: Allergen;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Farbkodierung für Allergene (a-n)
|
||||
const ALLERGEN_COLORS: Record<string, string> = {
|
||||
'a': 'bg-red-500', // Glutenhaltige Getreide
|
||||
'b': 'bg-orange-500', // Krebstiere
|
||||
'c': 'bg-yellow-500', // Eier
|
||||
'd': 'bg-green-500', // Fisch
|
||||
'e': 'bg-blue-500', // Erdnüsse
|
||||
'f': 'bg-indigo-500', // Soja
|
||||
'g': 'bg-purple-500', // Milch/Laktose
|
||||
'h': 'bg-pink-500', // Schalenfrüchte
|
||||
'i': 'bg-red-400', // Sellerie
|
||||
'j': 'bg-orange-400', // Senf
|
||||
'k': 'bg-yellow-400', // Sesam
|
||||
'l': 'bg-green-400', // Schwefeldioxid
|
||||
'm': 'bg-blue-400', // Lupinen
|
||||
'n': 'bg-indigo-400', // Weichtiere
|
||||
};
|
||||
|
||||
export function AllergenBadge({ allergen, size = 'md', className = '' }: AllergenBadgeProps) {
|
||||
const colorClass = ALLERGEN_COLORS[allergen.id] || 'bg-gray-500';
|
||||
const sizeClass = size === 'sm'
|
||||
? 'text-xs px-1.5 py-0.5'
|
||||
: 'text-xs px-2 py-1';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center font-medium text-white rounded ${colorClass} ${sizeClass} ${className}`}
|
||||
title={`${allergen.id}: ${allergen.name}`}
|
||||
aria-label={`Allergen ${allergen.id}: ${allergen.name}`}
|
||||
>
|
||||
{allergen.id}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface AllergenListProps {
|
||||
allergens: Allergen[];
|
||||
size?: 'sm' | 'md';
|
||||
maxVisible?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AllergenList({ allergens, size = 'md', maxVisible, className = '' }: AllergenListProps) {
|
||||
if (!allergens?.length) return null;
|
||||
|
||||
const visible = maxVisible ? allergens.slice(0, maxVisible) : allergens;
|
||||
const remaining = maxVisible && allergens.length > maxVisible
|
||||
? allergens.length - maxVisible
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-1 ${className}`}>
|
||||
{visible.map(allergen => (
|
||||
<AllergenBadge
|
||||
key={allergen.id}
|
||||
allergen={allergen}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<span
|
||||
className={`inline-flex items-center font-medium text-gray-600 bg-gray-200 rounded ${size === 'sm' ? 'text-xs px-1.5 py-0.5' : 'text-xs px-2 py-1'}`}
|
||||
title={`${remaining} weitere Allergene`}
|
||||
aria-label={`${remaining} weitere Allergene`}
|
||||
>
|
||||
+{remaining}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/AllergenPicker.tsx
Normal file
106
frontend/src/components/AllergenPicker.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Allergen } from '../types';
|
||||
|
||||
interface AllergenPickerProps {
|
||||
allergens: Allergen[];
|
||||
selectedIds: string[];
|
||||
onChange: (selectedIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Deutsche Namen für Allergene (nach EU-Verordnung)
|
||||
const ALLERGEN_NAMES: Record<string, string> = {
|
||||
'a': 'Glutenhaltige Getreide',
|
||||
'b': 'Krebstiere',
|
||||
'c': 'Eier',
|
||||
'd': 'Fisch',
|
||||
'e': 'Erdnüsse',
|
||||
'f': 'Soja',
|
||||
'g': 'Milch/Laktose',
|
||||
'h': 'Schalenfrüchte',
|
||||
'i': 'Sellerie',
|
||||
'j': 'Senf',
|
||||
'k': 'Sesam',
|
||||
'l': 'Schwefeldioxid',
|
||||
'm': 'Lupinen',
|
||||
'n': 'Weichtiere'
|
||||
};
|
||||
|
||||
export function AllergenPicker({ allergens, selectedIds, onChange, className = '' }: AllergenPickerProps) {
|
||||
const handleToggle = (allergenId: string) => {
|
||||
if (selectedIds.includes(allergenId)) {
|
||||
onChange(selectedIds.filter(id => id !== allergenId));
|
||||
} else {
|
||||
onChange([...selectedIds, allergenId]);
|
||||
}
|
||||
};
|
||||
|
||||
// Sortierte Allergene (a-n)
|
||||
const sortedAllergens = [...allergens].sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return (
|
||||
<fieldset className={`border border-gray-300 rounded-md p-4 ${className}`}>
|
||||
<legend className="text-sm font-medium text-gray-900 px-2">
|
||||
Allergene auswählen
|
||||
</legend>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3 mt-3"
|
||||
role="group"
|
||||
aria-labelledby="allergen-picker-legend"
|
||||
>
|
||||
{sortedAllergens.map(allergen => {
|
||||
const isSelected = selectedIds.includes(allergen.id);
|
||||
const displayName = ALLERGEN_NAMES[allergen.id] || allergen.name;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={allergen.id}
|
||||
className={`flex items-center space-x-3 p-3 rounded-md border cursor-pointer transition-colors min-h-[44px] ${
|
||||
isSelected
|
||||
? 'bg-primary text-white border-primary'
|
||||
: 'bg-white hover:bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggle(allergen.id)}
|
||||
className="hidden"
|
||||
aria-describedby={`allergen-${allergen.id}-description`}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={`flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs font-bold rounded ${
|
||||
isSelected ? 'bg-white text-primary' : 'bg-danger text-white'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{allergen.id}
|
||||
</span>
|
||||
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
<span
|
||||
id={`allergen-${allergen.id}-description`}
|
||||
className="sr-only"
|
||||
>
|
||||
Allergen {allergen.id}: {displayName}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Ausgewählte Allergene:</strong>{' '}
|
||||
{selectedIds.sort().join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
244
frontend/src/components/ProductForm.tsx
Normal file
244
frontend/src/components/ProductForm.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Product, ProductFormData, Allergen, Additive } from '../types';
|
||||
import { AllergenPicker } from './AllergenPicker';
|
||||
import { AdditivePicker } from './AdditivePicker';
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product; // Für Bearbeitung
|
||||
allergens: Allergen[];
|
||||
additives: Additive[];
|
||||
onSubmit: (data: ProductFormData) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ProductForm({
|
||||
product,
|
||||
allergens,
|
||||
additives,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false
|
||||
}: ProductFormProps) {
|
||||
const [formData, setFormData] = useState<ProductFormData>({
|
||||
name: '',
|
||||
multiline: false,
|
||||
allergenIds: [],
|
||||
additiveIds: []
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Initialize form with product data for editing
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormData({
|
||||
name: product.name,
|
||||
multiline: product.multiline,
|
||||
allergenIds: product.allergens?.map(a => a.id) || [],
|
||||
additiveIds: product.additives?.map(a => a.id) || []
|
||||
});
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// Validation
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Produktname ist erforderlich';
|
||||
} else if (formData.name.length > 100) {
|
||||
newErrors.name = 'Produktname darf maximal 100 Zeichen lang sein';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} catch (error) {
|
||||
// Error handling is done in parent component
|
||||
console.error('Fehler beim Speichern des Produkts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
multiline: false,
|
||||
allergenIds: [],
|
||||
additiveIds: []
|
||||
});
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const isEditing = !!product;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEditing ? 'Produkt bearbeiten' : 'Neues Produkt'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
aria-label="Dialog schließen"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Product Name */}
|
||||
<div>
|
||||
<label htmlFor="product-name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Produktname *
|
||||
</label>
|
||||
<input
|
||||
id="product-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className={`input ${errors.name ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
|
||||
placeholder="z.B. Vollkornbrot mit Käse"
|
||||
maxLength={100}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
required
|
||||
/>
|
||||
{errors.name && (
|
||||
<p id="name-error" className="mt-1 text-sm text-red-600" role="alert">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{formData.name.length}/100 Zeichen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Multiline Option */}
|
||||
<div>
|
||||
<label className="flex items-center space-x-3 cursor-pointer min-h-[44px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.multiline}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, multiline: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary bg-gray-100 border-gray-300 rounded focus:ring-primary focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Mehrzeiliges Produkt
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-gray-500 ml-7">
|
||||
Aktivieren, wenn das Produkt in mehreren Zeilen angezeigt werden soll
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Allergens */}
|
||||
<AllergenPicker
|
||||
allergens={allergens}
|
||||
selectedIds={formData.allergenIds}
|
||||
onChange={(ids) => setFormData(prev => ({ ...prev, allergenIds: ids }))}
|
||||
/>
|
||||
|
||||
{/* Additives */}
|
||||
<AdditivePicker
|
||||
additives={additives}
|
||||
selectedIds={formData.additiveIds}
|
||||
onChange={(ids) => setFormData(prev => ({ ...prev, additiveIds: ids }))}
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
{formData.name.trim() && (
|
||||
<div className="bg-gray-50 p-4 rounded-md border">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Vorschau</h4>
|
||||
<div className="bg-white p-3 rounded border">
|
||||
<div className="font-medium">{formData.name}</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{formData.allergenIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{formData.allergenIds.sort().map(id => (
|
||||
<span key={id} className="allergen-badge bg-danger">
|
||||
{id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{formData.additiveIds.length > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
Zusatzstoffe: {formData.additiveIds.sort((a, b) => {
|
||||
const aNum = parseInt(a);
|
||||
const bNum = parseInt(b);
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
|
||||
return a.localeCompare(b);
|
||||
}).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Speichern...
|
||||
</span>
|
||||
) : (
|
||||
isEditing ? 'Änderungen speichern' : 'Produkt erstellen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
frontend/src/components/ProductList.tsx
Normal file
293
frontend/src/components/ProductList.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useState } from 'react';
|
||||
import { Product, Allergen, Additive } from '../types';
|
||||
import { AllergenList } from './AllergenBadge';
|
||||
import { ProductForm } from './ProductForm';
|
||||
|
||||
interface ProductListProps {
|
||||
products: Product[];
|
||||
allergens: Allergen[];
|
||||
additives: Additive[];
|
||||
onCreateProduct: (data: any) => Promise<void>;
|
||||
onUpdateProduct: (id: number, data: any) => Promise<void>;
|
||||
onDeleteProduct: (id: number) => Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ProductList({
|
||||
products,
|
||||
allergens,
|
||||
additives,
|
||||
onCreateProduct,
|
||||
onUpdateProduct,
|
||||
onDeleteProduct,
|
||||
loading = false
|
||||
}: ProductListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [deletingProductId, setDeletingProductId] = useState<number | null>(null);
|
||||
|
||||
// Filter products based on search
|
||||
const filteredProducts = products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
product.allergens?.some(a =>
|
||||
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
a.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) ||
|
||||
product.additives?.some(a =>
|
||||
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
a.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// Handle create product
|
||||
const handleCreateProduct = async (data: any) => {
|
||||
try {
|
||||
await onCreateProduct(data);
|
||||
setShowForm(false);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Produkts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle update product
|
||||
const handleUpdateProduct = async (data: any) => {
|
||||
if (!editingProduct) return;
|
||||
|
||||
try {
|
||||
await onUpdateProduct(editingProduct.id, data);
|
||||
setEditingProduct(null);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Bearbeiten des Produkts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete product with confirmation
|
||||
const handleDeleteProduct = async (product: Product) => {
|
||||
if (deletingProductId === product.id) {
|
||||
// Second click - confirm deletion
|
||||
try {
|
||||
await onDeleteProduct(product.id);
|
||||
setDeletingProductId(null);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Produkts:', error);
|
||||
setDeletingProductId(null);
|
||||
}
|
||||
} else {
|
||||
// First click - show confirmation
|
||||
setDeletingProductId(product.id);
|
||||
|
||||
// Auto-cancel after 3 seconds
|
||||
setTimeout(() => {
|
||||
setDeletingProductId(prev => prev === product.id ? null : prev);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProduct = (product: Product) => {
|
||||
setEditingProduct(product);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingProduct(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Produktverwaltung
|
||||
</h1>
|
||||
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neues Produkt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-md">
|
||||
<label htmlFor="product-search" className="sr-only">
|
||||
Produkte durchsuchen
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="product-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Produkte suchen..."
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{searchQuery && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
{filteredProducts.length} von {products.length} Produkten
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Products Table */}
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
{products.length === 0 ? (
|
||||
<div>
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Keine Produkte</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Erstellen Sie Ihr erstes Produkt, um zu beginnen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Keine Suchergebnisse</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Versuchen Sie es mit anderen Suchbegriffen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Produkt
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Allergene
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Zusatzstoffe
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Typ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredProducts.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {product.id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<AllergenList
|
||||
allergens={product.allergens || []}
|
||||
size="sm"
|
||||
maxVisible={5}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{product.additives?.length > 0 ? (
|
||||
<span className="text-xs text-gray-600 font-mono">
|
||||
{product.additives.map(a => a.id).sort().join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
product.multiline
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{product.multiline ? 'Mehrzeilig' : 'Einzeilig'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-medium space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditProduct(product)}
|
||||
className="text-primary hover:text-blue-700 focus:outline-none focus:underline"
|
||||
aria-label={`${product.name} bearbeiten`}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product)}
|
||||
className={`focus:outline-none focus:underline ${
|
||||
deletingProductId === product.id
|
||||
? 'text-red-600 hover:text-red-800 font-medium'
|
||||
: 'text-gray-600 hover:text-red-600'
|
||||
}`}
|
||||
aria-label={
|
||||
deletingProductId === product.id
|
||||
? `${product.name} wirklich löschen`
|
||||
: `${product.name} löschen`
|
||||
}
|
||||
>
|
||||
{deletingProductId === product.id ? 'Bestätigen?' : 'Löschen'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-gray-900">{products.length}</div>
|
||||
<div className="text-sm text-gray-600">Produkte gesamt</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{products.filter(p => (p.allergens?.length || 0) > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Mit Allergenen</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{products.filter(p => (p.additives?.length || 0) > 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Mit Zusatzstoffen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Form Modal */}
|
||||
{(showForm || editingProduct) && (
|
||||
<ProductForm
|
||||
product={editingProduct || undefined}
|
||||
allergens={allergens}
|
||||
additives={additives}
|
||||
onSubmit={editingProduct ? handleUpdateProduct : handleCreateProduct}
|
||||
onCancel={handleCancelEdit}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
frontend/src/components/ProductSearch.tsx
Normal file
243
frontend/src/components/ProductSearch.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Product } from '../types';
|
||||
import { AllergenList } from './AllergenBadge';
|
||||
|
||||
interface ProductSearchProps {
|
||||
products: Product[];
|
||||
onSelect: (product: Product) => void;
|
||||
onCustomText?: (text: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
allowCustom?: boolean;
|
||||
}
|
||||
|
||||
export function ProductSearch({
|
||||
products,
|
||||
onSelect,
|
||||
onCustomText,
|
||||
placeholder = 'Produkt suchen oder eingeben...',
|
||||
className = '',
|
||||
allowCustom = true
|
||||
}: ProductSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Filter products based on search query
|
||||
const filteredProducts = query.trim()
|
||||
? products.filter(product =>
|
||||
product.name.toLowerCase().includes(query.toLowerCase())
|
||||
).slice(0, 10) // Limit to 10 results for performance
|
||||
: [];
|
||||
|
||||
// Handle product selection
|
||||
const handleSelect = (product: Product) => {
|
||||
setQuery(product.name);
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
onSelect(product);
|
||||
};
|
||||
|
||||
// Handle custom text entry
|
||||
const handleCustomEntry = () => {
|
||||
if (query.trim() && allowCustom && onCustomText) {
|
||||
onCustomText(query.trim());
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < filteredProducts.length - (allowCustom && query.trim() ? 0 : 1)
|
||||
? prev + 1
|
||||
: prev
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > -1 ? prev - 1 : prev);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < filteredProducts.length) {
|
||||
handleSelect(filteredProducts[selectedIndex]);
|
||||
} else if (selectedIndex === filteredProducts.length && allowCustom && query.trim()) {
|
||||
handleCustomEntry();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && listRef.current) {
|
||||
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
|
||||
selectedElement?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const showResults = isOpen && (filteredProducts.length > 0 || (allowCustom && query.trim()));
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} role="combobox" aria-expanded={isOpen}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Produktsuche"
|
||||
/>
|
||||
|
||||
{showResults && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
aria-label="Suchergebnisse"
|
||||
>
|
||||
{filteredProducts.map((product, index) => (
|
||||
<li
|
||||
key={product.id}
|
||||
className={`px-3 py-2 cursor-pointer border-b border-gray-100 last:border-b-0 ${
|
||||
index === selectedIndex ? 'bg-primary text-white' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => handleSelect(product)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{product.name}</span>
|
||||
{product.allergens?.length > 0 && (
|
||||
<AllergenList
|
||||
allergens={product.allergens}
|
||||
size="sm"
|
||||
maxVisible={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.additives?.length > 0 && (
|
||||
<div className="text-xs mt-1 opacity-75">
|
||||
Zusatzstoffe: {product.additives.map(a => a.id).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{allowCustom && query.trim() && (
|
||||
<li
|
||||
className={`px-3 py-2 cursor-pointer border-t border-gray-200 italic ${
|
||||
selectedIndex === filteredProducts.length
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === filteredProducts.length}
|
||||
onClick={handleCustomEntry}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">✏️</span>
|
||||
Als Freitext eingeben: "{query}"
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{filteredProducts.length === 0 && (!allowCustom || !query.trim()) && (
|
||||
<li className="px-3 py-2 text-gray-500 italic">
|
||||
Keine Produkte gefunden
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vereinfachte Version für reine Anzeige
|
||||
interface ProductDisplayProps {
|
||||
product: Product;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProductDisplay({ product, onRemove, className = '' }: ProductDisplayProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between p-2 bg-white border border-gray-200 rounded-md ${className}`}>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{product.name}</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{product.allergens?.length > 0 && (
|
||||
<AllergenList allergens={product.allergens} size="sm" maxVisible={5} />
|
||||
)}
|
||||
|
||||
{product.additives?.length > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Zusatzstoffe: {product.additives.map(a => a.id).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-red-600 focus:text-red-600"
|
||||
aria-label={`${product.name} entfernen`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
frontend/src/hooks/useProducts.ts
Normal file
234
frontend/src/hooks/useProducts.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Product, Allergen, Additive, ProductFormData } from '../types';
|
||||
|
||||
// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein)
|
||||
// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert
|
||||
import { GetProducts, GetProduct, CreateProduct, UpdateProduct, DeleteProduct, GetAllergens, GetAdditives } from '../../wailsjs/go/main/App';
|
||||
|
||||
export function useProducts() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [allergens, setAllergens] = useState<Allergen[]>([]);
|
||||
const [additives, setAdditives] = useState<Additive[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Alle Produkte laden
|
||||
const loadProducts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const productList = await GetProducts();
|
||||
setProducts(productList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Produkte');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stammdaten laden (Allergene und Zusatzstoffe)
|
||||
const loadMasterData = useCallback(async () => {
|
||||
try {
|
||||
const [allergenList, additiveList] = await Promise.all([
|
||||
GetAllergens(),
|
||||
GetAdditives()
|
||||
]);
|
||||
|
||||
setAllergens(allergenList);
|
||||
setAdditives(additiveList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Stammdaten');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Einzelnes Produkt laden
|
||||
const getProduct = async (id: number): Promise<Product | null> => {
|
||||
try {
|
||||
const product = await GetProduct(id);
|
||||
return product;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden des Produkts');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Neues Produkt erstellen
|
||||
const createProduct = async (data: ProductFormData): Promise<Product | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newProduct = await CreateProduct(
|
||||
data.name,
|
||||
data.multiline,
|
||||
data.allergenIds,
|
||||
data.additiveIds
|
||||
);
|
||||
|
||||
setProducts(prev => [...prev, newProduct]);
|
||||
return newProduct;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Produkts');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Produkt bearbeiten
|
||||
const updateProduct = async (id: number, data: ProductFormData): Promise<Product | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedProduct = await UpdateProduct(
|
||||
id,
|
||||
data.name,
|
||||
data.multiline,
|
||||
data.allergenIds,
|
||||
data.additiveIds
|
||||
);
|
||||
|
||||
setProducts(prev => prev.map(p => p.id === id ? updatedProduct : p));
|
||||
return updatedProduct;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Bearbeiten des Produkts');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Produkt löschen
|
||||
const deleteProduct = async (id: number): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await DeleteProduct(id);
|
||||
setProducts(prev => prev.filter(p => p.id !== id));
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Löschen des Produkts');
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Produkte durchsuchen
|
||||
const searchProducts = (query: string): Product[] => {
|
||||
if (!query.trim()) return products;
|
||||
|
||||
const searchTerm = query.toLowerCase();
|
||||
return products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchTerm) ||
|
||||
product.allergens.some(a => a.name.toLowerCase().includes(searchTerm)) ||
|
||||
product.additives.some(a => a.name.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
};
|
||||
|
||||
// Produkte nach Allergenen filtern
|
||||
const filterByAllergen = (allergenId: string): Product[] => {
|
||||
return products.filter(product =>
|
||||
product.allergens.some(a => a.id === allergenId)
|
||||
);
|
||||
};
|
||||
|
||||
// Produkte nach Zusatzstoffen filtern
|
||||
const filterByAdditive = (additiveId: string): Product[] => {
|
||||
return products.filter(product =>
|
||||
product.additives.some(a => a.id === additiveId)
|
||||
);
|
||||
};
|
||||
|
||||
// Allergen nach ID suchen
|
||||
const getAllergenById = (id: string): Allergen | undefined => {
|
||||
return allergens.find(a => a.id === id);
|
||||
};
|
||||
|
||||
// Zusatzstoff nach ID suchen
|
||||
const getAdditiveById = (id: string): Additive | undefined => {
|
||||
return additives.find(a => a.id === id);
|
||||
};
|
||||
|
||||
// Prüfen ob Produkt Allergene enthält
|
||||
const hasAllergens = (product: Product): boolean => {
|
||||
return product.allergens && product.allergens.length > 0;
|
||||
};
|
||||
|
||||
// Prüfen ob Produkt Zusatzstoffe enthält
|
||||
const hasAdditives = (product: Product): boolean => {
|
||||
return product.additives && product.additives.length > 0;
|
||||
};
|
||||
|
||||
// Initial laden
|
||||
useEffect(() => {
|
||||
loadProducts();
|
||||
loadMasterData();
|
||||
}, [loadProducts, loadMasterData]);
|
||||
|
||||
return {
|
||||
// State
|
||||
products,
|
||||
allergens,
|
||||
additives,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Aktionen
|
||||
loadProducts,
|
||||
loadMasterData,
|
||||
getProduct,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
|
||||
// Suche/Filter
|
||||
searchProducts,
|
||||
filterByAllergen,
|
||||
filterByAdditive,
|
||||
|
||||
// Helper
|
||||
getAllergenById,
|
||||
getAdditiveById,
|
||||
hasAllergens,
|
||||
hasAdditives,
|
||||
|
||||
// Clear error
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
}
|
||||
|
||||
// Separater Hook für Autocomplete/Search
|
||||
export function useProductSearch(initialQuery = '') {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||
const { products, searchProducts } = useProducts();
|
||||
|
||||
const results = searchProducts(query);
|
||||
const selectedProduct = selectedProductId
|
||||
? products.find(p => p.id === selectedProductId)
|
||||
: null;
|
||||
|
||||
const selectProduct = (product: Product) => {
|
||||
setSelectedProductId(product.id);
|
||||
setQuery(product.name);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedProductId(null);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
results,
|
||||
selectedProduct,
|
||||
selectProduct,
|
||||
clearSelection,
|
||||
hasSelection: selectedProductId !== null
|
||||
};
|
||||
}
|
||||
224
frontend/src/hooks/useWeekPlan.ts
Normal file
224
frontend/src/hooks/useWeekPlan.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { WeekPlan, PlanEntry, SpecialDay, MealType, WeekDay, GroupLabel } from '../types';
|
||||
|
||||
// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein)
|
||||
// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert
|
||||
import { GetWeekPlan, CreateWeekPlan, CopyWeekPlan, AddPlanEntry, RemovePlanEntry, UpdatePlanEntry, SetSpecialDay, RemoveSpecialDay } from '../../wailsjs/go/main/App';
|
||||
|
||||
export function useWeekPlan(year: number, week: number) {
|
||||
const [weekPlan, setWeekPlan] = useState<WeekPlan | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Wochenplan laden
|
||||
const loadWeekPlan = useCallback(async () => {
|
||||
if (!year || !week) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const plan = await GetWeekPlan(year, week);
|
||||
setWeekPlan(plan);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden des Wochenplans');
|
||||
setWeekPlan(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, week]);
|
||||
|
||||
// Neuen Wochenplan erstellen
|
||||
const createWeekPlan = async (): Promise<WeekPlan | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newPlan = await CreateWeekPlan(year, week);
|
||||
setWeekPlan(newPlan);
|
||||
return newPlan;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Wochenplans');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Wochenplan kopieren
|
||||
const copyWeekPlan = async (srcYear: number, srcWeek: number): Promise<WeekPlan | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const copiedPlan = await CopyWeekPlan(srcYear, srcWeek, year, week);
|
||||
setWeekPlan(copiedPlan);
|
||||
return copiedPlan;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Kopieren des Wochenplans');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Eintrag hinzufügen
|
||||
const addEntry = async (
|
||||
day: WeekDay,
|
||||
meal: MealType,
|
||||
productId?: number,
|
||||
customText?: string,
|
||||
groupLabel?: GroupLabel
|
||||
): Promise<PlanEntry | null> => {
|
||||
if (!weekPlan) return null;
|
||||
|
||||
try {
|
||||
const newEntry = await AddPlanEntry(weekPlan.id, day, meal, productId, customText, groupLabel);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
entries: [...prev.entries, newEntry]
|
||||
} : null);
|
||||
|
||||
return newEntry;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Hinzufügen des Eintrags');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Eintrag entfernen
|
||||
const removeEntry = async (entryId: number): Promise<boolean> => {
|
||||
try {
|
||||
await RemovePlanEntry(entryId);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
entries: prev.entries.filter(e => e.id !== entryId)
|
||||
} : null);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Entfernen des Eintrags');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Eintrag bearbeiten
|
||||
const updateEntry = async (
|
||||
entryId: number,
|
||||
day: WeekDay,
|
||||
meal: MealType,
|
||||
slot: number,
|
||||
productId?: number,
|
||||
customText?: string,
|
||||
groupLabel?: GroupLabel
|
||||
): Promise<PlanEntry | null> => {
|
||||
try {
|
||||
const updatedEntry = await UpdatePlanEntry(entryId, day, meal, slot, productId, customText, groupLabel);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
entries: prev.entries.map(e => e.id === entryId ? updatedEntry : e)
|
||||
} : null);
|
||||
|
||||
return updatedEntry;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Bearbeiten des Eintrags');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Sondertag setzen
|
||||
const setSpecialDay = async (day: WeekDay, type: string, label?: string): Promise<boolean> => {
|
||||
if (!weekPlan) return false;
|
||||
|
||||
try {
|
||||
await SetSpecialDay(weekPlan.id, day, type, label);
|
||||
|
||||
// State aktualisieren
|
||||
const newSpecialDay: SpecialDay = {
|
||||
id: Date.now(), // Temporäre ID - wird vom Backend überschrieben
|
||||
week_plan_id: weekPlan.id,
|
||||
day,
|
||||
type,
|
||||
label
|
||||
};
|
||||
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
special_days: [...prev.special_days.filter(s => s.day !== day), newSpecialDay]
|
||||
} : null);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Setzen des Sondertags');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Sondertag entfernen
|
||||
const removeSpecialDay = async (day: WeekDay): Promise<boolean> => {
|
||||
if (!weekPlan) return false;
|
||||
|
||||
try {
|
||||
await RemoveSpecialDay(weekPlan.id, day);
|
||||
|
||||
// State aktualisieren
|
||||
setWeekPlan(prev => prev ? {
|
||||
...prev,
|
||||
special_days: prev.special_days.filter(s => s.day !== day)
|
||||
} : null);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Entfernen des Sondertags');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper-Funktionen für UI
|
||||
const getEntriesForDay = (day: WeekDay, meal: MealType): PlanEntry[] => {
|
||||
if (!weekPlan) return [];
|
||||
return weekPlan.entries
|
||||
.filter(e => e.day === day && e.meal === meal)
|
||||
.sort((a, b) => a.slot - b.slot);
|
||||
};
|
||||
|
||||
const getSpecialDay = (day: WeekDay): SpecialDay | undefined => {
|
||||
if (!weekPlan) return undefined;
|
||||
return weekPlan.special_days.find(s => s.day === day);
|
||||
};
|
||||
|
||||
const isDaySpecial = (day: WeekDay): boolean => {
|
||||
return !!getSpecialDay(day);
|
||||
};
|
||||
|
||||
// Initial laden
|
||||
useEffect(() => {
|
||||
loadWeekPlan();
|
||||
}, [loadWeekPlan]);
|
||||
|
||||
return {
|
||||
weekPlan,
|
||||
loading,
|
||||
error,
|
||||
loadWeekPlan,
|
||||
createWeekPlan,
|
||||
copyWeekPlan,
|
||||
addEntry,
|
||||
removeEntry,
|
||||
updateEntry,
|
||||
setSpecialDay,
|
||||
removeSpecialDay,
|
||||
// Helper
|
||||
getEntriesForDay,
|
||||
getSpecialDay,
|
||||
isDaySpecial,
|
||||
// Clear error
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
}
|
||||
123
frontend/src/lib/weekHelper.ts
Normal file
123
frontend/src/lib/weekHelper.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Utility-Funktionen für Kalenderwochen-Berechnungen
|
||||
* Deutsche Kalenderwoche-Standards (ISO 8601)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Berechnet die aktuelle Kalenderwoche
|
||||
*/
|
||||
export function getCurrentWeek(): { year: number; week: number } {
|
||||
const now = new Date();
|
||||
return getWeekFromDate(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Kalenderwoche aus einem Datum
|
||||
*/
|
||||
export function getWeekFromDate(date: Date): { year: number; week: number } {
|
||||
const tempDate = new Date(date.valueOf());
|
||||
const dayNum = (tempDate.getDay() + 6) % 7; // Montag = 0
|
||||
|
||||
tempDate.setDate(tempDate.getDate() - dayNum + 3);
|
||||
const firstThursday = tempDate.valueOf();
|
||||
tempDate.setMonth(0, 1);
|
||||
|
||||
if (tempDate.getDay() !== 4) {
|
||||
tempDate.setMonth(0, 1 + ((4 - tempDate.getDay()) + 7) % 7);
|
||||
}
|
||||
|
||||
const week = 1 + Math.ceil((firstThursday - tempDate.valueOf()) / 604800000); // 7 * 24 * 3600 * 1000
|
||||
|
||||
return {
|
||||
year: tempDate.getFullYear(),
|
||||
week: week
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet das erste Datum einer Kalenderwoche
|
||||
*/
|
||||
export function getDateFromWeek(year: number, week: number): Date {
|
||||
const date = new Date(year, 0, 1);
|
||||
const dayOfWeek = date.getDay();
|
||||
const daysToMonday = dayOfWeek <= 4 ? dayOfWeek - 1 : dayOfWeek - 8;
|
||||
|
||||
date.setDate(date.getDate() - daysToMonday);
|
||||
date.setDate(date.getDate() + (week - 1) * 7);
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet alle Tage einer Kalenderwoche (Mo-Fr)
|
||||
*/
|
||||
export function getWeekDays(year: number, week: number): Date[] {
|
||||
const monday = getDateFromWeek(year, week);
|
||||
const days: Date[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) { // Nur Mo-Fr
|
||||
const day = new Date(monday);
|
||||
day.setDate(monday.getDate() + i);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert ein Datum für die Anzeige (DD.MM.)
|
||||
*/
|
||||
export function formatDateShort(date: Date): string {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
return `${day}.${month}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigiert zur nächsten Kalenderwoche
|
||||
*/
|
||||
export function getNextWeek(year: number, week: number): { year: number; week: number } {
|
||||
const weeksInYear = getWeeksInYear(year);
|
||||
|
||||
if (week < weeksInYear) {
|
||||
return { year, week: week + 1 };
|
||||
} else {
|
||||
return { year: year + 1, week: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigiert zur vorherigen Kalenderwoche
|
||||
*/
|
||||
export function getPrevWeek(year: number, week: number): { year: number; week: number } {
|
||||
if (week > 1) {
|
||||
return { year, week: week - 1 };
|
||||
} else {
|
||||
const prevYear = year - 1;
|
||||
const weeksInPrevYear = getWeeksInYear(prevYear);
|
||||
return { year: prevYear, week: weeksInPrevYear };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Anzahl Kalenderwochen in einem Jahr
|
||||
*/
|
||||
export function getWeeksInYear(year: number): number {
|
||||
const dec31 = new Date(year, 11, 31);
|
||||
const week = getWeekFromDate(dec31);
|
||||
return week.year === year ? week.week : week.week - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Kalenderwoche für Anzeige
|
||||
*/
|
||||
export function formatWeek(year: number, week: number): string {
|
||||
return `KW ${week} ${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob eine Kalenderwoche existiert
|
||||
*/
|
||||
export function isValidWeek(year: number, week: number): boolean {
|
||||
return week >= 1 && week <= getWeeksInYear(year);
|
||||
}
|
||||
14
frontend/src/main.tsx
Normal file
14
frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import './style.css'
|
||||
import App from './App'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
const root = createRoot(container!)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
)
|
||||
26
frontend/src/style.css
Normal file
26
frontend/src/style.css
Normal file
@@ -0,0 +1,26 @@
|
||||
html {
|
||||
background-color: rgba(27, 38, 54, 1);
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(""),
|
||||
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
75
frontend/src/styles/globals.css
Normal file
75
frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,75 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Focus indicators für Barrierefreiheit */
|
||||
*:focus {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip-to-content für Screenreader */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 6px;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-base font-medium rounded-md border border-transparent focus:outline-none focus:ring-2 focus:ring-offset-2 min-h-[44px] min-w-[44px];
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary text-white hover:bg-blue-700 focus:ring-primary;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger text-white hover:bg-red-700 focus:ring-danger;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 text-base border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary focus:border-primary min-h-[44px];
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.allergen-badge {
|
||||
@apply inline-flex items-center px-2 py-1 text-xs font-medium rounded text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-contrast-aa {
|
||||
color: #1f2937; /* Mindestens 4.5:1 Kontrast auf weißem Hintergrund */
|
||||
}
|
||||
}
|
||||
102
frontend/src/types/index.ts
Normal file
102
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// TypeScript-Interfaces basierend auf Go-Models
|
||||
|
||||
export interface Allergen {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface Additive {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
multiline: boolean;
|
||||
allergens: Allergen[];
|
||||
additives: Additive[];
|
||||
}
|
||||
|
||||
export interface WeekPlan {
|
||||
id: number;
|
||||
year: number;
|
||||
week: number;
|
||||
created_at: string;
|
||||
entries: PlanEntry[];
|
||||
special_days: SpecialDay[];
|
||||
}
|
||||
|
||||
export interface PlanEntry {
|
||||
id: number;
|
||||
week_plan_id: number;
|
||||
day: number; // 1=Montag, 2=Dienstag, ..., 5=Freitag
|
||||
meal: string; // 'fruehstueck' | 'vesper'
|
||||
slot: number;
|
||||
product_id?: number;
|
||||
product?: Product;
|
||||
custom_text?: string;
|
||||
group_label?: string; // 'Krippe' | 'Kita' | 'Hort'
|
||||
}
|
||||
|
||||
export interface SpecialDay {
|
||||
id: number;
|
||||
week_plan_id: number;
|
||||
day: number; // 1-5 (Mo-Fr)
|
||||
type: string; // 'feiertag' | 'schliesstag'
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
available: boolean;
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
download_url?: string;
|
||||
release_notes?: string;
|
||||
}
|
||||
|
||||
// UI-spezifische Types
|
||||
export type MealType = 'fruehstueck' | 'vesper';
|
||||
export type GroupLabel = 'Krippe' | 'Kita' | 'Hort';
|
||||
export type SpecialDayType = 'feiertag' | 'schliesstag';
|
||||
export type WeekDay = 1 | 2 | 3 | 4 | 5; // Mo-Fr
|
||||
|
||||
// Navigation
|
||||
export type NavRoute = 'wochenplan' | 'produkte' | 'info';
|
||||
|
||||
// Form States
|
||||
export interface ProductFormData {
|
||||
name: string;
|
||||
multiline: boolean;
|
||||
allergenIds: string[];
|
||||
additiveIds: string[];
|
||||
}
|
||||
|
||||
export interface AddEntryFormData {
|
||||
meal: MealType;
|
||||
productId?: number;
|
||||
customText?: string;
|
||||
groupLabel?: GroupLabel;
|
||||
}
|
||||
|
||||
// Konstanten für Deutsche Texte
|
||||
export const DAY_NAMES: Record<WeekDay, string> = {
|
||||
1: 'Montag',
|
||||
2: 'Dienstag',
|
||||
3: 'Mittwoch',
|
||||
4: 'Donnerstag',
|
||||
5: 'Freitag'
|
||||
};
|
||||
|
||||
export const MEAL_NAMES: Record<MealType, string> = {
|
||||
fruehstueck: 'Frühstück',
|
||||
vesper: 'Vesper'
|
||||
};
|
||||
|
||||
export const GROUP_LABELS: GroupLabel[] = ['Krippe', 'Kita', 'Hort'];
|
||||
|
||||
export const SPECIAL_DAY_NAMES: Record<SpecialDayType, string> = {
|
||||
feiertag: 'Feiertag',
|
||||
schliesstag: 'Schließtag'
|
||||
};
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
frontend/tailwind.config.js
Normal file
20
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#2563EB',
|
||||
danger: '#DC2626',
|
||||
success: '#059669',
|
||||
},
|
||||
fontSize: {
|
||||
'base': ['16px', '24px'], // Mindestens 16px für Barrierefreiheit
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
Reference in New Issue
Block a user