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:
@@ -1,12 +1,22 @@
|
|||||||
<!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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div id="App">
|
<ProductList
|
||||||
<img src={logo} id="logo" alt="logo"/>
|
products={products}
|
||||||
<div id="result" className="result">{resultText}</div>
|
allergens={allergens}
|
||||||
<div id="input" className="input-box">
|
additives={additives}
|
||||||
<input id="name" className="input" onChange={updateName} autoComplete="off" name="input" type="text"/>
|
onCreateProduct={createProduct}
|
||||||
<button className="btn" onClick={greet}>Greet</button>
|
onUpdateProduct={updateProduct}
|
||||||
</div>
|
onDeleteProduct={deleteProduct}
|
||||||
</div>
|
loading={loading}
|
||||||
)
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
// 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;
|
||||||
193
frontend/src/components/DayColumn.tsx
Normal file
193
frontend/src/components/DayColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
frontend/src/components/EntryCard.tsx
Normal file
234
frontend/src/components/EntryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
291
frontend/src/components/InfoPage.tsx
Normal file
291
frontend/src/components/InfoPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/components/Layout.tsx
Normal file
87
frontend/src/components/Layout.tsx
Normal 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;
|
||||||
|
}
|
||||||
272
frontend/src/components/MealSlot.tsx
Normal file
272
frontend/src/components/MealSlot.tsx
Normal 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];
|
||||||
|
}
|
||||||
|
`;
|
||||||
335
frontend/src/components/Sidebar.tsx
Normal file
335
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
frontend/src/components/SpecialDayDialog.tsx
Normal file
238
frontend/src/components/SpecialDayDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
frontend/src/components/WeekPlanner.tsx
Normal file
253
frontend/src/components/WeekPlanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|
||||||
const root = createRoot(container!)
|
|
||||||
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user