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

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