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,13 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>speiseplan</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.tsx" type="module"></script>
|
||||
<div id="root" aria-label="Kita Speiseplan Anwendung"></div>
|
||||
<script src="./src/main.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,28 +1,121 @@
|
||||
import {useState} from 'react';
|
||||
import logo from './assets/images/logo-universal.png';
|
||||
import './App.css';
|
||||
import {Greet} from "../wailsjs/go/main/App";
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Layout, useSelectedWeek } from './components/Layout';
|
||||
import { WeekPlanner } from './components/WeekPlanner';
|
||||
import { ProductList } from './components/ProductList';
|
||||
import { InfoPage } from './components/InfoPage';
|
||||
import { useProducts } from './hooks/useProducts';
|
||||
import './styles/globals.css';
|
||||
|
||||
function App() {
|
||||
const [resultText, setResultText] = useState("Please enter your name below 👇");
|
||||
const [name, setName] = useState('');
|
||||
const updateName = (e: any) => setName(e.target.value);
|
||||
const updateResultText = (result: string) => setResultText(result);
|
||||
// Home Page Component (Week Planner View)
|
||||
function HomePage() {
|
||||
const selectedWeek = useSelectedWeek();
|
||||
|
||||
function greet() {
|
||||
Greet(name).then(updateResultText);
|
||||
return (
|
||||
<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 (
|
||||
<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>
|
||||
)
|
||||
<ProductList
|
||||
products={products}
|
||||
allergens={allergens}
|
||||
additives={additives}
|
||||
onCreateProduct={createProduct}
|
||||
onUpdateProduct={updateProduct}
|
||||
onDeleteProduct={deleteProduct}
|
||||
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 {createRoot} from 'react-dom/client'
|
||||
import './style.css'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
|
||||
const root = createRoot(container!)
|
||||
|
||||
root.render(
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user