946 lines
35 KiB
TypeScript
946 lines
35 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import {
|
|
Loader2,
|
|
LogOut,
|
|
Sun,
|
|
Moon,
|
|
Sunrise,
|
|
ClipboardList,
|
|
Users,
|
|
Settings,
|
|
Plus,
|
|
Pencil,
|
|
Eye,
|
|
Send,
|
|
Check,
|
|
ChefHat,
|
|
AlertTriangle,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Calendar,
|
|
UserCheck,
|
|
UserX,
|
|
} 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'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface User {
|
|
id: number
|
|
name?: string
|
|
email: string
|
|
tenants?: Array<{
|
|
tenant: { id: number; name: string } | number
|
|
roles?: string[]
|
|
}>
|
|
}
|
|
|
|
interface MealOrder {
|
|
id: number
|
|
title: string
|
|
date: string
|
|
mealType: 'breakfast' | 'lunch' | 'dinner'
|
|
status: 'draft' | 'submitted' | 'preparing' | 'completed'
|
|
mealCount: number
|
|
createdAt: string
|
|
}
|
|
|
|
interface Resident {
|
|
id: number
|
|
name: string
|
|
room: string
|
|
}
|
|
|
|
interface Meal {
|
|
id: number
|
|
resident: number | { id: number; name: string }
|
|
}
|
|
|
|
interface OrderStats {
|
|
draft: number
|
|
submitted: number
|
|
preparing: number
|
|
completed: number
|
|
total: number
|
|
}
|
|
|
|
interface PaginationInfo {
|
|
totalDocs: number
|
|
totalPages: number
|
|
page: number
|
|
limit: number
|
|
hasNextPage: boolean
|
|
hasPrevPage: boolean
|
|
}
|
|
|
|
const ITEMS_PER_PAGE = 10
|
|
|
|
export default function CaregiverDashboardPage() {
|
|
const router = useRouter()
|
|
const [user, setUser] = useState<User | null>(null)
|
|
const [orders, setOrders] = useState<MealOrder[]>([])
|
|
const [residents, setResidents] = useState<Resident[]>([])
|
|
const [stats, setStats] = useState<OrderStats>({
|
|
draft: 0,
|
|
submitted: 0,
|
|
preparing: 0,
|
|
completed: 0,
|
|
total: 0,
|
|
})
|
|
const [pagination, setPagination] = useState<PaginationInfo>({
|
|
totalDocs: 0,
|
|
totalPages: 1,
|
|
page: 1,
|
|
limit: ITEMS_PER_PAGE,
|
|
hasNextPage: false,
|
|
hasPrevPage: false,
|
|
})
|
|
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(() => new Date().toISOString().split('T')[0])
|
|
const [newOrderMealType, setNewOrderMealType] = useState<'breakfast' | 'lunch' | 'dinner'>('breakfast')
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
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`
|
|
if (dateFilter) {
|
|
url += `&where[date][equals]=${dateFilter}`
|
|
}
|
|
if (statusFilter !== 'all') {
|
|
url += `&where[status][equals]=${statusFilter}`
|
|
}
|
|
|
|
const res = await fetch(url, { credentials: 'include' })
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setOrders(data.docs || [])
|
|
setPagination({
|
|
totalDocs: data.totalDocs || 0,
|
|
totalPages: data.totalPages || 1,
|
|
page: data.page || 1,
|
|
limit: data.limit || ITEMS_PER_PAGE,
|
|
hasNextPage: data.hasNextPage || false,
|
|
hasPrevPage: data.hasPrevPage || false,
|
|
})
|
|
} else if (res.status === 401) {
|
|
router.push('/caregiver/login')
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching orders:', err)
|
|
} finally {
|
|
setOrdersLoading(false)
|
|
}
|
|
}, [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')
|
|
return
|
|
}
|
|
const userData = await userRes.json()
|
|
if (!userData.user) {
|
|
router.push('/caregiver/login')
|
|
return
|
|
}
|
|
setUser(userData.user)
|
|
|
|
// Fetch residents
|
|
const residentsRes = await fetch('/api/residents?where[active][equals]=true&limit=500', {
|
|
credentials: 'include',
|
|
})
|
|
if (residentsRes.ok) {
|
|
const residentsData = await residentsRes.json()
|
|
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' }
|
|
)
|
|
if (statsRes.ok) {
|
|
const statsData = await statsRes.json()
|
|
const allOrders = statsData.docs || []
|
|
setStats({
|
|
draft: allOrders.filter((o: MealOrder) => o.status === 'draft').length,
|
|
submitted: allOrders.filter((o: MealOrder) => o.status === 'submitted').length,
|
|
preparing: allOrders.filter((o: MealOrder) => o.status === 'preparing').length,
|
|
completed: allOrders.filter((o: MealOrder) => o.status === 'completed').length,
|
|
total: allOrders.length,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
fetchInitialData()
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
if (!loading) {
|
|
fetchOrders(1)
|
|
}
|
|
}, [loading, fetchOrders])
|
|
|
|
const handleLogout = async () => {
|
|
await fetch('/api/users/logout', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
})
|
|
router.push('/caregiver/login')
|
|
}
|
|
|
|
const handleCreateOrder = async () => {
|
|
setCreating(true)
|
|
try {
|
|
const res = await fetch('/api/meal-orders', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
date: newOrderDate,
|
|
mealType: newOrderMealType,
|
|
status: 'draft',
|
|
}),
|
|
credentials: 'include',
|
|
})
|
|
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setCreateDialogOpen(false)
|
|
router.push(`/caregiver/orders/${data.doc.id}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Error creating order:', err)
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
const handleViewCoverage = async (order: MealOrder) => {
|
|
setSelectedOrder(order)
|
|
setCoverageDialogOpen(true)
|
|
setCoverageLoading(true)
|
|
|
|
try {
|
|
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 || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching meals:', err)
|
|
} finally {
|
|
setCoverageLoading(false)
|
|
}
|
|
}
|
|
|
|
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
|
|
const percentage = totalResidents > 0 ? Math.round((coveredCount / totalResidents) * 100) : 0
|
|
return { coveredCount, totalResidents, percentage }
|
|
}
|
|
|
|
const getCoverageColor = (percentage: number) => {
|
|
if (percentage === 100) return 'bg-green-500'
|
|
if (percentage >= 75) return 'bg-blue-500'
|
|
if (percentage >= 50) return 'bg-yellow-500'
|
|
return 'bg-red-500'
|
|
}
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
const tenantName =
|
|
user?.tenants?.[0]?.tenant && typeof user.tenants[0].tenant === 'object'
|
|
? 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))
|
|
)
|
|
const coveredResidents = residents.filter((r) => coveredResidentIds.has(r.id))
|
|
const uncoveredResidents = residents.filter((r) => !coveredResidentIds.has(r.id))
|
|
|
|
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">
|
|
<h1 className="text-xl font-semibold">{tenantName}</h1>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-sm text-muted-foreground">{user?.name || user?.email}</span>
|
|
<Button variant="outline" size="sm" onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
Logout
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="container py-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
|
|
<p className="text-muted-foreground">Manage meal orders for your care home</p>
|
|
</div>
|
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
New Meal Order
|
|
</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>
|
|
</div>
|
|
|
|
{/* Quick Actions */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Quick Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Link href="/caregiver/orders">
|
|
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
|
<CardContent className="flex flex-col items-center justify-center p-6">
|
|
<ClipboardList className="h-8 w-8 mb-2 text-muted-foreground" />
|
|
<span className="font-medium">All Orders</span>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
<Link href="/caregiver/residents">
|
|
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
|
<CardContent className="flex flex-col items-center justify-center p-6">
|
|
<Users className="h-8 w-8 mb-2 text-muted-foreground" />
|
|
<span className="font-medium">Residents</span>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
<Link href="/admin">
|
|
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
|
<CardContent className="flex flex-col items-center justify-center p-6">
|
|
<Settings className="h-8 w-8 mb-2 text-muted-foreground" />
|
|
<span className="font-medium">Admin Panel</span>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
<Card
|
|
className="cursor-pointer transition-colors hover:bg-accent"
|
|
onClick={() => setCreateDialogOpen(true)}
|
|
>
|
|
<CardContent className="flex flex-col items-center justify-center p-6">
|
|
<Plus className="h-8 w-8 mb-2 text-primary" />
|
|
<span className="font-medium">New Order</span>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Meal Orders Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<CardTitle>Meal Orders</CardTitle>
|
|
<CardDescription>
|
|
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
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
id="dateFilter"
|
|
value={dateFilter}
|
|
onChange={(e) => setDateFilter(e.target.value)}
|
|
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>
|
|
</SelectContent>
|
|
</Select>
|
|
{(dateFilter || statusFilter !== 'all') && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setDateFilter('')
|
|
setStatusFilter('all')
|
|
}}
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</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>
|
|
) : 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)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Create Order
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Meal</TableHead>
|
|
<TableHead>Date</TableHead>
|
|
<TableHead>Coverage</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{orders.map((order) => {
|
|
const coverage = getCoverageInfo(order)
|
|
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>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
{formatDate(order.date)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
className="w-full max-w-32 cursor-pointer"
|
|
onClick={() => handleViewCoverage(order)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Progress
|
|
value={coverage.percentage}
|
|
className="h-2 flex-1"
|
|
/>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{coverage.coveredCount}/{coverage.totalResidents}
|
|
</span>
|
|
</div>
|
|
{coverage.percentage < 100 && (
|
|
<div className="flex items-center gap-1 mt-1">
|
|
<AlertTriangle className="h-3 w-3 text-yellow-500" />
|
|
<span className="text-xs text-yellow-600">
|
|
{coverage.totalResidents - coverage.coveredCount} missing
|
|
</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Click to view coverage details</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge(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}`}>
|
|
<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>
|
|
)
|
|
})}
|
|
</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">
|
|
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
|
{Math.min(pagination.page * pagination.limit, pagination.totalDocs)} of{' '}
|
|
{pagination.totalDocs} orders
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fetchOrders(pagination.page - 1)}
|
|
disabled={!pagination.hasPrevPage || ordersLoading}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<div className="flex items-center gap-1">
|
|
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
|
|
let pageNum: number
|
|
if (pagination.totalPages <= 5) {
|
|
pageNum = i + 1
|
|
} else if (pagination.page <= 3) {
|
|
pageNum = i + 1
|
|
} else if (pagination.page >= pagination.totalPages - 2) {
|
|
pageNum = pagination.totalPages - 4 + i
|
|
} else {
|
|
pageNum = pagination.page - 2 + i
|
|
}
|
|
return (
|
|
<Button
|
|
key={pageNum}
|
|
variant={pagination.page === pageNum ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="w-8 h-8 p-0"
|
|
onClick={() => fetchOrders(pageNum)}
|
|
disabled={ordersLoading}
|
|
>
|
|
{pageNum}
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fetchOrders(pagination.page + 1)}
|
|
disabled={!pagination.hasNextPage || ordersLoading}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</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>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="newOrderDate">Date</Label>
|
|
<Input
|
|
type="date"
|
|
id="newOrderDate"
|
|
value={newOrderDate}
|
|
onChange={(e) => setNewOrderDate(e.target.value)}
|
|
/>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreateOrder} disabled={creating}>
|
|
{creating ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
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>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Users className="h-5 w-5" />
|
|
Resident Coverage
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedOrder && (
|
|
<>
|
|
{getMealTypeLabel(selectedOrder.mealType)} - {formatDate(selectedOrder.date)}
|
|
</>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{coverageLoading ? (
|
|
<div className="flex items-center justify-center p-8">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<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">
|
|
<span className="text-sm font-medium">Coverage Progress</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{coveredResidents.length}/{residents.length} residents
|
|
</span>
|
|
</div>
|
|
<Progress
|
|
value={
|
|
residents.length > 0
|
|
? (coveredResidents.length / residents.length) * 100
|
|
: 0
|
|
}
|
|
className="h-3"
|
|
/>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-center w-16 h-16 rounded-full text-white font-bold',
|
|
getCoverageColor(
|
|
residents.length > 0
|
|
? (coveredResidents.length / residents.length) * 100
|
|
: 0
|
|
)
|
|
)}
|
|
>
|
|
{residents.length > 0
|
|
? Math.round((coveredResidents.length / residents.length) * 100)
|
|
: 0}
|
|
%
|
|
</div>
|
|
</div>
|
|
|
|
{/* Uncovered Residents */}
|
|
{uncoveredResidents.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<UserX className="h-4 w-4 text-red-500" />
|
|
<h4 className="font-medium text-red-700">
|
|
Missing Meals ({uncoveredResidents.length})
|
|
</h4>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{uncoveredResidents.map((resident) => (
|
|
<div
|
|
key={resident.id}
|
|
className="flex items-center justify-between p-3 bg-red-50 border border-red-200 rounded-lg"
|
|
>
|
|
<div>
|
|
<div className="font-medium text-red-900">{resident.name}</div>
|
|
<div className="text-sm text-red-600">Room {resident.room}</div>
|
|
</div>
|
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Covered Residents */}
|
|
{coveredResidents.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<UserCheck className="h-4 w-4 text-green-500" />
|
|
<h4 className="font-medium text-green-700">
|
|
Covered Residents ({coveredResidents.length})
|
|
</h4>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{coveredResidents.map((resident) => (
|
|
<div
|
|
key={resident.id}
|
|
className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg"
|
|
>
|
|
<div>
|
|
<div className="font-medium text-green-900">{resident.name}</div>
|
|
<div className="text-sm text-green-600">Room {resident.room}</div>
|
|
</div>
|
|
<Check className="h-4 w-4 text-green-500" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
{selectedOrder?.status === 'draft' && (
|
|
<Button asChild>
|
|
<Link href={`/caregiver/orders/${selectedOrder.id}`}>
|
|
<Pencil className="mr-2 h-4 w-4" />
|
|
Edit Order
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={() => setCoverageDialogOpen(false)}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|