466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useMemo, useState } from "react"
|
||
import { useRouter } from "next/navigation"
|
||
import { Clock, MapPin, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||
import { toast } from "sonner"
|
||
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||
import { Badge } from "@/shared/components/ui/badge"
|
||
import { cn } from "@/shared/lib/utils"
|
||
import { Button } from "@/shared/components/ui/button"
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/shared/components/ui/dropdown-menu"
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/shared/components/ui/alert-dialog"
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/shared/components/ui/dialog"
|
||
import { Input } from "@/shared/components/ui/input"
|
||
import { Label } from "@/shared/components/ui/label"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||
import type { ClassScheduleItem, TeacherClass } from "../types"
|
||
import {
|
||
createClassScheduleItemAction,
|
||
deleteClassScheduleItemAction,
|
||
updateClassScheduleItemAction,
|
||
} from "../actions"
|
||
|
||
const WEEKDAYS: Array<{ key: ClassScheduleItem["weekday"]; label: string }> = [
|
||
{ key: 1, label: "Mon" },
|
||
{ key: 2, label: "Tue" },
|
||
{ key: 3, label: "Wed" },
|
||
{ key: 4, label: "Thu" },
|
||
{ key: 5, label: "Fri" },
|
||
{ key: 6, label: "Sat" },
|
||
{ key: 7, label: "Sun" },
|
||
]
|
||
|
||
export function ScheduleView({
|
||
schedule,
|
||
classes,
|
||
}: {
|
||
schedule: ClassScheduleItem[]
|
||
classes: TeacherClass[]
|
||
}) {
|
||
const router = useRouter()
|
||
const [isWorking, setIsWorking] = useState(false)
|
||
const [editItem, setEditItem] = useState<ClassScheduleItem | null>(null)
|
||
const [deleteItem, setDeleteItem] = useState<ClassScheduleItem | null>(null)
|
||
|
||
const [createWeekday, setCreateWeekday] = useState<ClassScheduleItem["weekday"]>(1)
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [createClassId, setCreateClassId] = useState<string>("")
|
||
|
||
const [editClassId, setEditClassId] = useState<string>("")
|
||
const [editWeekday, setEditWeekday] = useState<string>("1")
|
||
|
||
const classNameById = useMemo(() => new Map(classes.map((c) => [c.id, c.name] as const)), [classes])
|
||
const defaultClassId = useMemo(() => classes[0]?.id ?? "", [classes])
|
||
|
||
useEffect(() => {
|
||
if (!editItem) return
|
||
setEditClassId(editItem.classId)
|
||
setEditWeekday(String(editItem.weekday))
|
||
}, [editItem])
|
||
|
||
useEffect(() => {
|
||
if (!createOpen) return
|
||
setCreateClassId(defaultClassId)
|
||
}, [createOpen, defaultClassId])
|
||
|
||
const byDay = new Map<ClassScheduleItem["weekday"], ClassScheduleItem[]>()
|
||
for (const d of WEEKDAYS) byDay.set(d.key, [])
|
||
for (const item of schedule) byDay.get(item.weekday)?.push(item)
|
||
|
||
const handleCreate = async (formData: FormData) => {
|
||
setIsWorking(true)
|
||
try {
|
||
formData.set("classId", createClassId || defaultClassId)
|
||
formData.set("weekday", String(createWeekday))
|
||
const res = await createClassScheduleItemAction(null, formData)
|
||
if (res.success) {
|
||
toast.success(res.message)
|
||
setCreateOpen(false)
|
||
router.refresh()
|
||
} else {
|
||
toast.error(res.message || "Failed to create schedule item")
|
||
}
|
||
} catch {
|
||
toast.error("Failed to create schedule item")
|
||
} finally {
|
||
setIsWorking(false)
|
||
}
|
||
}
|
||
|
||
const handleUpdate = async (formData: FormData) => {
|
||
if (!editItem) return
|
||
setIsWorking(true)
|
||
try {
|
||
formData.set("classId", editClassId)
|
||
formData.set("weekday", editWeekday)
|
||
const res = await updateClassScheduleItemAction(editItem.id, null, formData)
|
||
if (res.success) {
|
||
toast.success(res.message)
|
||
setEditItem(null)
|
||
router.refresh()
|
||
} else {
|
||
toast.error(res.message || "Failed to update schedule item")
|
||
}
|
||
} catch {
|
||
toast.error("Failed to update schedule item")
|
||
} finally {
|
||
setIsWorking(false)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async () => {
|
||
if (!deleteItem) return
|
||
setIsWorking(true)
|
||
try {
|
||
const res = await deleteClassScheduleItemAction(deleteItem.id)
|
||
if (res.success) {
|
||
toast.success(res.message)
|
||
setDeleteItem(null)
|
||
router.refresh()
|
||
} else {
|
||
toast.error(res.message || "Failed to delete schedule item")
|
||
}
|
||
} catch {
|
||
toast.error("Failed to delete schedule item")
|
||
} finally {
|
||
setIsWorking(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||
{WEEKDAYS.map((d) => {
|
||
const items = byDay.get(d.key) ?? []
|
||
return (
|
||
<Card key={d.key} className="shadow-none">
|
||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||
<div className="flex items-center gap-2">
|
||
<CardTitle className="text-base">{d.label}</CardTitle>
|
||
<Badge variant="secondary" className={cn(items.length === 0 && "opacity-60")}>
|
||
{items.length} items
|
||
</Badge>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
disabled={classes.length === 0}
|
||
onClick={() => {
|
||
setCreateWeekday(d.key)
|
||
setCreateOpen(true)
|
||
}}
|
||
>
|
||
<Plus className="size-4" />
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{items.length === 0 ? (
|
||
<div className="text-muted-foreground text-sm">No classes scheduled.</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{items.map((item) => (
|
||
<div key={item.id} className="space-y-1 border-b pb-4 last:border-0 last:pb-0">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="font-medium leading-none">{item.course}</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline">{classNameById.get(item.classId) ?? "Class"}</Badge>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
|
||
<MoreHorizontal className="size-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => setEditItem(item)}>
|
||
<Pencil className="mr-2 size-4" />
|
||
Edit
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
className="text-destructive focus:text-destructive"
|
||
onClick={() => setDeleteItem(item)}
|
||
>
|
||
<Trash2 className="mr-2 size-4" />
|
||
Delete
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
<div className="text-muted-foreground flex items-center gap-3 text-sm">
|
||
<span className="inline-flex items-center gap-1 tabular-nums">
|
||
<Clock className="h-3.5 w-3.5" />
|
||
{item.startTime}–{item.endTime}
|
||
</span>
|
||
{item.location ? (
|
||
<span className="inline-flex items-center gap-1">
|
||
<MapPin className="h-3.5 w-3.5" />
|
||
{item.location}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
})}
|
||
|
||
<Dialog
|
||
open={createOpen}
|
||
onOpenChange={(v) => {
|
||
if (isWorking) return
|
||
setCreateOpen(v)
|
||
}}
|
||
>
|
||
<DialogContent className="sm:max-w-[560px]">
|
||
<DialogHeader>
|
||
<DialogTitle>Add schedule item</DialogTitle>
|
||
<DialogDescription>Create a class schedule entry.</DialogDescription>
|
||
</DialogHeader>
|
||
<form action={handleCreate}>
|
||
<div className="grid gap-4 py-4">
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label className="text-right">Class</Label>
|
||
<div className="col-span-3">
|
||
<Select value={createClassId} onValueChange={setCreateClassId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a class" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{classes.map((c) => (
|
||
<SelectItem key={c.id} value={c.id}>
|
||
{c.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<input type="hidden" name="classId" value={createClassId} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label className="text-right">Weekday</Label>
|
||
<Input value={WEEKDAYS.find((w) => w.key === createWeekday)?.label ?? ""} readOnly className="col-span-3" />
|
||
<input type="hidden" name="weekday" value={String(createWeekday)} />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="create-startTime" className="text-right">
|
||
Start
|
||
</Label>
|
||
<Input id="create-startTime" name="startTime" type="time" className="col-span-3" required />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="create-endTime" className="text-right">
|
||
End
|
||
</Label>
|
||
<Input id="create-endTime" name="endTime" type="time" className="col-span-3" required />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="create-course" className="text-right">
|
||
Course
|
||
</Label>
|
||
<Input id="create-course" name="course" className="col-span-3" placeholder="e.g. Math" required />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="create-location" className="text-right">
|
||
Location
|
||
</Label>
|
||
<Input id="create-location" name="location" className="col-span-3" placeholder="Optional" />
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button type="submit" disabled={isWorking || !createClassId}>
|
||
{isWorking ? "Creating..." : "Create"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog
|
||
open={Boolean(editItem)}
|
||
onOpenChange={(v) => {
|
||
if (isWorking) return
|
||
if (!v) setEditItem(null)
|
||
}}
|
||
>
|
||
<DialogContent className="sm:max-w-[560px]">
|
||
<DialogHeader>
|
||
<DialogTitle>Edit schedule item</DialogTitle>
|
||
<DialogDescription>Update this schedule entry.</DialogDescription>
|
||
</DialogHeader>
|
||
{editItem ? (
|
||
<form action={handleUpdate}>
|
||
<div className="grid gap-4 py-4">
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label className="text-right">Class</Label>
|
||
<div className="col-span-3">
|
||
<Select value={editClassId} onValueChange={setEditClassId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a class" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{classes.map((c) => (
|
||
<SelectItem key={c.id} value={c.id}>
|
||
{c.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<input type="hidden" name="classId" value={editClassId} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label className="text-right">Weekday</Label>
|
||
<div className="col-span-3">
|
||
<Select value={editWeekday} onValueChange={setEditWeekday}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select weekday" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="1">Mon</SelectItem>
|
||
<SelectItem value="2">Tue</SelectItem>
|
||
<SelectItem value="3">Wed</SelectItem>
|
||
<SelectItem value="4">Thu</SelectItem>
|
||
<SelectItem value="5">Fri</SelectItem>
|
||
<SelectItem value="6">Sat</SelectItem>
|
||
<SelectItem value="7">Sun</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<input type="hidden" name="weekday" value={editWeekday} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="edit-startTime" className="text-right">
|
||
Start
|
||
</Label>
|
||
<Input
|
||
id="edit-startTime"
|
||
name="startTime"
|
||
type="time"
|
||
className="col-span-3"
|
||
defaultValue={editItem.startTime}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="edit-endTime" className="text-right">
|
||
End
|
||
</Label>
|
||
<Input
|
||
id="edit-endTime"
|
||
name="endTime"
|
||
type="time"
|
||
className="col-span-3"
|
||
defaultValue={editItem.endTime}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="edit-course" className="text-right">
|
||
Course
|
||
</Label>
|
||
<Input
|
||
id="edit-course"
|
||
name="course"
|
||
className="col-span-3"
|
||
defaultValue={editItem.course}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-4 items-center gap-4">
|
||
<Label htmlFor="edit-location" className="text-right">
|
||
Location
|
||
</Label>
|
||
<Input
|
||
id="edit-location"
|
||
name="location"
|
||
className="col-span-3"
|
||
defaultValue={editItem.location ?? ""}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button type="submit" disabled={isWorking}>
|
||
{isWorking ? "Saving..." : "Save"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<AlertDialog
|
||
open={Boolean(deleteItem)}
|
||
onOpenChange={(v) => {
|
||
if (isWorking) return
|
||
if (!v) setDeleteItem(null)
|
||
}}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Delete schedule item?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
{deleteItem ? (
|
||
<>
|
||
This will permanently delete <span className="font-medium text-foreground">{deleteItem.course}</span>{" "}
|
||
({deleteItem.startTime}–{deleteItem.endTime}).
|
||
</>
|
||
) : null}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
onClick={handleDelete}
|
||
disabled={isWorking}
|
||
>
|
||
{isWorking ? "Deleting..." : "Delete"}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
)
|
||
}
|
||
|