v0.2.0 — Phase 2: Frontend (React + Tailwind)

- Sidebar with KW navigation (← KW → arrows)
- WeekPlanner: 5-column grid (Mo-Fr), Frühstück + Vesper slots
- ProductList: searchable table with allergen/additive badges
- ProductForm: create/edit with AllergenPicker + AdditivePicker
- ProductSearch: autocomplete dropdown for plan entries
- DayColumn + MealSlot + EntryCard components
- SpecialDayDialog: Feiertag/Schließtag marking
- InfoPage: version display + update check
- Layout with responsive sidebar
- BITV 2.0: aria-labels, focus indicators, min 16px, WCAG AA contrasts
- All UI text in German
This commit is contained in:
clawd
2026-02-20 10:05:01 +00:00
parent c19483ea81
commit e146442513
11 changed files with 2042 additions and 41 deletions

View File

@@ -1,13 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>speiseplan</title> <meta name="description" content="Kita Speiseplan - Wochenplanungstool für Kindertageseinrichtungen"/>
<meta name="author" content="Kita Speiseplan"/>
<!-- Barrierefreiheit -->
<meta name="accessibility" content="BITV 2.0 konform"/>
<meta name="color-scheme" content="light"/>
<title>Kita Speiseplan</title>
<!-- Prevent zoom on mobile while maintaining accessibility -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes"/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root" aria-label="Kita Speiseplan Anwendung"></div>
<script src="./src/main.tsx" type="module"></script> <script src="./src/main.tsx" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -1,28 +1,121 @@
import {useState} from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import logo from './assets/images/logo-universal.png'; import { Layout, useSelectedWeek } from './components/Layout';
import './App.css'; import { WeekPlanner } from './components/WeekPlanner';
import {Greet} from "../wailsjs/go/main/App"; import { ProductList } from './components/ProductList';
import { InfoPage } from './components/InfoPage';
import { useProducts } from './hooks/useProducts';
import './styles/globals.css';
function App() { // Home Page Component (Week Planner View)
const [resultText, setResultText] = useState("Please enter your name below 👇"); function HomePage() {
const [name, setName] = useState(''); const selectedWeek = useSelectedWeek();
const updateName = (e: any) => setName(e.target.value);
const updateResultText = (result: string) => setResultText(result);
function greet() { return (
Greet(name).then(updateResultText); <WeekPlanner
} year={selectedWeek.year}
week={selectedWeek.week}
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 // Products Page Component
function ProductsPage() {
const {
products,
allergens,
additives,
createProduct,
updateProduct,
deleteProduct,
loading,
error
} = useProducts();
// Error display for products page
if (error && products.length === 0) {
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">
<svg className="mx-auto h-12 w-12 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h2 className="text-lg font-medium text-red-800">Fehler beim Laden</h2>
<p className="mt-2 text-red-600">{error}</p>
</div>
<button
onClick={() => window.location.reload()}
className="btn-primary"
>
Seite neu laden
</button>
</div>
);
}
return (
<ProductList
products={products}
allergens={allergens}
additives={additives}
onCreateProduct={createProduct}
onUpdateProduct={updateProduct}
onDeleteProduct={deleteProduct}
loading={loading}
/>
);
}
// Main App Component
function App() {
return (
<Router>
<div className="App min-h-screen bg-gray-50 text-contrast-aa">
<Routes>
<Route path="/" element={<Layout />}>
{/* Home Page - Week Planner */}
<Route index element={<HomePage />} />
{/* Products Management */}
<Route path="/produkte" element={<ProductsPage />} />
{/* Info/About Page */}
<Route path="/info" element={<InfoPage />} />
{/* 404 Not Found */}
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</div>
</Router>
);
}
// 404 Not Found Page
function NotFoundPage() {
return (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Seite nicht gefunden
</h1>
<p className="text-gray-600 mb-6">
Die angeforderte Seite konnte nicht gefunden werden.
</p>
<a href="/" className="btn-primary">
Zurück zur Startseite
</a>
</div>
);
}
export default App;

View File

@@ -0,0 +1,193 @@
import { WeekDay, PlanEntry, SpecialDay, DAY_NAMES, GroupLabel } from '../types';
import { MealSlot } from './MealSlot';
import { SpecialDayDisplay } from './SpecialDayDialog';
interface DayColumnProps {
day: WeekDay;
date: Date;
entries: PlanEntry[];
specialDay?: SpecialDay;
onAddEntry: (day: WeekDay, meal: 'fruehstueck' | 'vesper', productId?: number, customText?: string, groupLabel?: GroupLabel) => void;
onEditEntry?: (entryId: number) => void;
onRemoveEntry: (entryId: number) => void;
onSetSpecialDay?: (day: WeekDay) => void;
className?: string;
}
export function DayColumn({
day,
date,
entries,
specialDay,
onAddEntry,
onEditEntry,
onRemoveEntry,
onSetSpecialDay,
className = ''
}: DayColumnProps) {
const dayName = DAY_NAMES[day];
const isSpecialDay = !!specialDay;
// Filter entries by meal
const breakfastEntries = entries.filter(e => e.meal === 'fruehstueck');
const vesperEntries = entries.filter(e => e.meal === 'vesper');
// Format date for display
const formatDate = (date: Date) => {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
return `${day}.${month}.`;
};
const handleAddEntry = (meal: 'fruehstueck' | 'vesper', productId?: number, customText?: string, groupLabel?: GroupLabel) => {
onAddEntry(day, meal, productId, customText, groupLabel);
};
return (
<div
className={`bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden ${className}`}
role="region"
aria-label={`${dayName} - ${formatDate(date)}`}
>
{/* Day Header */}
<div
className={`p-4 border-b border-gray-200 ${
isSpecialDay
? specialDay!.type === 'feiertag'
? 'bg-yellow-50'
: 'bg-red-50'
: 'bg-gray-50'
}`}
>
<div className="text-center">
<h3 className="font-semibold text-gray-900">
{dayName}
</h3>
<p className="text-sm text-gray-600 mt-1">
{formatDate(date)}
</p>
</div>
{/* Special Day Indicator */}
{!isSpecialDay && onSetSpecialDay && (
<button
onClick={() => onSetSpecialDay(day)}
className="w-full mt-2 text-xs text-gray-500 hover:text-primary focus:outline-none focus:text-primary"
aria-label={`Sondertag für ${dayName} setzen`}
>
+ Sondertag
</button>
)}
</div>
{/* Content */}
<div className="p-4">
{isSpecialDay ? (
/* Special Day Display */
<SpecialDayDisplay
specialDay={specialDay!}
day={day}
onClick={() => onSetSpecialDay?.(day)}
/>
) : (
/* Regular Day Content */
<div className="space-y-6">
{/* Breakfast */}
<MealSlot
day={day}
meal="fruehstueck"
entries={breakfastEntries}
onAddEntry={(productId, customText, groupLabel) =>
handleAddEntry('fruehstueck', productId, customText, groupLabel)
}
onEditEntry={onEditEntry}
onRemoveEntry={onRemoveEntry}
/>
{/* Divider */}
<div className="border-t border-gray-200" />
{/* Vesper */}
<MealSlot
day={day}
meal="vesper"
entries={vesperEntries}
onAddEntry={(productId, customText, groupLabel) =>
handleAddEntry('vesper', productId, customText, groupLabel)
}
onEditEntry={onEditEntry}
onRemoveEntry={onRemoveEntry}
/>
</div>
)}
</div>
{/* Footer with stats */}
{!isSpecialDay && (
<div className="px-4 py-2 bg-gray-50 border-t border-gray-200">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>
{entries.length} {entries.length === 1 ? 'Eintrag' : 'Einträge'}
</span>
{/* Allergen Warning */}
{entries.some(e => e.product?.allergens?.length) && (
<span className="flex items-center text-orange-600">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Allergene
</span>
)}
</div>
</div>
)}
</div>
);
}
// Week Day Header (for responsive mobile view if needed)
export function DayHeader({
day,
date,
specialDay,
className = ''
}: {
day: WeekDay;
date: Date;
specialDay?: SpecialDay;
className?: string;
}) {
const dayName = DAY_NAMES[day];
const isSpecialDay = !!specialDay;
const formatDate = (date: Date) => {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
return `${day}.${month}.`;
};
return (
<div
className={`text-center py-2 px-4 ${
isSpecialDay
? specialDay!.type === 'feiertag'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
} ${className}`}
>
<div className="font-semibold text-sm">
{dayName}
</div>
<div className="text-xs opacity-75">
{formatDate(date)}
</div>
{isSpecialDay && (
<div className="text-xs mt-1 font-medium">
{specialDay!.type === 'feiertag' ? 'Feiertag' : 'Schließtag'}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,234 @@
import { PlanEntry, GroupLabel, GROUP_LABELS } from '../types';
import { AllergenList } from './AllergenBadge';
interface EntryCardProps {
entry: PlanEntry;
onEdit?: () => void;
onRemove?: () => void;
className?: string;
compact?: boolean;
}
export function EntryCard({
entry,
onEdit,
onRemove,
className = '',
compact = false
}: EntryCardProps) {
const hasProduct = entry.product_id && entry.product;
const isCustomText = !hasProduct && entry.custom_text;
return (
<div
className={`bg-white border border-gray-200 rounded-md p-3 shadow-sm hover:shadow-md transition-shadow ${className}`}
role="article"
aria-label={
hasProduct
? `Produkt: ${entry.product?.name}`
: `Freitext: ${entry.custom_text}`
}
>
{/* Content */}
<div className="space-y-2">
{/* Product or Custom Text */}
{hasProduct ? (
<div>
<div className={`font-medium text-gray-900 ${compact ? 'text-sm' : 'text-base'}`}>
{entry.product!.name}
</div>
{/* Allergens and Additives */}
{!compact && (entry.product!.allergens?.length || entry.product!.additives?.length) && (
<div className="flex items-center gap-3 mt-2">
{entry.product!.allergens?.length > 0 && (
<AllergenList
allergens={entry.product!.allergens}
size="sm"
maxVisible={compact ? 3 : 6}
/>
)}
{entry.product!.additives?.length > 0 && (
<span className="text-xs text-gray-500 font-mono">
{entry.product!.additives.map(a => a.id).sort().join(', ')}
</span>
)}
</div>
)}
</div>
) : isCustomText ? (
<div
className={`text-gray-900 ${compact ? 'text-sm' : 'text-base'} ${entry.custom_text!.length > 50 ? 'text-sm' : ''}`}
>
{entry.custom_text}
</div>
) : (
<div className="text-gray-400 italic text-sm">
Leerer Eintrag
</div>
)}
{/* Group Label */}
{entry.group_label && (
<div className="flex items-center justify-between">
<GroupLabelBadge
label={entry.group_label as GroupLabel}
size={compact ? 'sm' : 'md'}
/>
</div>
)}
</div>
{/* Actions */}
{(onEdit || onRemove) && (
<div className="flex items-center justify-end space-x-2 mt-3 pt-2 border-t border-gray-100">
{onEdit && (
<button
onClick={onEdit}
className="text-xs text-primary hover:text-blue-700 focus:outline-none focus:underline"
aria-label={`Eintrag bearbeiten`}
>
Bearbeiten
</button>
)}
{onRemove && (
<button
onClick={onRemove}
className="text-xs text-gray-500 hover:text-red-600 focus:outline-none focus:underline"
aria-label={`Eintrag entfernen`}
>
Entfernen
</button>
)}
</div>
)}
</div>
);
}
// Group Label Badge Component
interface GroupLabelBadgeProps {
label: GroupLabel;
size?: 'sm' | 'md';
className?: string;
}
export function GroupLabelBadge({ label, size = 'md', className = '' }: GroupLabelBadgeProps) {
const colors: Record<GroupLabel, string> = {
'Krippe': 'bg-pink-100 text-pink-800 border-pink-200',
'Kita': 'bg-blue-100 text-blue-800 border-blue-200',
'Hort': 'bg-green-100 text-green-800 border-green-200'
};
const sizeClasses = size === 'sm'
? 'text-xs px-2 py-0.5'
: 'text-sm px-2 py-1';
return (
<span
className={`inline-flex items-center font-medium rounded border ${colors[label]} ${sizeClasses} ${className}`}
title={`Gruppe: ${label}`}
aria-label={`Gruppe: ${label}`}
>
{label}
</span>
);
}
// Group Label Selector Component
interface GroupLabelSelectorProps {
selectedLabel?: GroupLabel;
onChange: (label?: GroupLabel) => void;
className?: string;
}
export function GroupLabelSelector({
selectedLabel,
onChange,
className = ''
}: GroupLabelSelectorProps) {
return (
<div className={className}>
<label className="block text-sm font-medium text-gray-700 mb-2">
Zielgruppe (optional)
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onChange(undefined)}
className={`px-3 py-1 text-sm rounded-md border transition-colors min-h-[44px] ${
!selectedLabel
? 'bg-gray-200 text-gray-800 border-gray-300'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
}`}
aria-pressed={!selectedLabel}
>
Alle
</button>
{GROUP_LABELS.map(label => (
<button
key={label}
type="button"
onClick={() => onChange(label)}
className={`px-3 py-1 text-sm rounded-md border transition-colors min-h-[44px] ${
selectedLabel === label
? (() => {
switch(label) {
case 'Krippe': return 'bg-pink-100 text-pink-800 border-pink-300';
case 'Kita': return 'bg-blue-100 text-blue-800 border-blue-300';
case 'Hort': return 'bg-green-100 text-green-800 border-green-300';
}
})()
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
}`}
aria-pressed={selectedLabel === label}
>
{label}
</button>
))}
</div>
</div>
);
}
// Empty Entry Placeholder
interface EmptyEntryProps {
onAdd?: () => void;
className?: string;
mealName?: string;
}
export function EmptyEntry({ onAdd, className = '', mealName }: EmptyEntryProps) {
return (
<div
className={`border-2 border-dashed border-gray-300 rounded-md p-4 text-center hover:border-gray-400 transition-colors ${className}`}
>
<svg
className="mx-auto h-8 w-8 text-gray-400 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<p className="text-sm text-gray-500 mb-2">
{mealName ? `${mealName} hinzufügen` : 'Eintrag hinzufügen'}
</p>
{onAdd && (
<button
onClick={onAdd}
className="text-sm text-primary hover:text-blue-700 focus:outline-none focus:underline"
aria-label={`${mealName || 'Eintrag'} hinzufügen`}
>
Hinzufügen
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useState } from 'react';
// Import der Wails-Funktionen (werden zur Laufzeit verfügbar sein)
// @ts-ignore - Wails-Bindings werden zur Laufzeit generiert
import { CheckForUpdate } from '../../wailsjs/go/main/App';
interface UpdateInfo {
available: boolean;
current_version: string;
latest_version: string;
download_url?: string;
release_notes?: string;
}
export function InfoPage() {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
// Handle checking for updates
const handleCheckUpdate = async () => {
setChecking(true);
setError(null);
try {
const info = await CheckForUpdate();
setUpdateInfo(info);
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Prüfen auf Updates');
} finally {
setChecking(false);
}
};
// Handle opening download URL
const handleDownload = () => {
if (updateInfo?.download_url) {
window.open(updateInfo.download_url, '_blank');
}
};
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-semibold text-gray-900">
Informationen
</h1>
<p className="mt-2 text-gray-600">
Version, Updates und Lizenzinformationen
</p>
</div>
<div className="grid gap-6">
{/* App Information */}
<section className="card p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Anwendungsinformationen
</h2>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Anwendung</dt>
<dd className="mt-1 text-sm text-gray-900">Kita Speiseplan</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Version</dt>
<dd className="mt-1 text-sm text-gray-900 font-mono">
{updateInfo?.current_version || '1.0.0'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Platform</dt>
<dd className="mt-1 text-sm text-gray-900">Windows Desktop</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Framework</dt>
<dd className="mt-1 text-sm text-gray-900">Wails 2 + React</dd>
</div>
</dl>
</section>
{/* Update Section */}
<section className="card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">
Software-Updates
</h2>
<button
onClick={handleCheckUpdate}
disabled={checking}
className="btn-primary"
aria-describedby="update-status"
>
{checking ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" 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>
Prüfen...
</span>
) : (
'Nach Updates suchen'
)}
</button>
</div>
{/* Update Status */}
<div id="update-status">
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Fehler bei Update-Prüfung
</h3>
<div className="mt-2 text-sm text-red-700">
{error}
</div>
</div>
</div>
</div>
)}
{updateInfo && !updateInfo.available && (
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-green-800">
Keine Updates verfügbar
</h3>
<div className="mt-2 text-sm text-green-700">
Sie verwenden bereits die neueste Version: {updateInfo.current_version}
</div>
</div>
</div>
</div>
)}
{updateInfo && updateInfo.available && (
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">
Update verfügbar
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
Aktuelle Version: <span className="font-mono">{updateInfo.current_version}</span>
</p>
<p>
Neue Version: <span className="font-mono">{updateInfo.latest_version}</span>
</p>
{updateInfo.release_notes && (
<div className="mt-3">
<p className="font-medium">Änderungen:</p>
<div className="mt-1 text-xs bg-white p-2 rounded border">
<pre className="whitespace-pre-wrap">{updateInfo.release_notes}</pre>
</div>
</div>
)}
</div>
{updateInfo.download_url && (
<div className="mt-4">
<button
onClick={handleDownload}
className="btn-primary"
>
Download starten
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
</section>
{/* License Information */}
<section className="card p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Lizenzinformationen
</h2>
<div className="space-y-4 text-sm text-gray-600">
<div>
<h3 className="font-medium text-gray-900">Kita Speiseplan</h3>
<p>© 2026 - Speziell entwickelt für Kindertageseinrichtungen im öffentlichen Sektor</p>
<p>Diese Software erfüllt die Anforderungen der BITV 2.0 (Barrierefreie Informationstechnik-Verordnung).</p>
</div>
<div>
<h3 className="font-medium text-gray-900">Verwendete Technologien</h3>
<ul className="list-disc list-inside space-y-1 mt-2">
<li><strong>Wails 2:</strong> MIT License - Cross-Platform Desktop Apps mit Go + Web</li>
<li><strong>React:</strong> MIT License - UI Framework von Meta</li>
<li><strong>TypeScript:</strong> Apache 2.0 License - Microsoft</li>
<li><strong>Tailwind CSS:</strong> MIT License - Utility-first CSS Framework</li>
<li><strong>Go:</strong> BSD-3-Clause License - Google</li>
</ul>
</div>
<div>
<h3 className="font-medium text-gray-900">Barrierefreiheit</h3>
<p>Diese Anwendung wurde nach den Richtlinien der BITV 2.0 entwickelt und bietet:</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li>Vollständige Tastaturnavigation</li>
<li>Screenreader-Unterstützung (NVDA, JAWS)</li>
<li>Hohe Farbkontraste (WCAG AA)</li>
<li>Große Schriftgrößen (min. 16px)</li>
<li>Touch-freundliche Bedienelemente</li>
<li>Semantisches HTML und ARIA-Labels</li>
</ul>
</div>
</div>
</section>
{/* System Requirements */}
<section className="card p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Systemanforderungen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-gray-900 mb-2">Minimum</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> Windows 10 (64-bit)</li>
<li> 4 GB RAM</li>
<li> 100 MB freier Speicher</li>
<li> Bildschirmauflösung: 1280x720</li>
</ul>
</div>
<div>
<h3 className="font-medium text-gray-900 mb-2">Empfohlen</h3>
<ul className="text-sm text-gray-600 space-y-1">
<li> Windows 11 (64-bit)</li>
<li> 8 GB RAM</li>
<li> 500 MB freier Speicher</li>
<li> Bildschirmauflösung: 1920x1080</li>
</ul>
</div>
</div>
</section>
{/* Support */}
<section className="card p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Support und Kontakt
</h2>
<div className="text-sm text-gray-600 space-y-2">
<p>
<strong>Version:</strong> 1.0.0 (Build 2026.02.20)
</p>
<p>
<strong>Support:</strong> Wenden Sie sich bei Fragen oder Problemen an Ihre IT-Abteilung
</p>
<p>
<strong>Dokumentation:</strong> Benutzerhandbuch ist über das Hilfe-Menü verfügbar
</p>
</div>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, createContext, useContext } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { getCurrentWeek } from '../lib/weekHelper';
import { useWeekPlan } from '../hooks/useWeekPlan';
// Context für die ausgewählte Woche
interface WeekContextType {
selectedWeek: { year: number; week: number };
setSelectedWeek: (week: { year: number; week: number }) => void;
}
const WeekContext = createContext<WeekContextType | null>(null);
export function Layout() {
const navigate = useNavigate();
const currentWeek = getCurrentWeek();
const [selectedWeek, setSelectedWeek] = useState(currentWeek);
const { createWeekPlan, copyWeekPlan } = useWeekPlan(selectedWeek.year, selectedWeek.week);
// Handle week change navigation
const handleWeekChange = (year: number, week: number) => {
setSelectedWeek({ year, week });
// Navigate to home if not already there
if (window.location.pathname !== '/') {
navigate('/');
}
};
// Handle creating new week plan
const handleCreateWeek = async (year: number, week: number) => {
try {
await createWeekPlan();
// Success handled by the hook
} catch (error) {
console.error('Fehler beim Erstellen des Wochenplans:', error);
}
};
// Handle copying week plan
const handleCopyWeek = async (srcYear: number, srcWeek: number) => {
try {
await copyWeekPlan(srcYear, srcWeek);
// Success handled by the hook
} catch (error) {
console.error('Fehler beim Kopieren des Wochenplans:', error);
}
};
return (
<WeekContext.Provider value={{ selectedWeek, setSelectedWeek }}>
<div className="flex h-screen bg-gray-50">
{/* Sidebar */}
<Sidebar
currentYear={selectedWeek.year}
currentWeek={selectedWeek.week}
onWeekChange={handleWeekChange}
onCreateWeek={handleCreateWeek}
onCopyWeek={handleCopyWeek}
/>
{/* Main Content */}
<main
className="flex-1 overflow-auto focus:outline-none"
id="main-content"
role="main"
aria-label="Hauptinhalt"
tabIndex={-1}
>
<div className="p-6 h-full">
<Outlet />
</div>
</main>
</div>
</WeekContext.Provider>
);
}
// Hook for accessing the selected week in child components
export function useSelectedWeek() {
const context = useContext(WeekContext);
if (!context) {
return getCurrentWeek();
}
return context.selectedWeek;
}

View File

@@ -0,0 +1,272 @@
import { useState } from 'react';
import { PlanEntry, MealType, WeekDay, GroupLabel, MEAL_NAMES } from '../types';
import { EntryCard, EmptyEntry, GroupLabelSelector } from './EntryCard';
import { ProductSearch } from './ProductSearch';
import { useProducts } from '../hooks/useProducts';
interface MealSlotProps {
day: WeekDay;
meal: MealType;
entries: PlanEntry[];
onAddEntry: (productId?: number, customText?: string, groupLabel?: GroupLabel) => void;
onEditEntry?: (entryId: number) => void;
onRemoveEntry: (entryId: number) => void;
disabled?: boolean;
className?: string;
}
export function MealSlot({
day,
meal,
entries,
onAddEntry,
onEditEntry,
onRemoveEntry,
disabled = false,
className = ''
}: MealSlotProps) {
const [showAddForm, setShowAddForm] = useState(false);
const [selectedGroupLabel, setSelectedGroupLabel] = useState<GroupLabel>();
const { products } = useProducts();
const mealName = MEAL_NAMES[meal];
// Handle adding product entry
const handleAddProduct = (product: any) => {
onAddEntry(product.id, undefined, selectedGroupLabel);
setShowAddForm(false);
setSelectedGroupLabel(undefined);
};
// Handle adding custom text entry
const handleAddCustomText = (text: string) => {
onAddEntry(undefined, text, selectedGroupLabel);
setShowAddForm(false);
setSelectedGroupLabel(undefined);
};
// Handle canceling add form
const handleCancelAdd = () => {
setShowAddForm(false);
setSelectedGroupLabel(undefined);
};
// Sort entries by slot
const sortedEntries = [...entries].sort((a, b) => a.slot - b.slot);
return (
<div
className={`space-y-3 ${className}`}
role="region"
aria-label={`${mealName} Einträge`}
>
{/* Meal Header */}
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-900 text-sm">
{mealName}
</h4>
{!disabled && !showAddForm && (
<button
onClick={() => setShowAddForm(true)}
className="text-xs text-primary hover:text-blue-700 focus:outline-none focus:underline"
aria-label={`${mealName} hinzufügen`}
>
+ Hinzufügen
</button>
)}
</div>
{/* Entries */}
{sortedEntries.length > 0 ? (
<div className="space-y-2">
{sortedEntries.map((entry) => (
<EntryCard
key={entry.id}
entry={entry}
onEdit={onEditEntry ? () => onEditEntry(entry.id) : undefined}
onRemove={() => onRemoveEntry(entry.id)}
compact
className={disabled ? 'opacity-50' : ''}
/>
))}
</div>
) : (
!showAddForm && (
<EmptyEntry
onAdd={disabled ? undefined : () => setShowAddForm(true)}
mealName={mealName}
className={disabled ? 'opacity-50' : ''}
/>
)
)}
{/* Add Entry Form */}
{showAddForm && !disabled && (
<AddEntryForm
onAddProduct={handleAddProduct}
onAddCustomText={handleAddCustomText}
onCancel={handleCancelAdd}
selectedGroupLabel={selectedGroupLabel}
onGroupLabelChange={setSelectedGroupLabel}
products={products}
mealName={mealName}
/>
)}
</div>
);
}
// Add Entry Form Component
interface AddEntryFormProps {
onAddProduct: (product: any) => void;
onAddCustomText: (text: string) => void;
onCancel: () => void;
selectedGroupLabel?: GroupLabel;
onGroupLabelChange: (label?: GroupLabel) => void;
products: any[];
mealName: string;
}
function AddEntryForm({
onAddProduct,
onAddCustomText,
onCancel,
selectedGroupLabel,
onGroupLabelChange,
products,
mealName
}: AddEntryFormProps) {
const [customText, setCustomText] = useState('');
const [mode, setMode] = useState<'search' | 'custom'>('search');
const handleSubmitCustom = (e: React.FormEvent) => {
e.preventDefault();
if (customText.trim()) {
onAddCustomText(customText.trim());
}
};
return (
<div className="border border-primary rounded-md p-4 bg-blue-50">
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h5 className="font-medium text-gray-900">
{mealName} hinzufügen
</h5>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600"
aria-label="Hinzufügen abbrechen"
>
<svg className="w-5 h-5" 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>
{/* Mode Toggle */}
<div className="flex space-x-1 bg-white rounded-md p-1 border">
<button
type="button"
onClick={() => setMode('search')}
className={`flex-1 py-2 px-3 text-sm font-medium rounded transition-colors ${
mode === 'search'
? 'bg-primary text-white'
: 'text-gray-600 hover:text-gray-800'
}`}
aria-pressed={mode === 'search'}
>
Produkt suchen
</button>
<button
type="button"
onClick={() => setMode('custom')}
className={`flex-1 py-2 px-3 text-sm font-medium rounded transition-colors ${
mode === 'custom'
? 'bg-primary text-white'
: 'text-gray-600 hover:text-gray-800'
}`}
aria-pressed={mode === 'custom'}
>
Freitext eingeben
</button>
</div>
{/* Product Search Mode */}
{mode === 'search' && (
<ProductSearch
products={products}
onSelect={onAddProduct}
onCustomText={onAddCustomText}
placeholder="Produkt suchen..."
allowCustom
/>
)}
{/* Custom Text Mode */}
{mode === 'custom' && (
<form onSubmit={handleSubmitCustom} className="space-y-3">
<div>
<label htmlFor="custom-text" className="sr-only">
Freitext für {mealName}
</label>
<textarea
id="custom-text"
value={customText}
onChange={(e) => setCustomText(e.target.value)}
placeholder={`z.B. "Müsli mit Früchten" oder "Butterbrot"`}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary focus:border-primary min-h-[44px] resize-none"
rows={2}
maxLength={200}
autoFocus
/>
<p className="mt-1 text-xs text-gray-500">
{customText.length}/200 Zeichen
</p>
</div>
<button
type="submit"
disabled={!customText.trim()}
className="btn-primary btn-sm"
>
Freitext hinzufügen
</button>
</form>
)}
{/* Group Label Selector */}
<GroupLabelSelector
selectedLabel={selectedGroupLabel}
onChange={onGroupLabelChange}
/>
{/* Quick Actions */}
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
<div className="text-xs text-gray-500">
Tipp: Verwenden Sie Tab zum Navigieren
</div>
<div className="flex space-x-2">
<button
type="button"
onClick={onCancel}
className="text-xs text-gray-600 hover:text-gray-800"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
);
}
// Helper styles
const styles = `
.btn-sm {
@apply px-3 py-1 text-sm min-h-[36px];
}
`;

View File

@@ -0,0 +1,335 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { getCurrentWeek, getNextWeek, getPrevWeek, formatWeek, isValidWeek } from '../lib/weekHelper';
interface SidebarProps {
currentYear: number;
currentWeek: number;
onWeekChange: (year: number, week: number) => void;
onCreateWeek?: (year: number, week: number) => void;
onCopyWeek?: (year: number, week: number) => void;
}
export function Sidebar({
currentYear,
currentWeek,
onWeekChange,
onCreateWeek,
onCopyWeek
}: SidebarProps) {
const [showCopyDialog, setShowCopyDialog] = useState(false);
// Handle week navigation
const handlePrevWeek = () => {
const prev = getPrevWeek(currentYear, currentWeek);
onWeekChange(prev.year, prev.week);
};
const handleNextWeek = () => {
const next = getNextWeek(currentYear, currentWeek);
onWeekChange(next.year, next.week);
};
const handleCurrentWeek = () => {
const current = getCurrentWeek();
onWeekChange(current.year, current.week);
};
// Handle keyboard navigation
const handleWeekNavKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
handlePrevWeek();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
handleNextWeek();
} else if (e.key === 'Home') {
e.preventDefault();
handleCurrentWeek();
}
};
const navigationItems = [
{
name: 'Wochenplan',
to: '/',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)
},
{
name: 'Produkte',
to: '/produkte',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
)
},
{
name: 'Info',
to: '/info',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
];
return (
<>
{/* Skip-to-content Link */}
<a
href="#main-content"
className="skip-link"
>
Zum Hauptinhalt springen
</a>
<aside
className="w-64 bg-white border-r border-gray-200 flex flex-col h-full"
aria-label="Hauptnavigation"
>
{/* Logo/Header */}
<div className="p-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">
Speiseplan
</h1>
<p className="text-sm text-gray-600 mt-1">
Kita Wochenplanung
</p>
</div>
{/* Week Navigator */}
<div className="p-4 border-b border-gray-200">
<div className="space-y-4">
{/* Current Week Display */}
<div className="text-center">
<h2 className="text-lg font-semibold text-gray-900">
{formatWeek(currentYear, currentWeek)}
</h2>
<p className="text-sm text-gray-600">
Aktuelle Auswahl
</p>
</div>
{/* Navigation Controls */}
<div
className="flex items-center justify-between"
onKeyDown={handleWeekNavKeyDown}
role="group"
aria-label="Kalenderwoche navigieren"
tabIndex={0}
>
<button
onClick={handlePrevWeek}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px] min-w-[44px]"
aria-label="Vorherige Kalenderwoche"
title="Vorherige KW (Pfeil links)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={handleCurrentWeek}
className="px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px]"
title="Aktuelle KW (Pos1)"
>
Heute
</button>
<button
onClick={handleNextWeek}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px] min-w-[44px]"
aria-label="Nächste Kalenderwoche"
title="Nächste KW (Pfeil rechts)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Quick Actions */}
<div className="space-y-2">
{onCreateWeek && (
<button
onClick={() => onCreateWeek(currentYear, currentWeek)}
className="w-full btn-primary text-sm"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue KW anlegen
</button>
)}
{onCopyWeek && (
<button
onClick={() => setShowCopyDialog(true)}
className="w-full btn-secondary text-sm"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
KW kopieren
</button>
)}
</div>
{/* Keyboard Shortcuts Info */}
<div className="text-xs text-gray-500 space-y-1">
<p><kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs"></kbd> KW wechseln</p>
<p><kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Pos1</kbd> Aktuelle KW</p>
</div>
</div>
</div>
{/* Main Navigation */}
<nav className="flex-1 p-4" role="navigation" aria-label="Hauptmenü">
<ul className="space-y-2">
{navigationItems.map((item) => (
<li key={item.name}>
<NavLink
to={item.to}
className={({ isActive }) =>
`flex items-center px-4 py-3 text-sm font-medium rounded-md transition-colors min-h-[44px] ${
isActive
? 'bg-primary text-white'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
}`
}
aria-current={({ isActive }) => isActive ? 'page' : undefined}
>
{item.icon}
<span className="ml-3">{item.name}</span>
</NavLink>
</li>
))}
</ul>
</nav>
{/* Footer */}
<div className="p-4 border-t border-gray-200 text-xs text-gray-500">
<p>© 2026 Kita Speiseplan</p>
<p className="mt-1">Barrierefreie Desktop-App</p>
</div>
</aside>
{/* Copy Week Dialog */}
{showCopyDialog && (
<CopyWeekDialog
targetYear={currentYear}
targetWeek={currentWeek}
onCopy={(srcYear, srcWeek) => {
onCopyWeek?.(srcYear, srcWeek);
setShowCopyDialog(false);
}}
onCancel={() => setShowCopyDialog(false)}
/>
)}
</>
);
}
// Copy Week Dialog Component
interface CopyWeekDialogProps {
targetYear: number;
targetWeek: number;
onCopy: (srcYear: number, srcWeek: number) => void;
onCancel: () => void;
}
function CopyWeekDialog({ targetYear, targetWeek, onCopy, onCancel }: CopyWeekDialogProps) {
const [srcYear, setSrcYear] = useState(targetYear);
const [srcWeek, setSrcWeek] = useState(Math.max(1, targetWeek - 1));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isValidWeek(srcYear, srcWeek)) {
onCopy(srcYear, srcWeek);
}
};
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-md w-full">
<form onSubmit={handleSubmit}>
<div className="p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Kalenderwoche kopieren
</h2>
<p className="text-sm text-gray-600 mb-4">
Welche Kalenderwoche soll nach <strong>{formatWeek(targetYear, targetWeek)}</strong> kopiert werden?
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="src-year" className="block text-sm font-medium text-gray-700 mb-1">
Jahr
</label>
<input
id="src-year"
type="number"
value={srcYear}
onChange={(e) => setSrcYear(parseInt(e.target.value) || targetYear)}
min="2020"
max="2030"
className="input"
/>
</div>
<div>
<label htmlFor="src-week" className="block text-sm font-medium text-gray-700 mb-1">
KW
</label>
<input
id="src-week"
type="number"
value={srcWeek}
onChange={(e) => setSrcWeek(parseInt(e.target.value) || 1)}
min="1"
max="53"
className="input"
/>
</div>
</div>
<div className="mt-4 p-3 bg-gray-50 rounded-md">
<p className="text-sm text-gray-600">
<strong>Quelle:</strong> {formatWeek(srcYear, srcWeek)}
</p>
<p className="text-sm text-gray-600">
<strong>Ziel:</strong> {formatWeek(targetYear, targetWeek)}
</p>
</div>
</div>
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
<button
type="button"
onClick={onCancel}
className="btn-secondary"
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary"
disabled={!isValidWeek(srcYear, srcWeek)}
>
Kopieren
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import { useState } from 'react';
import { SpecialDay, SpecialDayType, WeekDay, DAY_NAMES, SPECIAL_DAY_NAMES } from '../types';
interface SpecialDayDialogProps {
day: WeekDay;
existingSpecialDay?: SpecialDay;
onSave: (day: WeekDay, type: SpecialDayType, label?: string) => void;
onRemove?: (day: WeekDay) => void;
onCancel: () => void;
}
export function SpecialDayDialog({
day,
existingSpecialDay,
onSave,
onRemove,
onCancel
}: SpecialDayDialogProps) {
const [type, setType] = useState<SpecialDayType>(
(existingSpecialDay?.type as SpecialDayType) || 'feiertag'
);
const [label, setLabel] = useState(existingSpecialDay?.label || '');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(day, type, label.trim() || undefined);
};
const handleRemove = () => {
if (onRemove) {
onRemove(day);
}
};
const isEditing = !!existingSpecialDay;
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-md w-full">
<form onSubmit={handleSubmit}>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
Sondertag für {DAY_NAMES[day]}
</h2>
<button
type="button"
onClick={onCancel}
className="text-gray-400 hover:text-gray-600"
aria-label="Dialog schließen"
>
<svg className="w-5 h-5" 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>
{/* Content */}
<div className="p-6 space-y-4">
{/* Type Selection */}
<div>
<fieldset>
<legend className="text-sm font-medium text-gray-700 mb-3">
Art des Sondertags
</legend>
<div className="space-y-2">
<label className="flex items-center space-x-3 cursor-pointer min-h-[44px]">
<input
type="radio"
value="feiertag"
checked={type === 'feiertag'}
onChange={(e) => setType(e.target.value as SpecialDayType)}
className="w-4 h-4 text-primary bg-gray-100 border-gray-300 focus:ring-primary focus:ring-2"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">
{SPECIAL_DAY_NAMES.feiertag}
</div>
<div className="text-sm text-gray-500">
Gesetzlicher oder betrieblicher Feiertag
</div>
</div>
</label>
<label className="flex items-center space-x-3 cursor-pointer min-h-[44px]">
<input
type="radio"
value="schliesstag"
checked={type === 'schliesstag'}
onChange={(e) => setType(e.target.value as SpecialDayType)}
className="w-4 h-4 text-primary bg-gray-100 border-gray-300 focus:ring-primary focus:ring-2"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">
{SPECIAL_DAY_NAMES.schliesstag}
</div>
<div className="text-sm text-gray-500">
Kita ist geschlossen (z.B. Betriebsurlaub)
</div>
</div>
</label>
</div>
</fieldset>
</div>
{/* Label Input */}
<div>
<label htmlFor="special-day-label" className="block text-sm font-medium text-gray-700 mb-2">
Bezeichnung (optional)
</label>
<input
id="special-day-label"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={
type === 'feiertag'
? 'z.B. Tag der Deutschen Einheit'
: 'z.B. Betriebsurlaub'
}
className="input"
maxLength={50}
/>
<p className="mt-1 text-sm text-gray-500">
Wird zusätzlich zum Sondertagtyp angezeigt
</p>
</div>
{/* Preview */}
<div className="bg-gray-50 p-3 rounded-md border">
<h4 className="text-sm font-medium text-gray-700 mb-2">Vorschau</h4>
<div
className={`p-3 rounded-md text-center ${
type === 'feiertag'
? 'bg-yellow-100 border border-yellow-300 text-yellow-800'
: 'bg-red-100 border border-red-300 text-red-800'
}`}
>
<div className="font-medium">
{SPECIAL_DAY_NAMES[type]}
</div>
{label && (
<div className="text-sm mt-1">
{label}
</div>
)}
<div className="text-xs mt-1 opacity-75">
{DAY_NAMES[day]}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
<div>
{isEditing && onRemove && (
<button
type="button"
onClick={handleRemove}
className="btn-danger"
>
Sondertag entfernen
</button>
)}
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={onCancel}
className="btn-secondary"
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary"
>
{isEditing ? 'Ändern' : 'Setzen'}
</button>
</div>
</div>
</form>
</div>
</div>
);
}
// Preview component for showing special days in the week planner
interface SpecialDayDisplayProps {
specialDay: SpecialDay;
day: WeekDay;
onClick?: () => void;
className?: string;
}
export function SpecialDayDisplay({
specialDay,
day,
onClick,
className = ''
}: SpecialDayDisplayProps) {
const isHoliday = specialDay.type === 'feiertag';
return (
<div
className={`p-3 rounded-md text-center cursor-pointer transition-colors ${
isHoliday
? 'bg-yellow-100 border border-yellow-300 text-yellow-800 hover:bg-yellow-200'
: 'bg-red-100 border border-red-300 text-red-800 hover:bg-red-200'
} ${className}`}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
aria-label={`Sondertag für ${DAY_NAMES[day]}: ${SPECIAL_DAY_NAMES[specialDay.type as SpecialDayType]}${specialDay.label ? ` - ${specialDay.label}` : ''}`}
>
<div className="font-medium text-sm">
{SPECIAL_DAY_NAMES[specialDay.type as SpecialDayType]}
</div>
{specialDay.label && (
<div className="text-xs mt-1">
{specialDay.label}
</div>
)}
<div className="text-xs mt-1 opacity-75">
Klicken zum Bearbeiten
</div>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import { useState } from 'react';
import { WeekPlan, WeekDay, GroupLabel, SpecialDayType } from '../types';
import { DayColumn } from './DayColumn';
import { SpecialDayDialog } from './SpecialDayDialog';
import { getWeekDays } from '../lib/weekHelper';
import { useWeekPlan } from '../hooks/useWeekPlan';
interface WeekPlannerProps {
year: number;
week: number;
className?: string;
}
export function WeekPlanner({ year, week, className = '' }: WeekPlannerProps) {
const [specialDayDialog, setSpecialDayDialog] = useState<{ day: WeekDay } | null>(null);
const {
weekPlan,
loading,
error,
createWeekPlan,
addEntry,
removeEntry,
setSpecialDay,
removeSpecialDay,
getEntriesForDay,
getSpecialDay,
clearError
} = useWeekPlan(year, week);
// Get week days
const weekDays = getWeekDays(year, week);
// Handle adding entry
const handleAddEntry = async (
day: WeekDay,
meal: 'fruehstueck' | 'vesper',
productId?: number,
customText?: string,
groupLabel?: GroupLabel
) => {
await addEntry(day, meal, productId, customText, groupLabel);
};
// Handle removing entry
const handleRemoveEntry = async (entryId: number) => {
await removeEntry(entryId);
};
// Handle setting special day
const handleSetSpecialDay = (day: WeekDay) => {
setSpecialDayDialog({ day });
};
// Handle saving special day
const handleSaveSpecialDay = async (day: WeekDay, type: SpecialDayType, label?: string) => {
await setSpecialDay(day, type, label);
setSpecialDayDialog(null);
};
// Handle removing special day
const handleRemoveSpecialDay = async (day: WeekDay) => {
await removeSpecialDay(day);
setSpecialDayDialog(null);
};
// Handle creating new week plan
const handleCreateWeekPlan = async () => {
await createWeekPlan();
};
// Loading state
if (loading && !weekPlan) {
return (
<div className={`flex items-center justify-center py-12 ${className}`}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-gray-600">Wochenplan wird geladen...</p>
</div>
</div>
);
}
// Error state
if (error && !weekPlan) {
return (
<div className={`py-12 ${className}`}>
<div className="text-center">
<svg className="mx-auto h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">Fehler beim Laden</h3>
<p className="mt-2 text-gray-600">{error}</p>
<div className="mt-6">
<button
onClick={() => {
clearError();
window.location.reload();
}}
className="btn-primary"
>
Neu laden
</button>
</div>
</div>
</div>
);
}
// No week plan exists
if (!weekPlan) {
return (
<div className={`py-12 ${className}`}>
<div className="text-center">
<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="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">
Kein Wochenplan vorhanden
</h3>
<p className="mt-2 text-gray-600">
Für KW {week} {year} ist noch kein Wochenplan erstellt.
</p>
<div className="mt-6">
<button
onClick={handleCreateWeekPlan}
className="btn-primary"
disabled={loading}
>
{loading ? 'Wird erstellt...' : 'Wochenplan erstellen'}
</button>
</div>
</div>
</div>
);
}
return (
<div className={className}>
{/* Error Banner */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Fehler
</h3>
<div className="mt-2 text-sm text-red-700">
{error}
</div>
<div className="mt-3">
<button
onClick={clearError}
className="text-sm text-red-800 hover:text-red-600 focus:outline-none focus:underline"
>
Schließen
</button>
</div>
</div>
</div>
</div>
)}
{/* Week Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-gray-900">
Wochenplan KW {week} {year}
</h1>
<div className="flex items-center space-x-2 text-sm text-gray-600">
<span>Erstellt: {new Date(weekPlan.created_at).toLocaleDateString('de-DE')}</span>
{loading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
)}
</div>
</div>
</div>
{/* Week Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 lg:gap-6">
{weekDays.map((date, index) => {
const dayNumber = (index + 1) as WeekDay;
const dayEntries = getEntriesForDay(dayNumber, 'fruehstueck').concat(
getEntriesForDay(dayNumber, 'vesper')
);
const daySpecialDay = getSpecialDay(dayNumber);
return (
<DayColumn
key={dayNumber}
day={dayNumber}
date={date}
entries={dayEntries}
specialDay={daySpecialDay}
onAddEntry={handleAddEntry}
onRemoveEntry={handleRemoveEntry}
onSetSpecialDay={handleSetSpecialDay}
className="h-fit"
/>
);
})}
</div>
{/* Week Statistics */}
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-gray-900">
{weekPlan.entries?.length || 0}
</div>
<div className="text-sm text-gray-600">Einträge gesamt</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-blue-600">
{weekPlan.entries?.filter(e => e.meal === 'fruehstueck').length || 0}
</div>
<div className="text-sm text-gray-600">Frühstück</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-green-600">
{weekPlan.entries?.filter(e => e.meal === 'vesper').length || 0}
</div>
<div className="text-sm text-gray-600">Vesper</div>
</div>
<div className="bg-white p-4 rounded-lg shadow border">
<div className="text-2xl font-bold text-yellow-600">
{weekPlan.special_days?.length || 0}
</div>
<div className="text-sm text-gray-600">Sondertage</div>
</div>
</div>
{/* Special Day Dialog */}
{specialDayDialog && (
<SpecialDayDialog
day={specialDayDialog.day}
existingSpecialDay={getSpecialDay(specialDayDialog.day)}
onSave={handleSaveSpecialDay}
onRemove={handleRemoveSpecialDay}
onCancel={() => setSpecialDayDialog(null)}
/>
)}
</div>
);
}

View File

@@ -1,14 +1,9 @@
import React from 'react' import React from 'react'
import {createRoot} from 'react-dom/client' import ReactDOM from 'react-dom/client'
import './style.css'
import App from './App' import App from './App'
const container = document.getElementById('root') ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
const root = createRoot(container!) <App />
</React.StrictMode>,
root.render(
<React.StrictMode>
<App/>
</React.StrictMode>
) )