Files
NextEdu/src/modules/school/components/academic-year-view.tsx
SpecialX ebaf03107d feat(modules-admin): update scheduling, school, settings, standards, student, textbooks, users
- scheduling: update auto-schedule-panel, schedule-change-form, schedule-change-list,

  schedule-conflicts-view, scheduling-rules-form, data-access-class-schedule, data-access

- school: update academic-year-view, departments-view, grade-form-dialog, data-access

- settings: update admin-settings-view, ai-provider-settings-card, avatar-upload,

  brand-config-card, notification-preferences-form, password-change-form,

  profile-settings-form, security-recent-logins-section, security-two-factor-section

- standards: update data-access

- student: update student-courses-view

- textbooks: update chapter-sidebar-list, create-chapter-dialog, force-graph,

  graph-kp-node, graph-prerequisite-edge, knowledge-graph-node, textbook-card,

  textbook-form-dialog, textbook-reader, textbook-settings-dialog,

  data-access-graph, data-access, use-kp-create, use-kp-delete, use-kp-update, types

- users: update user-import-dialog
2026-07-07 16:22:07 +08:00

318 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import type { AcademicYearListItem } from "../types"
import { createAcademicYearAction, deleteAcademicYearAction, updateAcademicYearAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
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 { formatDate } from "@/shared/lib/utils"
const toDateInput = (iso: string) => iso.slice(0, 10)
export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) {
const t = useTranslations("school")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [createActive, setCreateActive] = useState(true)
const [editItem, setEditItem] = useState<AcademicYearListItem | null>(null)
const [editActive, setEditActive] = useState(false)
const [deleteItem, setDeleteItem] = useState<AcademicYearListItem | null>(null)
const activeYear = useMemo(() => years.find((y) => y.isActive) ?? null, [years])
const handleCreate = async (formData: FormData) => {
setIsWorking(true)
try {
formData.set("isActive", createActive ? "true" : "false")
const res = await createAcademicYearAction(undefined, formData)
if (res.success) {
notify.success(res.message)
setCreateOpen(false)
router.refresh()
} else {
notify.error(res.message || t("academicYear.delete.title"))
}
} catch {
notify.error(t("academicYear.delete.title"))
} finally {
setIsWorking(false)
}
}
const handleUpdate = async (formData: FormData) => {
if (!editItem) return
setIsWorking(true)
try {
formData.set("isActive", editActive ? "true" : "false")
const res = await updateAcademicYearAction(editItem.id, undefined, formData)
if (res.success) {
notify.success(res.message)
setEditItem(null)
router.refresh()
} else {
notify.error(res.message || t("academicYear.delete.title"))
}
} catch {
notify.error(t("academicYear.delete.title"))
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteAcademicYearAction(deleteItem.id)
if (res.success) {
notify.success(res.message)
setDeleteItem(null)
router.refresh()
} else {
notify.error(res.message || t("academicYear.delete.title"))
}
} catch {
notify.error(t("academicYear.delete.title"))
} finally {
setIsWorking(false)
}
}
return (
<>
<div className="flex justify-end">
<Button
onClick={() => {
setCreateActive(activeYear === null)
setCreateOpen(true)
}}
disabled={isWorking}
>
<Plus className="mr-2 h-4 w-4" />
{t("academicYear.new")}
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1 shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("academicYear.active")}</CardTitle>
</CardHeader>
<CardContent>
{activeYear ? (
<div className="space-y-2">
<div className="text-lg font-semibold">{activeYear.name}</div>
<div className="text-sm text-muted-foreground">
{formatDate(activeYear.startDate)} {formatDate(activeYear.endDate)}
</div>
<Badge variant="secondary">{t("academicYear.active")}</Badge>
</div>
) : (
<EmptyState
title={t("academicYear.empty.title")}
description={t("academicYear.empty.description")}
className="h-auto border-none shadow-none"
/>
)}
</CardContent>
</Card>
<Card className="lg:col-span-2 shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("academicYear.all")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{years.length}
</Badge>
</CardHeader>
<CardContent>
{years.length === 0 ? (
<EmptyState
title={t("academicYear.empty.title")}
description={t("academicYear.empty.description")}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("academicYear.column.name")}</TableHead>
<TableHead>{t("academicYear.column.startDate")}</TableHead>
<TableHead>{t("academicYear.column.status")}</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{years.map((y) => (
<TableRow key={y.id}>
<TableCell className="font-medium">{y.name}</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(y.startDate)} {formatDate(y.endDate)}
</TableCell>
<TableCell>{y.isActive ? <Badge variant="secondary">{t("academicYear.active")}</Badge> : <Badge variant="outline">-</Badge>}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isWorking}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(y)
setEditActive(y.isActive)
}}
>
<Pencil className="mr-2 h-4 w-4" />
{t("academicYear.actions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(y)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("academicYear.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("academicYear.form.createTitle")}</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">{t("academicYear.form.name")}</Label>
<Input id="name" name="name" placeholder={t("academicYear.form.namePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">{t("academicYear.form.startDate")}</Label>
<Input id="startDate" name="startDate" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="endDate">{t("academicYear.form.endDate")}</Label>
<Input id="endDate" name="endDate" type="date" />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={createActive} onCheckedChange={(v) => setCreateActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setCreateActive((v) => !v)}>
{t("academicYear.form.isActive")}
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
{t("academicYear.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("academicYear.form.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editItem)} onOpenChange={(open) => {
if (!open) setEditItem(null)
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("academicYear.form.editTitle")}</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">{t("academicYear.form.name")}</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-startDate">{t("academicYear.form.startDate")}</Label>
<Input id="edit-startDate" name="startDate" type="date" defaultValue={toDateInput(editItem.startDate)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-endDate">{t("academicYear.form.endDate")}</Label>
<Input id="edit-endDate" name="endDate" type="date" defaultValue={toDateInput(editItem.endDate)} />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={editActive} onCheckedChange={(v) => setEditActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setEditActive((v) => !v)}>
{t("academicYear.form.isActive")}
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
{t("academicYear.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("academicYear.form.save")}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteItem)} onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("academicYear.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>{t("academicYear.delete.description", { name: deleteItem?.name || "" })}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>{t("academicYear.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
{t("academicYear.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}