refactor: codebase and some fixes

This commit is contained in:
2025-12-02 16:05:27 +01:00
parent a57cf5de5b
commit 2a9956c3e6
17 changed files with 1089 additions and 1089 deletions

View File

@@ -5,31 +5,24 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { format, parseISO } from 'date-fns'
import {
Loader2,
LogOut,
Sun,
Moon,
Sunrise,
ClipboardList,
Users,
Settings,
Plus,
Pencil,
Eye,
Send,
Check,
ChefHat,
AlertTriangle,
ChevronLeft,
ChevronRight,
Calendar,
UserCheck,
UserX,
Check,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
@@ -64,6 +57,21 @@ import {
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
LoadingSpinner,
StatusBadge,
MealTypeIcon,
StatCard,
EmptyState,
MealTypeSelector,
} from '@/components/caregiver'
import {
ORDER_STATUSES,
getMealTypeLabel,
type MealType,
type OrderStatus,
} from '@/lib/constants/meal'
interface User {
id: number
name?: string
@@ -78,8 +86,8 @@ interface MealOrder {
id: number
title: string
date: string
mealType: 'breakfast' | 'lunch' | 'dinner'
status: 'draft' | 'submitted' | 'preparing' | 'completed'
mealType: MealType
status: OrderStatus
mealCount: number
createdAt: string
}
@@ -137,23 +145,21 @@ export default function CaregiverDashboardPage() {
const [loading, setLoading] = useState(true)
const [ordersLoading, setOrdersLoading] = useState(false)
// Filters
const [dateFilter, setDateFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
// Coverage dialog
const [coverageDialogOpen, setCoverageDialogOpen] = useState(false)
const [selectedOrder, setSelectedOrder] = useState<MealOrder | null>(null)
const [orderMeals, setOrderMeals] = useState<Meal[]>([])
const [coverageLoading, setCoverageLoading] = useState(false)
// Create order dialog
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [newOrderDate, setNewOrderDate] = useState(() => format(new Date(), 'yyyy-MM-dd'))
const [newOrderMealType, setNewOrderMealType] = useState<'breakfast' | 'lunch' | 'dinner'>('breakfast')
const [newOrderMealType, setNewOrderMealType] = useState<MealType>('breakfast')
const [creating, setCreating] = useState(false)
const fetchOrders = useCallback(async (page: number = 1) => {
const fetchOrders = useCallback(
async (page: number = 1) => {
setOrdersLoading(true)
try {
let url = `/api/meal-orders?sort=-date,-createdAt&limit=${ITEMS_PER_PAGE}&page=${page}&depth=0`
@@ -184,12 +190,13 @@ export default function CaregiverDashboardPage() {
} finally {
setOrdersLoading(false)
}
}, [router, dateFilter, statusFilter])
},
[router, dateFilter, statusFilter],
)
useEffect(() => {
const fetchInitialData = async () => {
try {
// Fetch current user
const userRes = await fetch('/api/users/me', { credentials: 'include' })
if (!userRes.ok) {
router.push('/caregiver/login')
@@ -202,7 +209,6 @@ export default function CaregiverDashboardPage() {
}
setUser(userData.user)
// Fetch residents
const residentsRes = await fetch('/api/residents?where[active][equals]=true&limit=500', {
credentials: 'include',
})
@@ -211,12 +217,11 @@ export default function CaregiverDashboardPage() {
setResidents(residentsData.docs || [])
}
// Fetch stats (all orders from last 7 days)
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const statsRes = await fetch(
`/api/meal-orders?where[date][greater_than_equal]=${weekAgo.toISOString().split('T')[0]}&limit=1000&depth=0`,
{ credentials: 'include' }
{ credentials: 'include' },
)
if (statsRes.ok) {
const statsData = await statsRes.json()
@@ -284,10 +289,9 @@ export default function CaregiverDashboardPage() {
setCoverageLoading(true)
try {
const res = await fetch(
`/api/meals?where[order][equals]=${order.id}&depth=1&limit=500`,
{ credentials: 'include' }
)
const res = await fetch(`/api/meals?where[order][equals]=${order.id}&depth=1&limit=500`, {
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
setOrderMeals(data.docs || [])
@@ -299,67 +303,6 @@ export default function CaregiverDashboardPage() {
}
}
const getMealTypeLabel = (type: string) => {
switch (type) {
case 'breakfast':
return 'Breakfast'
case 'lunch':
return 'Lunch'
case 'dinner':
return 'Dinner'
default:
return type
}
}
const getMealTypeIcon = (type: string) => {
switch (type) {
case 'breakfast':
return <Sunrise className="h-4 w-4 text-orange-500" />
case 'lunch':
return <Sun className="h-4 w-4 text-yellow-500" />
case 'dinner':
return <Moon className="h-4 w-4 text-indigo-500" />
default:
return null
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return (
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200">
<Pencil className="mr-1 h-3 w-3" />
Draft
</Badge>
)
case 'submitted':
return (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
<Send className="mr-1 h-3 w-3" />
Submitted
</Badge>
)
case 'preparing':
return (
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
<ChefHat className="mr-1 h-3 w-3" />
Preparing
</Badge>
)
case 'completed':
return (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<Check className="mr-1 h-3 w-3" />
Completed
</Badge>
)
default:
return <Badge variant="outline">{status}</Badge>
}
}
const getCoverageInfo = (order: MealOrder) => {
const totalResidents = residents.length
const coveredCount = order.mealCount
@@ -379,11 +322,7 @@ export default function CaregiverDashboardPage() {
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
return <LoadingSpinner fullPage />
}
const tenantName =
@@ -391,9 +330,8 @@ export default function CaregiverDashboardPage() {
? user.tenants[0].tenant.name
: 'Care Home'
// Calculate covered residents for coverage dialog
const coveredResidentIds = new Set(
orderMeals.map((m) => (typeof m.resident === 'object' ? m.resident.id : m.resident))
orderMeals.map((m) => (typeof m.resident === 'object' ? m.resident.id : m.resident)),
)
const coveredResidents = residents.filter((r) => coveredResidentIds.has(r.id))
const uncoveredResidents = residents.filter((r) => !coveredResidentIds.has(r.id))
@@ -425,61 +363,24 @@ export default function CaregiverDashboardPage() {
</Button>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground">Last 7 days</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Draft</CardTitle>
<div className="h-2 w-2 rounded-full bg-gray-400" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.draft}</div>
<p className="text-xs text-muted-foreground">In progress</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Submitted</CardTitle>
<div className="h-2 w-2 rounded-full bg-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.submitted}</div>
<p className="text-xs text-muted-foreground">Sent to kitchen</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Preparing</CardTitle>
<div className="h-2 w-2 rounded-full bg-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.preparing}</div>
<p className="text-xs text-muted-foreground">Being prepared</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<div className="h-2 w-2 rounded-full bg-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completed}</div>
<p className="text-xs text-muted-foreground">Finished</p>
</CardContent>
</Card>
<StatCard
title="Total Orders"
value={stats.total}
description="Last 7 days"
icon={ClipboardList}
/>
{ORDER_STATUSES.map((status) => (
<StatCard
key={status.value}
title={status.label}
value={stats[status.value as keyof OrderStats]}
description={status.description}
dotColor={status.dotColor}
/>
))}
</div>
{/* Quick Actions */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
@@ -523,7 +424,6 @@ export default function CaregiverDashboardPage() {
</CardContent>
</Card>
{/* Meal Orders Table */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@@ -533,7 +433,6 @@ export default function CaregiverDashboardPage() {
View and manage all meal orders. Click on coverage to see resident details.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Label htmlFor="dateFilter" className="sr-only">
Filter by date
@@ -546,17 +445,17 @@ export default function CaregiverDashboardPage() {
className="w-40"
placeholder="Filter by date"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="submitted">Submitted</SelectItem>
<SelectItem value="preparing">Preparing</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
{ORDER_STATUSES.map((status) => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(dateFilter || statusFilter !== 'all') && (
@@ -576,21 +475,19 @@ export default function CaregiverDashboardPage() {
</CardHeader>
<CardContent className="p-0">
{ordersLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<LoadingSpinner />
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No orders found.</p>
<p className="text-sm text-muted-foreground mt-2">
Create a new order to get started.
</p>
<Button className="mt-4" onClick={() => setCreateDialogOpen(true)}>
<EmptyState
icon={ClipboardList}
title="No orders found."
description="Create a new order to get started."
action={
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Order
</Button>
</div>
}
/>
) : (
<>
<Table>
@@ -609,12 +506,7 @@ export default function CaregiverDashboardPage() {
return (
<TableRow key={order.id}>
<TableCell>
<div className="flex items-center gap-2">
{getMealTypeIcon(order.mealType)}
<span className="font-medium">
{getMealTypeLabel(order.mealType)}
</span>
</div>
<MealTypeIcon type={order.mealType} showLabel />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
@@ -631,10 +523,7 @@ export default function CaregiverDashboardPage() {
onClick={() => handleViewCoverage(order)}
>
<div className="flex items-center gap-2">
<Progress
value={coverage.percentage}
className="h-2 flex-1"
/>
<Progress value={coverage.percentage} className="h-2 flex-1" />
<span className="text-xs text-muted-foreground whitespace-nowrap">
{coverage.coveredCount}/{coverage.totalResidents}
</span>
@@ -655,25 +544,25 @@ export default function CaregiverDashboardPage() {
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell>{getStatusBadge(order.status)}</TableCell>
<TableCell>
<StatusBadge status={order.status} />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{order.status === 'draft' ? (
<Button variant="outline" size="sm" asChild>
<Link href={`/caregiver/orders/${order.id}`}>
{order.status === 'draft' ? (
<>
<Pencil className="mr-1 h-3 w-3" />
Edit
</Link>
</Button>
</>
) : (
<Button variant="outline" size="sm" asChild>
<Link href={`/caregiver/orders/${order.id}`}>
<>
<Eye className="mr-1 h-3 w-3" />
View
</>
)}
</Link>
</Button>
)}
</div>
</TableCell>
</TableRow>
)
@@ -681,7 +570,6 @@ export default function CaregiverDashboardPage() {
</TableBody>
</Table>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-4 border-t">
<div className="text-sm text-muted-foreground">
@@ -743,14 +631,11 @@ export default function CaregiverDashboardPage() {
</Card>
</main>
{/* Create Order Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Meal Order</DialogTitle>
<DialogDescription>
Select a date and meal type to create a new order.
</DialogDescription>
<DialogDescription>Select a date and meal type to create a new order.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
@@ -764,28 +649,7 @@ export default function CaregiverDashboardPage() {
</div>
<div className="space-y-2">
<Label>Meal Type</Label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: 'breakfast', label: 'Breakfast', icon: Sunrise, color: 'text-orange-500' },
{ value: 'lunch', label: 'Lunch', icon: Sun, color: 'text-yellow-500' },
{ value: 'dinner', label: 'Dinner', icon: Moon, color: 'text-indigo-500' },
].map(({ value, label, icon: Icon, color }) => (
<button
key={value}
type="button"
className={cn(
'flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-colors',
newOrderMealType === value
? 'border-primary bg-primary/5'
: 'border-border hover:bg-muted'
)}
onClick={() => setNewOrderMealType(value as 'breakfast' | 'lunch' | 'dinner')}
>
<Icon className={cn('h-6 w-6 mb-1', color)} />
<span className="text-sm font-medium">{label}</span>
</button>
))}
</div>
<MealTypeSelector value={newOrderMealType} onChange={setNewOrderMealType} variant="button" />
</div>
</div>
<DialogFooter>
@@ -794,22 +658,16 @@ export default function CaregiverDashboardPage() {
</Button>
<Button onClick={handleCreateOrder} disabled={creating}>
{creating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
<LoadingSpinner size="sm" className="mr-2" />
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Create Order
</>
)}
{creating ? 'Creating...' : 'Create Order'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Coverage Dialog */}
<Dialog open={coverageDialogOpen} onOpenChange={setCoverageDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
@@ -827,12 +685,9 @@ export default function CaregiverDashboardPage() {
</DialogHeader>
{coverageLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<LoadingSpinner />
) : (
<div className="space-y-6">
{/* Coverage Summary */}
<div className="flex items-center gap-4 p-4 bg-muted rounded-lg">
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
@@ -843,9 +698,7 @@ export default function CaregiverDashboardPage() {
</div>
<Progress
value={
residents.length > 0
? (coveredResidents.length / residents.length) * 100
: 0
residents.length > 0 ? (coveredResidents.length / residents.length) * 100 : 0
}
className="h-3"
/>
@@ -856,8 +709,8 @@ export default function CaregiverDashboardPage() {
getCoverageColor(
residents.length > 0
? (coveredResidents.length / residents.length) * 100
: 0
)
: 0,
),
)}
>
{residents.length > 0
@@ -867,7 +720,6 @@ export default function CaregiverDashboardPage() {
</div>
</div>
{/* Uncovered Residents */}
{uncoveredResidents.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
@@ -893,7 +745,6 @@ export default function CaregiverDashboardPage() {
</div>
)}
{/* Covered Residents */}
{coveredResidents.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,12 @@ import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { format, parseISO } from 'date-fns'
import { ArrowLeft, Plus, Loader2, Send, Eye, Pencil, Check, ChefHat } from 'lucide-react'
import { Plus, Pencil, Eye } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
@@ -27,12 +26,26 @@ import {
TableRow,
} from '@/components/ui/table'
import {
PageHeader,
LoadingSpinner,
StatusBadge,
MealTypeIcon,
EmptyState,
} from '@/components/caregiver'
import {
ORDER_STATUSES,
getMealTypeLabel,
type MealType,
type OrderStatus,
} from '@/lib/constants/meal'
interface MealOrder {
id: number
title: string
date: string
mealType: 'breakfast' | 'lunch' | 'dinner'
status: 'draft' | 'submitted' | 'preparing' | 'completed'
mealType: MealType
status: OrderStatus
mealCount: number
createdAt: string
}
@@ -72,55 +85,7 @@ export default function OrdersListPage() {
fetchOrders()
}, [router, dateFilter, statusFilter])
const getMealTypeLabel = (type: string) => {
switch (type) {
case 'breakfast':
return 'Breakfast'
case 'lunch':
return 'Lunch'
case 'dinner':
return 'Dinner'
default:
return type
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return (
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200">
<Pencil className="mr-1 h-3 w-3" />
Draft
</Badge>
)
case 'submitted':
return (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
<Send className="mr-1 h-3 w-3" />
Submitted
</Badge>
)
case 'preparing':
return (
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
<ChefHat className="mr-1 h-3 w-3" />
Preparing
</Badge>
)
case 'completed':
return (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<Check className="mr-1 h-3 w-3" />
Completed
</Badge>
)
default:
return <Badge variant="outline">{status}</Badge>
}
}
const handleCreateOrder = async (mealType: 'breakfast' | 'lunch' | 'dinner') => {
const handleCreateOrder = async (mealType: MealType) => {
try {
const res = await fetch('/api/meal-orders', {
method: 'POST',
@@ -144,19 +109,7 @@ export default function OrdersListPage() {
return (
<div className="min-h-screen bg-muted/50">
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/caregiver/dashboard">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<h1 className="text-xl font-semibold">Meal Orders</h1>
</div>
</div>
</header>
<PageHeader title="Meal Orders" backHref="/caregiver/dashboard" />
<main className="container py-6">
<Card className="mb-6">
@@ -182,43 +135,29 @@ export default function OrdersListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="submitted">Submitted</SelectItem>
<SelectItem value="preparing">Preparing</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
{ORDER_STATUSES.map((status) => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Create New Order</Label>
<div className="flex gap-2">
{(['breakfast', 'lunch', 'dinner'] as const).map((type) => (
<Button
key={type}
variant="outline"
size="sm"
onClick={() => handleCreateOrder('breakfast')}
onClick={() => handleCreateOrder(type)}
className="flex-1"
>
<Plus className="mr-1 h-3 w-3" />
Breakfast
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCreateOrder('lunch')}
className="flex-1"
>
<Plus className="mr-1 h-3 w-3" />
Lunch
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleCreateOrder('dinner')}
className="flex-1"
>
<Plus className="mr-1 h-3 w-3" />
Dinner
{getMealTypeLabel(type)}
</Button>
))}
</div>
</div>
</div>
@@ -228,16 +167,12 @@ export default function OrdersListPage() {
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<LoadingSpinner />
) : orders.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-muted-foreground">No orders found for the selected date.</p>
<p className="text-sm text-muted-foreground mt-2">
Create a new order using the buttons above.
</p>
</div>
<EmptyState
title="No orders found for the selected date."
description="Create a new order using the buttons above."
/>
) : (
<Table>
<TableHeader>
@@ -252,12 +187,14 @@ export default function OrdersListPage() {
<TableBody>
{orders.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-medium">
{getMealTypeLabel(order.mealType)}
<TableCell>
<MealTypeIcon type={order.mealType} showLabel />
</TableCell>
<TableCell>{format(parseISO(order.date), 'MMM d, yyyy')}</TableCell>
<TableCell>{order.mealCount} residents</TableCell>
<TableCell>{getStatusBadge(order.status)}</TableCell>
<TableCell>
<StatusBadge status={order.status} />
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" asChild>
<Link href={`/caregiver/orders/${order.id}`}>

View File

@@ -1,4 +1,5 @@
import React from 'react'
import type { Viewport } from 'next'
import '@/app/globals.css'
@@ -7,6 +8,12 @@ export const metadata = {
title: 'Meal Planner - Caregiver',
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
}
// eslint-disable-next-line no-restricted-exports
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (

View File

@@ -166,4 +166,13 @@
.container {
@apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8;
}
/* Safe area padding for mobile devices with notches/home indicators */
.safe-area-bottom {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
.safe-area-top {
padding-top: max(1rem, env(safe-area-inset-top));
}
}

View File

@@ -0,0 +1,58 @@
'use client'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface CheckboxOptionProps {
id: string
label: string
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
className?: string
}
export function CheckboxOption({
id,
label,
checked,
onCheckedChange,
disabled = false,
className,
}: CheckboxOptionProps) {
return (
<div
className={cn(
'flex items-center space-x-3 rounded-lg border p-3 cursor-pointer transition-colors select-none',
checked ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50',
disabled && 'opacity-50 cursor-not-allowed',
className,
)}
onClick={() => !disabled && onCheckedChange(!checked)}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
onCheckedChange(!checked)
}
}}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
onClick={(e) => e.stopPropagation()}
disabled={disabled}
/>
<Label
htmlFor={id}
className={cn('cursor-pointer flex-1 text-sm', disabled && 'cursor-not-allowed')}
onClick={(e) => e.stopPropagation()}
>
{label}
</Label>
</div>
)
}

View File

@@ -0,0 +1,23 @@
'use client'
import { type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon?: LucideIcon
title: string
description?: string
action?: React.ReactNode
className?: string
}
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center p-8 text-center', className)}>
{Icon && <Icon className="h-12 w-12 text-muted-foreground mb-4" />}
<p className="text-muted-foreground">{title}</p>
{description && <p className="text-sm text-muted-foreground mt-2">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LoadingSpinnerProps {
fullPage?: boolean
size?: 'sm' | 'md' | 'lg'
className?: string
}
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
export function LoadingSpinner({ fullPage = false, size = 'md', className }: LoadingSpinnerProps) {
const spinner = (
<Loader2 className={cn('animate-spin text-primary', sizeClasses[size], className)} />
)
if (fullPage) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50">
{spinner}
</div>
)
}
return (
<div className="flex items-center justify-center p-8">
{spinner}
</div>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { cn } from '@/lib/utils'
import { getMealTypeConfig, getMealTypeLabel, type MealType } from '@/lib/constants/meal'
interface MealTypeIconProps {
type: MealType
showLabel?: boolean
size?: 'sm' | 'md' | 'lg'
className?: string
}
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-10 w-10',
}
export function MealTypeIcon({ type, showLabel = false, size = 'sm', className }: MealTypeIconProps) {
const config = getMealTypeConfig(type)
if (!config) {
return showLabel ? <span>{type}</span> : null
}
const Icon = config.icon
if (showLabel) {
return (
<div className={cn('flex items-center gap-2', className)}>
<Icon className={cn(sizeClasses[size], config.color)} />
<span className="font-medium">{getMealTypeLabel(type)}</span>
</div>
)
}
return <Icon className={cn(sizeClasses[size], config.color, className)} />
}

View File

@@ -0,0 +1,67 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { MEAL_TYPES, type MealType } from '@/lib/constants/meal'
interface MealTypeSelectorProps {
value: MealType | null
onChange: (type: MealType) => void
showSublabel?: boolean
variant?: 'card' | 'button'
className?: string
}
export function MealTypeSelector({
value,
onChange,
showSublabel = false,
variant = 'card',
className,
}: MealTypeSelectorProps) {
if (variant === 'button') {
return (
<div className={cn('grid grid-cols-3 gap-2', className)}>
{MEAL_TYPES.map(({ value: type, label, icon: Icon, color }) => (
<button
key={type}
type="button"
className={cn(
'flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-colors',
value === type
? 'border-primary bg-primary/5'
: 'border-border hover:bg-muted',
)}
onClick={() => onChange(type)}
>
<Icon className={cn('h-6 w-6 mb-1', color)} />
<span className="text-sm font-medium">{label}</span>
</button>
))}
</div>
)
}
return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{MEAL_TYPES.map(({ value: type, label, sublabel, icon: Icon, color }) => (
<Card
key={type}
className={cn(
'cursor-pointer transition-colors',
value === type ? 'border-primary bg-primary/5' : 'hover:bg-muted/50',
)}
onClick={() => onChange(type)}
>
<CardContent className="flex flex-col items-center justify-center p-6">
<Icon className={cn('h-10 w-10 mb-2', color)} />
<span className="font-semibold">{label}</span>
{showSublabel && sublabel && (
<span className="text-sm text-muted-foreground">{sublabel}</span>
)}
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface PageHeaderProps {
title: string
subtitle?: string
backHref?: string
backLabel?: string
rightContent?: React.ReactNode
}
export function PageHeader({
title,
subtitle,
backHref,
backLabel = 'Back',
rightContent,
}: PageHeaderProps) {
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-4">
{backHref && (
<Button variant="ghost" size="sm" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
{backLabel}
</Link>
</Button>
)}
<div>
<h1 className="text-xl font-semibold">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
{rightContent && <div className="flex items-center gap-2">{rightContent}</div>}
</div>
</header>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import { type LucideIcon } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
interface StatCardProps {
title: string
value: string | number
description?: string
icon?: LucideIcon
iconColor?: string
dotColor?: string
className?: string
}
export function StatCard({
title,
value,
description,
icon: Icon,
iconColor,
dotColor,
className,
}: StatCardProps) {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{Icon && <Icon className={cn('h-4 w-4 text-muted-foreground', iconColor)} />}
{!Icon && dotColor && <div className={cn('h-2 w-2 rounded-full', dotColor)} />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { getStatusConfig, type OrderStatus } from '@/lib/constants/meal'
interface StatusBadgeProps {
status: OrderStatus
showIcon?: boolean
className?: string
}
export function StatusBadge({ status, showIcon = true, className }: StatusBadgeProps) {
const config = getStatusConfig(status)
if (!config) {
return <Badge variant="outline">{status}</Badge>
}
const Icon = config.icon
return (
<Badge
variant="outline"
className={cn(config.bgColor, config.textColor, config.borderColor, className)}
>
{showIcon && <Icon className="mr-1 h-3 w-3" />}
{config.label}
</Badge>
)
}

View File

@@ -0,0 +1,8 @@
export { PageHeader } from './PageHeader'
export { LoadingSpinner } from './LoadingSpinner'
export { StatusBadge } from './StatusBadge'
export { MealTypeIcon } from './MealTypeIcon'
export { StatCard } from './StatCard'
export { CheckboxOption } from './CheckboxOption'
export { EmptyState } from './EmptyState'
export { MealTypeSelector } from './MealTypeSelector'

View File

@@ -60,9 +60,9 @@ function SheetContent({
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full border-l",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full border-r",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&

View File

@@ -0,0 +1,271 @@
export interface OptionItem {
key: string
label: string
}
export interface OptionSection {
title: string
columns: 1 | 2 | 3
options: OptionItem[]
}
export interface BreakfastOptions {
accordingToPlan: boolean
bread: {
breadRoll: boolean
wholeGrainRoll: boolean
greyBread: boolean
wholeGrainBread: boolean
whiteBread: boolean
crispbread: boolean
}
porridge: boolean
preparation: { sliced: boolean; spread: boolean }
spreads: {
butter: boolean
margarine: boolean
jam: boolean
diabeticJam: boolean
honey: boolean
cheese: boolean
quark: boolean
sausage: boolean
}
beverages: { coffee: boolean; tea: boolean; hotMilk: boolean; coldMilk: boolean }
additions: { sugar: boolean; sweetener: boolean; coffeeCreamer: boolean }
}
export interface LunchOptions {
portionSize: 'small' | 'large' | 'vegetarian'
soup: boolean
dessert: boolean
specialPreparations: {
pureedFood: boolean
pureedMeat: boolean
slicedMeat: boolean
mashedPotatoes: boolean
}
restrictions: { noFish: boolean; fingerFood: boolean; onlySweet: boolean }
}
export interface DinnerOptions {
accordingToPlan: boolean
bread: { greyBread: boolean; wholeGrainBread: boolean; whiteBread: boolean; crispbread: boolean }
preparation: { spread: boolean; sliced: boolean }
spreads: { butter: boolean; margarine: boolean }
soup: boolean
porridge: boolean
noFish: boolean
beverages: { tea: boolean; cocoa: boolean; hotMilk: boolean; coldMilk: boolean }
additions: { sugar: boolean; sweetener: boolean }
}
export const DEFAULT_BREAKFAST: BreakfastOptions = {
accordingToPlan: false,
bread: {
breadRoll: false,
wholeGrainRoll: false,
greyBread: false,
wholeGrainBread: false,
whiteBread: false,
crispbread: false,
},
porridge: false,
preparation: { sliced: false, spread: false },
spreads: {
butter: false,
margarine: false,
jam: false,
diabeticJam: false,
honey: false,
cheese: false,
quark: false,
sausage: false,
},
beverages: { coffee: false, tea: false, hotMilk: false, coldMilk: false },
additions: { sugar: false, sweetener: false, coffeeCreamer: false },
}
export const DEFAULT_LUNCH: LunchOptions = {
portionSize: 'large',
soup: false,
dessert: true,
specialPreparations: {
pureedFood: false,
pureedMeat: false,
slicedMeat: false,
mashedPotatoes: false,
},
restrictions: { noFish: false, fingerFood: false, onlySweet: false },
}
export const DEFAULT_DINNER: DinnerOptions = {
accordingToPlan: false,
bread: { greyBread: false, wholeGrainBread: false, whiteBread: false, crispbread: false },
preparation: { spread: false, sliced: false },
spreads: { butter: false, margarine: false },
soup: false,
porridge: false,
noFish: false,
beverages: { tea: false, cocoa: false, hotMilk: false, coldMilk: false },
additions: { sugar: false, sweetener: false },
}
export const BREAKFAST_CONFIG: Record<string, OptionSection> = {
bread: {
title: 'Bread (Brot)',
columns: 2,
options: [
{ key: 'breadRoll', label: 'Bread Roll' },
{ key: 'wholeGrainRoll', label: 'Whole Grain Roll' },
{ key: 'greyBread', label: 'Grey Bread' },
{ key: 'wholeGrainBread', label: 'Whole Grain' },
{ key: 'whiteBread', label: 'White Bread' },
{ key: 'crispbread', label: 'Crispbread' },
],
},
preparation: {
title: 'Preparation',
columns: 3,
options: [
{ key: 'porridge', label: 'Porridge' },
{ key: 'sliced', label: 'Sliced' },
{ key: 'spread', label: 'Spread' },
],
},
spreads: {
title: 'Spreads',
columns: 2,
options: [
{ key: 'butter', label: 'Butter' },
{ key: 'margarine', label: 'Margarine' },
{ key: 'jam', label: 'Jam' },
{ key: 'diabeticJam', label: 'Diabetic Jam' },
{ key: 'honey', label: 'Honey' },
{ key: 'cheese', label: 'Cheese' },
{ key: 'quark', label: 'Quark' },
{ key: 'sausage', label: 'Sausage' },
],
},
beverages: {
title: 'Beverages',
columns: 2,
options: [
{ key: 'coffee', label: 'Coffee' },
{ key: 'tea', label: 'Tea' },
{ key: 'hotMilk', label: 'Hot Milk' },
{ key: 'coldMilk', label: 'Cold Milk' },
],
},
additions: {
title: 'Additions',
columns: 3,
options: [
{ key: 'sugar', label: 'Sugar' },
{ key: 'sweetener', label: 'Sweetener' },
{ key: 'coffeeCreamer', label: 'Creamer' },
],
},
}
export const LUNCH_CONFIG = {
portionSizes: [
{ value: 'small', label: 'Small' },
{ value: 'large', label: 'Large' },
{ value: 'vegetarian', label: 'Vegetarian' },
] as const,
mealOptions: {
title: 'Meal Options',
columns: 2 as const,
options: [
{ key: 'soup', label: 'Soup' },
{ key: 'dessert', label: 'Dessert' },
],
},
specialPreparations: {
title: 'Special Preparations',
columns: 2 as const,
options: [
{ key: 'pureedFood', label: 'Pureed Food' },
{ key: 'pureedMeat', label: 'Pureed Meat' },
{ key: 'slicedMeat', label: 'Sliced Meat' },
{ key: 'mashedPotatoes', label: 'Mashed Potatoes' },
],
},
restrictions: {
title: 'Restrictions',
columns: 3 as const,
options: [
{ key: 'noFish', label: 'No Fish' },
{ key: 'fingerFood', label: 'Finger Food' },
{ key: 'onlySweet', label: 'Only Sweet' },
],
},
}
export const DINNER_CONFIG: Record<string, OptionSection> = {
bread: {
title: 'Bread',
columns: 2,
options: [
{ key: 'greyBread', label: 'Grey Bread' },
{ key: 'wholeGrainBread', label: 'Whole Grain' },
{ key: 'whiteBread', label: 'White Bread' },
{ key: 'crispbread', label: 'Crispbread' },
],
},
preparation: {
title: 'Preparation',
columns: 2,
options: [
{ key: 'spread', label: 'Spread' },
{ key: 'sliced', label: 'Sliced' },
],
},
spreads: {
title: 'Spreads',
columns: 2,
options: [
{ key: 'butter', label: 'Butter' },
{ key: 'margarine', label: 'Margarine' },
],
},
additionalItems: {
title: 'Additional Items',
columns: 3,
options: [
{ key: 'soup', label: 'Soup' },
{ key: 'porridge', label: 'Porridge' },
{ key: 'noFish', label: 'No Fish' },
],
},
beverages: {
title: 'Beverages',
columns: 2,
options: [
{ key: 'tea', label: 'Tea' },
{ key: 'cocoa', label: 'Cocoa' },
{ key: 'hotMilk', label: 'Hot Milk' },
{ key: 'coldMilk', label: 'Cold Milk' },
],
},
additions: {
title: 'Additions',
columns: 2,
options: [
{ key: 'sugar', label: 'Sugar' },
{ key: 'sweetener', label: 'Sweetener' },
],
},
}
export const getGridColsClass = (cols: 1 | 2 | 3): string => {
switch (cols) {
case 1:
return 'grid-cols-1'
case 2:
return 'grid-cols-1 sm:grid-cols-2'
case 3:
return 'grid-cols-1 sm:grid-cols-3'
}
}

88
src/lib/constants/meal.ts Normal file
View File

@@ -0,0 +1,88 @@
import { Sunrise, Sun, Moon, Pencil, Send, ChefHat, Check, type LucideIcon } from 'lucide-react'
export type MealType = 'breakfast' | 'lunch' | 'dinner'
export type OrderStatus = 'draft' | 'submitted' | 'preparing' | 'completed'
export interface MealTypeConfig {
value: MealType
label: string
sublabel?: string
icon: LucideIcon
color: string
}
export interface StatusConfig {
value: OrderStatus
label: string
icon: LucideIcon
bgColor: string
textColor: string
borderColor: string
dotColor: string
description: string
}
export const MEAL_TYPES: MealTypeConfig[] = [
{ value: 'breakfast', label: 'Breakfast', sublabel: 'Frühstück', icon: Sunrise, color: 'text-orange-500' },
{ value: 'lunch', label: 'Lunch', sublabel: 'Mittagessen', icon: Sun, color: 'text-yellow-500' },
{ value: 'dinner', label: 'Dinner', sublabel: 'Abendessen', icon: Moon, color: 'text-indigo-500' },
]
export const ORDER_STATUSES: StatusConfig[] = [
{
value: 'draft',
label: 'Draft',
icon: Pencil,
bgColor: 'bg-gray-50',
textColor: 'text-gray-700',
borderColor: 'border-gray-200',
dotColor: 'bg-gray-400',
description: 'In progress',
},
{
value: 'submitted',
label: 'Submitted',
icon: Send,
bgColor: 'bg-blue-50',
textColor: 'text-blue-700',
borderColor: 'border-blue-200',
dotColor: 'bg-blue-500',
description: 'Sent to kitchen',
},
{
value: 'preparing',
label: 'Preparing',
icon: ChefHat,
bgColor: 'bg-yellow-50',
textColor: 'text-yellow-700',
borderColor: 'border-yellow-200',
dotColor: 'bg-yellow-500',
description: 'Being prepared',
},
{
value: 'completed',
label: 'Completed',
icon: Check,
bgColor: 'bg-green-50',
textColor: 'text-green-700',
borderColor: 'border-green-200',
dotColor: 'bg-green-500',
description: 'Finished',
},
]
export const getMealTypeConfig = (type: MealType): MealTypeConfig | undefined => {
return MEAL_TYPES.find((m) => m.value === type)
}
export const getStatusConfig = (status: OrderStatus): StatusConfig | undefined => {
return ORDER_STATUSES.find((s) => s.value === status)
}
export const getMealTypeLabel = (type: MealType): string => {
return getMealTypeConfig(type)?.label ?? type
}
export const getStatusLabel = (status: OrderStatus): string => {
return getStatusConfig(status)?.label ?? status
}