feat: add OpenEduCat institutional LMS features + Odoo 19 Backend SRS

- Update ENCOACH_UNIFIED_SRS.md with 6 new feature sections:
  Academic Year/Term, Departments, Admission/Enrollment,
  Subject Registration, Institutional Exams, Assignment Submissions
- Add TypeScript types: academic.ts, admission.ts, institutional-exam.ts
- Add API services: academic, admission, institutional-exam
- Add TanStack Query hooks: useAcademic, useAdmissions, useInstitutionalExams
- Add 9 new pages: AcademicYearManager, DepartmentManager, AdmissionList,
  AdmissionDetail, AdmissionRegisterPage, AdmissionApplication,
  SubjectRegistrationPage, InstitutionalExamSessions, MarksheetManager
- Update App.tsx routes, AdminLmsLayout sidebar (Institutional group),
  StudentLayout sidebar (Subject Registration)
- Generate ENCOACH_ODOO19_BACKEND_SRS.md: complete Odoo 19 backend spec
  covering 200+ REST API endpoints, 31 modules, all OpenEduCat models

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-26 23:42:06 +04:00
parent 3fbbfd9348
commit 19d7e01be1
27 changed files with 4691 additions and 27 deletions

View File

@@ -70,6 +70,15 @@ import AdminSettings from "@/pages/admin/AdminSettings";
import AdminProfileLms from "@/pages/admin/AdminProfile";
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
import ResourceManager from "@/pages/admin/ResourceManager";
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
import DepartmentManager from "@/pages/admin/DepartmentManager";
import AdmissionList from "@/pages/admin/AdmissionList";
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
import MarksheetManager from "@/pages/admin/MarksheetManager";
import AdmissionApplication from "@/pages/AdmissionApplication";
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
import NotFound from "@/pages/NotFound";
import { queryClient } from "@/lib/query-client";
@@ -85,6 +94,8 @@ const App = () => (
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
{/* Public admission */}
<Route path="/apply" element={<AdmissionApplication />} />
{/* Student routes */}
<Route element={<ProtectedRoute allowedRoles={["student"]} />}>
@@ -102,6 +113,7 @@ const App = () => (
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
<Route path="/student/plan/:subjectId" element={<LearningPlanPage />} />
<Route path="/student/topic/:topicId" element={<TopicLearning />} />
<Route path="/student/subject-registration" element={<SubjectRegistrationPage />} />
</Route>
</Route>
@@ -163,6 +175,14 @@ const App = () => (
<Route path="/admin/exam" element={<ExamPage />} />
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
<Route path="/admin/resources" element={<ResourceManager />} />
{/* Institutional LMS pages */}
<Route path="/admin/academic-years" element={<AcademicYearManager />} />
<Route path="/admin/departments" element={<DepartmentManager />} />
<Route path="/admin/admissions" element={<AdmissionList />} />
<Route path="/admin/admissions/:id" element={<AdmissionDetail />} />
<Route path="/admin/admission-register" element={<AdmissionRegisterPage />} />
<Route path="/admin/exam-sessions" element={<InstitutionalExamSessions />} />
<Route path="/admin/marksheets" element={<MarksheetManager />} />
</Route>
</Route>

View File

@@ -26,6 +26,7 @@ import {
FileText, Layers, BookOpen, Wand2, GitBranch, Users, BarChart3,
Building2, History, CreditCard, Ticket, BookA, PenTool,
Calendar, ChevronDown, HelpCircle, LucideIcon, Target, FolderOpen,
CalendarDays, Landmark, UserPlus, ScrollText, Award,
} from "lucide-react";
import React from "react";
@@ -60,6 +61,15 @@ const adaptiveItems: NavItem[] = [
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
];
const institutionalItems: NavItem[] = [
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
{ title: "Departments", url: "/admin/departments", icon: Landmark },
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
];
const managementItems: NavItem[] = [
{ title: "Users", url: "/admin/users", icon: Users },
{ title: "Entities", url: "/admin/entities", icon: Building2 },
@@ -126,6 +136,9 @@ const routeLabels: Record<string, string> = {
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
profile: "Profile", exam: "Exam",
"academic-years": "Academic Years", departments: "Departments",
admissions: "Admissions", "admission-register": "Admission Register",
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
};
function AppBreadcrumbs() {
@@ -257,6 +270,7 @@ function AdminSidebar() {
<SidebarNavGroup label="Overview" items={overviewItems} />
<SidebarNavGroup label="LMS" items={lmsItems} />
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
<SidebarNavGroup label="Institutional" items={institutionalItems} />
<SidebarNavGroup label="Academic" items={academicItems} />
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}

View File

@@ -1,7 +1,7 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
} from "lucide-react";
const navGroups: NavGroup[] = [
@@ -10,6 +10,7 @@ const navGroups: NavGroup[] = [
items: [
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
],
},

View File

@@ -5,4 +5,7 @@ export * from "./useExams";
export * from "./useAssignments";
export * from "./useEntities";
export * from "./useAdaptive";
export * from "./useAcademic";
export * from "./useAdmissions";
export * from "./useInstitutionalExams";
export { usePermissions } from "../usePermissions";

View File

@@ -92,4 +92,65 @@ export const queryKeys = {
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
alerts: ["analytics", "alerts"] as const,
},
academicYears: {
all: ["academic-years"] as const,
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
detail: (id: number) => ["academic-years", id] as const,
},
academicTerms: {
all: ["academic-terms"] as const,
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
detail: (id: number) => ["academic-terms", id] as const,
},
departments: {
all: ["departments"] as const,
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
detail: (id: number) => ["departments", id] as const,
},
admissionRegisters: {
all: ["admission-registers"] as const,
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
detail: (id: number) => ["admission-registers", id] as const,
},
admissions: {
all: ["admissions"] as const,
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
detail: (id: number) => ["admissions", id] as const,
},
instExamSessions: {
all: ["inst-exam-sessions"] as const,
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
detail: (id: number) => ["inst-exam-sessions", id] as const,
},
examTypes: {
all: ["exam-types"] as const,
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
},
gradeConfigs: {
all: ["grade-configs"] as const,
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
},
resultTemplates: {
all: ["result-templates"] as const,
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
},
marksheets: {
all: ["marksheets"] as const,
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
detail: (id: number) => ["marksheets", id] as const,
},
courseAssignments: {
all: ["course-assignments"] as const,
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
detail: (id: number) => ["course-assignments", id] as const,
},
assignmentTypes: {
all: ["assignment-types"] as const,
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
},
subjectRegistrations: {
all: ["subject-registrations"] as const,
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
},
};

View File

@@ -0,0 +1,104 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { academicService } from "@/services/academic.service";
import type { PaginationParams } from "@/types";
import type {
AcademicYearCreateRequest,
AcademicTermCreateRequest,
DepartmentCreateRequest,
} from "@/types/academic";
export function useAcademicYears(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.academicYears.list(params),
queryFn: () => academicService.listAcademicYears(params),
});
}
export function useAcademicYear(id: number) {
return useQuery({
queryKey: queryKeys.academicYears.detail(id),
queryFn: () => academicService.getAcademicYear(id),
enabled: !!id,
});
}
export function useAcademicTerms(yearId?: number) {
return useQuery({
queryKey: queryKeys.academicTerms.list({ year_id: yearId }),
queryFn: () => academicService.listTerms({ year_id: yearId }),
});
}
export function useDepartments(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.departments.list(params),
queryFn: () => academicService.listDepartments(params),
});
}
export function useCreateAcademicYear() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AcademicYearCreateRequest) => academicService.createAcademicYear(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicYears.all }),
});
}
export function useUpdateAcademicYear() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<AcademicYearCreateRequest> }) =>
academicService.updateAcademicYear(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicYears.all }),
});
}
export function useDeleteAcademicYear() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => academicService.deleteAcademicYear(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicYears.all }),
});
}
export function useGenerateTerms() {
const qc = useQueryClient();
return useMutation({
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
});
}
export function useCreateAcademicTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AcademicTermCreateRequest) => academicService.createTerm(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
});
}
export function useCreateDepartment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: DepartmentCreateRequest) => academicService.createDepartment(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.departments.all }),
});
}
export function useUpdateDepartment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<DepartmentCreateRequest> }) =>
academicService.updateDepartment(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.departments.all }),
});
}
export function useDeleteDepartment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => academicService.deleteDepartment(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.departments.all }),
});
}

View File

@@ -0,0 +1,94 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { admissionService } from "@/services/admission.service";
import type { PaginationParams } from "@/types";
import type {
AdmissionRegisterCreateRequest,
AdmissionCreateRequest,
} from "@/types/admission";
export function useAdmissionRegisters(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.admissionRegisters.list(params),
queryFn: () => admissionService.listRegisters(params),
});
}
export function useAdmissions(params?: PaginationParams & { state?: string; register_id?: number; course_id?: number }) {
return useQuery({
queryKey: queryKeys.admissions.list(params),
queryFn: () => admissionService.listAdmissions(params),
});
}
export function useAdmission(id: number) {
return useQuery({
queryKey: queryKeys.admissions.detail(id),
queryFn: () => admissionService.getAdmission(id),
enabled: !!id,
});
}
export function useCreateAdmissionRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AdmissionRegisterCreateRequest) => admissionService.createRegister(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissionRegisters.all }),
});
}
export function useConfirmRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.confirmRegister(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissionRegisters.all }),
});
}
export function useCloseRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.closeRegister(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissionRegisters.all }),
});
}
export function useCreateAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AdmissionCreateRequest) => admissionService.createAdmission(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useSubmitAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.submitAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useConfirmAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.confirmAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useAdmitAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.admitAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useRejectAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.rejectAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}

View File

@@ -0,0 +1,199 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { institutionalExamService } from "@/services/institutional-exam.service";
import type { PaginationParams } from "@/types";
import type {
InstExamSessionCreateRequest,
InstExamCreateRequest,
ResultTemplateCreateRequest,
CourseAssignmentCreateRequest,
SubjectRegistrationCreateRequest,
AssignmentSubmission,
} from "@/types/institutional-exam";
export function useInstitutionalExamSessions(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.instExamSessions.list(params),
queryFn: () => institutionalExamService.listSessions(params),
});
}
export function useInstitutionalExamSession(id: number) {
return useQuery({
queryKey: queryKeys.instExamSessions.detail(id),
queryFn: () => institutionalExamService.getSession(id),
enabled: !!id,
});
}
export function useExamTypes(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.examTypes.list(params),
queryFn: () => institutionalExamService.listExamTypes(params),
});
}
export function useGradeConfigs(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.gradeConfigs.list(params),
queryFn: () => institutionalExamService.listGradeConfigs(params),
});
}
export function useResultTemplates(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.resultTemplates.list(params),
queryFn: () => institutionalExamService.listResultTemplates(params),
});
}
export function useMarksheets(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.marksheets.list(params),
queryFn: () => institutionalExamService.listMarksheets(params),
});
}
export function useMarksheet(id: number) {
return useQuery({
queryKey: queryKeys.marksheets.detail(id),
queryFn: () => institutionalExamService.getMarksheet(id),
enabled: !!id,
});
}
export function useCourseAssignments(params?: PaginationParams & { course_id?: number; batch_id?: number; state?: string }) {
return useQuery({
queryKey: queryKeys.courseAssignments.list(params),
queryFn: () => institutionalExamService.listCourseAssignments(params),
});
}
export function useCourseAssignment(id: number) {
return useQuery({
queryKey: queryKeys.courseAssignments.detail(id),
queryFn: () => institutionalExamService.getCourseAssignment(id),
enabled: !!id,
});
}
export function useSubjectRegistrations(params?: PaginationParams & { student_id?: number; course_id?: number; state?: string }) {
return useQuery({
queryKey: queryKeys.subjectRegistrations.list(params),
queryFn: () => institutionalExamService.listSubjectRegistrations(params),
});
}
export function useAvailableSubjects(params?: { student_id?: number; course_id?: number }) {
return useQuery({
queryKey: queryKeys.subjectRegistrations.available(params),
queryFn: () => institutionalExamService.getAvailableSubjects(params),
});
}
export function useCreateExamSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: InstExamSessionCreateRequest) => institutionalExamService.createSession(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useScheduleExamSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.scheduleSession(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useCreateInstitutionalExam() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: InstExamCreateRequest) => institutionalExamService.createExam(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useEnterMarks() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, data }: { examId: number; data: { marks: { student_id: number; score: number }[] } }) =>
institutionalExamService.enterMarks(examId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useCreateResultTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ResultTemplateCreateRequest) => institutionalExamService.createResultTemplate(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.resultTemplates.all }),
});
}
export function useGenerateMarksheets() {
const qc = useQueryClient();
return useMutation({
mutationFn: (templateId: number) => institutionalExamService.generateMarksheets(templateId),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.marksheets.all }),
});
}
export function useValidateMarksheet() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.validateMarksheet(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.marksheets.all }),
});
}
export function useCreateCourseAssignment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CourseAssignmentCreateRequest) => institutionalExamService.createCourseAssignment(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function usePublishCourseAssignment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.publishCourseAssignment(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function useSubmitAssignment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ assignmentId, file }: { assignmentId: number; file: File }) =>
institutionalExamService.submitAssignment(assignmentId, file),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function useGradeSubmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ submissionId, data }: { submissionId: number; data: Partial<AssignmentSubmission> }) =>
institutionalExamService.gradeSubmission(submissionId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function useCreateSubjectRegistration() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: SubjectRegistrationCreateRequest) => institutionalExamService.createSubjectRegistration(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.subjectRegistrations.all }),
});
}
export function useConfirmSubjectRegistration() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.confirmSubjectRegistration(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.subjectRegistrations.all }),
});
}

View File

@@ -0,0 +1,205 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useToast } from "@/hooks/use-toast";
import type { AdmissionCreateRequest } from "@/types/admission";
type Step = "register" | "personal" | "review" | "success";
export default function AdmissionApplication() {
const { toast } = useToast();
const [step, setStep] = useState<Step>("register");
const [selectedRegisterId, setSelectedRegisterId] = useState<number>(0);
const [form, setForm] = useState<Omit<AdmissionCreateRequest, "register_id">>({
first_name: "",
last_name: "",
email: "",
gender: "m",
});
const [applicationNumber, setApplicationNumber] = useState("");
const { data: registersData, isLoading: loadingRegisters } = useQuery({
queryKey: ["admission-registers", "list"],
queryFn: () => admissionService.listRegisters(),
});
const registers = registersData?.items ?? [];
const selectedRegister = registers.find((r: { id: number }) => r.id === selectedRegisterId);
const submitMutation = useMutation({
mutationFn: async () => {
const admission = await admissionService.createAdmission({ ...form, register_id: selectedRegisterId });
await admissionService.submitAdmission(admission.id);
return admission;
},
onSuccess: (data) => {
setApplicationNumber(data.application_number);
setStep("success");
},
onError: () => toast({ title: "Error", description: "Submission failed. Please try again.", variant: "destructive" }),
});
const canProceedFromRegister = selectedRegisterId > 0;
const canProceedFromPersonal = form.first_name && form.last_name && form.email;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-lg">
{step !== "success" && (
<div className="flex items-center justify-center gap-2 mb-8">
{["register", "personal", "review"].map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s === step ? "bg-primary text-primary-foreground" :
["register", "personal", "review"].indexOf(step) > i ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
}`}>
{i + 1}
</div>
{i < 2 && <div className="w-12 h-px bg-border" />}
</div>
))}
</div>
)}
{step === "register" && (
<Card>
<CardHeader>
<CardTitle>Select Course</CardTitle>
<CardDescription>Choose the admission register you want to apply to.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadingRegisters ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-2">
{registers
.filter((r: { state: string }) => r.state === "confirm")
.map((r: { id: number; name: string; course_name: string; start_date: string; end_date: string }) => (
<button
key={r.id}
onClick={() => setSelectedRegisterId(r.id)}
className={`w-full text-left p-4 rounded-lg border transition-colors ${selectedRegisterId === r.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}
>
<p className="font-medium">{r.name}</p>
<p className="text-sm text-muted-foreground">{r.course_name} · {r.start_date} {r.end_date}</p>
</button>
))}
{registers.filter((r: { state: string }) => r.state === "confirm").length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No open admissions available.</p>
)}
</div>
)}
<Button className="w-full" onClick={() => setStep("personal")} disabled={!canProceedFromRegister}>
Next
</Button>
</CardContent>
</Card>
)}
{step === "personal" && (
<Card>
<CardHeader>
<CardTitle>Personal Information</CardTitle>
<CardDescription>Fill in your personal details.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>First Name *</Label>
<Input value={form.first_name} onChange={e => setForm({ ...form, first_name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Last Name *</Label>
<Input value={form.last_name} onChange={e => setForm({ ...form, last_name: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Middle Name</Label>
<Input value={form.middle_name ?? ""} onChange={e => setForm({ ...form, middle_name: e.target.value || undefined })} />
</div>
<div className="space-y-2">
<Label>Email *</Label>
<Input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone ?? ""} onChange={e => setForm({ ...form, phone: e.target.value || undefined })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Birth Date</Label>
<Input type="date" value={form.birth_date ?? ""} onChange={e => setForm({ ...form, birth_date: e.target.value || undefined })} />
</div>
<div className="space-y-2">
<Label>Gender *</Label>
<Select value={form.gender} onValueChange={v => setForm({ ...form, gender: v as "m" | "f" | "o" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="m">Male</SelectItem>
<SelectItem value="f">Female</SelectItem>
<SelectItem value="o">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setStep("register")}>Back</Button>
<Button className="flex-1" onClick={() => setStep("review")} disabled={!canProceedFromPersonal}>Next</Button>
</div>
</CardContent>
</Card>
)}
{step === "review" && (
<Card>
<CardHeader>
<CardTitle>Review & Submit</CardTitle>
<CardDescription>Please review your application before submitting.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium">Course</p>
<p className="text-sm text-muted-foreground">{selectedRegister?.name} {selectedRegister?.course_name}</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium">Applicant</p>
<p className="text-sm text-muted-foreground">{form.first_name} {form.middle_name ?? ""} {form.last_name}</p>
<p className="text-sm text-muted-foreground">{form.email} {form.phone ? `· ${form.phone}` : ""}</p>
<p className="text-sm text-muted-foreground capitalize">{form.gender === "m" ? "Male" : form.gender === "f" ? "Female" : "Other"} {form.birth_date ? `· ${form.birth_date}` : ""}</p>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setStep("personal")}>Back</Button>
<Button className="flex-1" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>
{submitMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Application
</Button>
</div>
</CardContent>
</Card>
)}
{step === "success" && (
<Card>
<CardContent className="pt-8 pb-8 text-center space-y-4">
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto" />
<div>
<h2 className="text-xl font-bold">Application Submitted!</h2>
<p className="text-muted-foreground mt-1">Your application has been received.</p>
</div>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm text-muted-foreground">Application Number</p>
<p className="text-2xl font-bold">{applicationNumber}</p>
</div>
<p className="text-sm text-muted-foreground">Please save this number for future reference.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, ChevronRight, RefreshCw, Loader2 } from "lucide-react";
import {
useAcademicYears,
useCreateAcademicYear,
useDeleteAcademicYear,
useGenerateTerms,
useAcademicTerms,
} from "@/hooks/queries/useAcademic";
import { useToast } from "@/hooks/use-toast";
import type { AcademicYear, AcademicYearCreateRequest } from "@/types/academic";
const TERM_STRUCTURES: { value: AcademicYear["term_structure"]; label: string }[] = [
{ value: "two_sem", label: "Two Semesters" },
{ value: "two_sem_qua", label: "Two Semesters + Quarter" },
{ value: "three_sem", label: "Three Semesters" },
{ value: "four_Quarter", label: "Four Quarters" },
{ value: "final_year", label: "Final Year" },
{ value: "others", label: "Others" },
];
export default function AcademicYearManager() {
const { toast } = useToast();
const { data: yearsData, isLoading } = useAcademicYears();
const years = yearsData?.items ?? [];
const createYear = useCreateAcademicYear();
const deleteYear = useDeleteAcademicYear();
const generateTerms = useGenerateTerms();
const [showCreate, setShowCreate] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [form, setForm] = useState<AcademicYearCreateRequest>({
name: "",
start_date: "",
end_date: "",
term_structure: "two_sem",
});
const { data: termsData } = useAcademicTerms(expandedId ?? undefined);
const terms = termsData?.items ?? [];
const handleCreate = () => {
createYear.mutate(form, {
onSuccess: () => {
toast({ title: "Academic year created" });
setShowCreate(false);
setForm({ name: "", start_date: "", end_date: "", term_structure: "two_sem" });
},
onError: () => toast({ title: "Error", description: "Failed to create academic year", variant: "destructive" }),
});
};
const handleDelete = (id: number) => {
deleteYear.mutate(id, {
onSuccess: () => toast({ title: "Academic year deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
};
const handleGenerateTerms = (yearId: number) => {
generateTerms.mutate(yearId, {
onSuccess: () => toast({ title: "Terms generated" }),
onError: () => toast({ title: "Error", description: "Failed to generate terms", variant: "destructive" }),
});
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Academic Years</h1>
<p className="text-muted-foreground">Manage academic years and their terms.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Academic Year</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Academic Year</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. 2025-2026" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Term Structure</Label>
<Select value={form.term_structure} onValueChange={v => setForm({ ...form, term_structure: v as AcademicYear["term_structure"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TERM_STRUCTURES.map(ts => (
<SelectItem key={ts.value} value={ts.value}>{ts.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createYear.isPending || !form.name || !form.start_date || !form.end_date}>
{createYear.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead />
<TableHead>Name</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Term Structure</TableHead>
<TableHead>Terms</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{years.map((year: AcademicYear) => (
<Collapsible key={year.id} asChild open={expandedId === year.id} onOpenChange={open => setExpandedId(open ? year.id : null)}>
<>
<TableRow>
<TableCell>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ChevronRight className={`h-4 w-4 transition-transform ${expandedId === year.id ? "rotate-90" : ""}`} />
</Button>
</CollapsibleTrigger>
</TableCell>
<TableCell className="font-medium">{year.name}</TableCell>
<TableCell>{year.start_date}</TableCell>
<TableCell>{year.end_date}</TableCell>
<TableCell>
<Badge variant="outline">{TERM_STRUCTURES.find(t => t.value === year.term_structure)?.label ?? year.term_structure}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{year.terms?.length ?? 0}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleGenerateTerms(year.id)} disabled={generateTerms.isPending}>
<RefreshCw className="mr-1 h-3 w-3" /> Generate Terms
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(year.id)} disabled={deleteYear.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<TableRow>
<TableCell colSpan={7} className="bg-muted/50 p-4">
{expandedId === year.id && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Terms</h4>
{terms.length > 0 ? (
<div className="grid gap-2">
{terms.map(term => (
<div key={term.id} className="flex items-center justify-between rounded border p-3 bg-background">
<span className="text-sm font-medium">{term.name}</span>
<span className="text-sm text-muted-foreground">{term.term_start_date} {term.term_end_date}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No terms yet. Click "Generate Terms" to auto-create.</p>
)}
</div>
)}
</TableCell>
</TableRow>
</CollapsibleContent>
</>
</Collapsible>
))}
{years.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No academic years found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useToast } from "@/hooks/use-toast";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
submit: "secondary",
confirm: "default",
admission: "default",
reject: "destructive",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const qc = useQueryClient();
const admissionId = Number(id);
const { data: admission, isLoading } = useQuery({
queryKey: ["admissions", admissionId],
queryFn: () => admissionService.getAdmission(admissionId),
enabled: !!admissionId,
});
const makeTransition = (action: "submit" | "confirm" | "admit" | "reject") => {
const fns = {
submit: admissionService.submitAdmission,
confirm: admissionService.confirmAdmission,
admit: admissionService.admitAdmission,
reject: admissionService.rejectAdmission,
};
return fns[action];
};
const transitionMutation = useMutation({
mutationFn: ({ action }: { action: "submit" | "confirm" | "admit" | "reject" }) =>
makeTransition(action)(admissionId),
onSuccess: (_, { action }) => {
qc.invalidateQueries({ queryKey: ["admissions"] });
toast({ title: `Admission ${action}${action === "submit" ? "ted" : action === "reject" ? "ed" : "ed"}` });
},
onError: () => toast({ title: "Error", description: "Action failed", variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
if (!admission) return <div className="text-center py-12 text-muted-foreground">Admission not found.</div>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>
<p className="text-muted-foreground">Application #{admission.application_number}</p>
</div>
<Badge variant={stateBadgeVariant[admission.state] ?? "outline"} className="capitalize text-sm px-3 py-1">
{admission.state}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>Applicant Information</CardTitle></CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Full Name</dt>
<dd className="text-sm font-medium">{admission.first_name} {admission.middle_name ?? ""} {admission.last_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Email</dt>
<dd className="text-sm font-medium">{admission.email}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Phone</dt>
<dd className="text-sm font-medium">{admission.phone ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Birth Date</dt>
<dd className="text-sm font-medium">{admission.birth_date ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Gender</dt>
<dd className="text-sm font-medium capitalize">{admission.gender === "m" ? "Male" : admission.gender === "f" ? "Female" : "Other"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Nationality</dt>
<dd className="text-sm font-medium">{admission.nationality_name ?? "—"}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Application Details</CardTitle></CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Course</dt>
<dd className="text-sm font-medium">{admission.course_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Register</dt>
<dd className="text-sm font-medium">{admission.register_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Batch</dt>
<dd className="text-sm font-medium">{admission.batch_name ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Fees</dt>
<dd className="text-sm font-medium">{admission.fees}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Applied On</dt>
<dd className="text-sm font-medium">{new Date(admission.created_at).toLocaleDateString()}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
<Card>
<CardHeader><CardTitle>Actions</CardTitle></CardHeader>
<CardContent>
<div className="flex items-center gap-3">
{admission.state === "draft" && (
<Button onClick={() => transitionMutation.mutate({ action: "submit" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Application
</Button>
)}
{admission.state === "submit" && (
<>
<Button onClick={() => transitionMutation.mutate({ action: "confirm" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm
</Button>
<Button variant="destructive" onClick={() => transitionMutation.mutate({ action: "reject" })} disabled={transitionMutation.isPending}>
Reject
</Button>
</>
)}
{admission.state === "confirm" && (
<>
<Button onClick={() => transitionMutation.mutate({ action: "admit" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Admit
</Button>
<Button variant="destructive" onClick={() => transitionMutation.mutate({ action: "reject" })} disabled={transitionMutation.isPending}>
Reject
</Button>
</>
)}
{["admission", "reject", "done"].includes(admission.state) && (
<p className="text-sm text-muted-foreground">No further actions available.</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import type { Admission, AdmissionState } from "@/types/admission";
const STATE_TABS: { value: string; label: string }[] = [
{ value: "all", label: "All" },
{ value: "draft", label: "Draft" },
{ value: "submit", label: "Submitted" },
{ value: "confirm", label: "Confirmed" },
{ value: "admission", label: "Admitted" },
{ value: "reject", label: "Rejected" },
];
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
submit: "secondary",
confirm: "default",
admission: "default",
reject: "destructive",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionList() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [stateFilter, setStateFilter] = useState("all");
const { data, isLoading } = useQuery({
queryKey: ["admissions", "list", { search, state: stateFilter === "all" ? undefined : stateFilter }],
queryFn: () => admissionService.listAdmissions({
search: search || undefined,
state: stateFilter === "all" ? undefined : stateFilter,
} as Parameters<typeof admissionService.listAdmissions>[0]),
});
const admissions = data?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Admissions</h1>
<p className="text-muted-foreground">View and manage student admission applications.</p>
</div>
<div className="flex items-center gap-2">
{STATE_TABS.map(tab => (
<Button
key={tab.value}
variant={stateFilter === tab.value ? "default" : "outline"}
size="sm"
onClick={() => setStateFilter(tab.value)}
>
{tab.label}
</Button>
))}
</div>
<Card>
<CardHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search by name..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Application #</TableHead>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Register</TableHead>
<TableHead>State</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{admissions.map((adm: Admission) => (
<TableRow key={adm.id} className="cursor-pointer hover:bg-accent/50" onClick={() => navigate(`/admin/admissions/${adm.id}`)}>
<TableCell className="font-medium">{adm.application_number}</TableCell>
<TableCell>{adm.first_name} {adm.last_name}</TableCell>
<TableCell>{adm.course_name}</TableCell>
<TableCell className="text-muted-foreground">{adm.register_name}</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[adm.state] ?? "outline"} className="capitalize">{adm.state}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{new Date(adm.created_at).toLocaleDateString()}</TableCell>
</TableRow>
))}
{admissions.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No admissions found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useCourses } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { AdmissionRegister, AdmissionRegisterCreateRequest } from "@/types/admission";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
confirm: "default",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionRegisterPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data: registersData, isLoading } = useQuery({
queryKey: ["admission-registers", "list"],
queryFn: () => admissionService.listRegisters(),
});
const registers = registersData?.items ?? [];
const { data: coursesData } = useCourses();
const courses = coursesData?.items ?? [];
const [form, setForm] = useState<AdmissionRegisterCreateRequest>({
name: "",
course_id: 0,
start_date: "",
end_date: "",
min_count: undefined,
max_count: undefined,
});
const createMutation = useMutation({
mutationFn: (data: AdmissionRegisterCreateRequest) => admissionService.createRegister(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admission-registers"] });
toast({ title: "Register created" });
setShowCreate(false);
setForm({ name: "", course_id: 0, start_date: "", end_date: "" });
},
onError: () => toast({ title: "Error", description: "Failed to create register", variant: "destructive" }),
});
const confirmMutation = useMutation({
mutationFn: (id: number) => admissionService.confirmRegister(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admission-registers"] }); toast({ title: "Register confirmed" }); },
onError: () => toast({ title: "Error", description: "Failed to confirm", variant: "destructive" }),
});
const closeMutation = useMutation({
mutationFn: (id: number) => admissionService.closeRegister(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admission-registers"] }); toast({ title: "Register closed" }); },
onError: () => toast({ title: "Error", description: "Failed to close", variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Admission Registers</h1>
<p className="text-muted-foreground">Manage admission registers and their status.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Register</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Admission Register</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Fall 2025 Intake" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id ? form.course_id.toString() : ""} onValueChange={v => setForm({ ...form, course_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map((c: { id: number; name: string }) => (
<SelectItem key={c.id} value={c.id.toString()}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Min Count</Label>
<Input type="number" value={form.min_count ?? ""} onChange={e => setForm({ ...form, min_count: e.target.value ? Number(e.target.value) : undefined })} />
</div>
<div className="space-y-2">
<Label>Max Count</Label>
<Input type="number" value={form.max_count ?? ""} onChange={e => setForm({ ...form, max_count: e.target.value ? Number(e.target.value) : undefined })} />
</div>
</div>
<Button className="w-full" onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.name || !form.course_id}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Register
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Applications</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{registers.map((reg: AdmissionRegister) => (
<TableRow key={reg.id}>
<TableCell className="font-medium">{reg.name}</TableCell>
<TableCell>{reg.course_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reg.start_date} {reg.end_date}</TableCell>
<TableCell>{reg.min_count}{reg.max_count}</TableCell>
<TableCell><Badge variant="secondary">{reg.application_count}</Badge></TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[reg.state] ?? "outline"} className="capitalize">{reg.state}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{reg.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => confirmMutation.mutate(reg.id)} disabled={confirmMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Confirm
</Button>
)}
{reg.state === "confirm" && (
<Button variant="ghost" size="sm" onClick={() => closeMutation.mutate(reg.id)} disabled={closeMutation.isPending}>
<XCircle className="mr-1 h-3 w-3" /> Close
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
{registers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No admission registers found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import {
useDepartments,
useCreateDepartment,
useUpdateDepartment,
useDeleteDepartment,
} from "@/hooks/queries/useAcademic";
import { useToast } from "@/hooks/use-toast";
import type { Department, DepartmentCreateRequest } from "@/types/academic";
export default function DepartmentManager() {
const { toast } = useToast();
const { data: deptsData, isLoading } = useDepartments();
const depts = deptsData?.items ?? [];
const createDept = useCreateDepartment();
const updateDept = useUpdateDepartment();
const deleteDept = useDeleteDepartment();
const [showDialog, setShowDialog] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [form, setForm] = useState<DepartmentCreateRequest>({ name: "", code: "" });
const openCreate = () => {
setEditing(null);
setForm({ name: "", code: "" });
setShowDialog(true);
};
const openEdit = (dept: Department) => {
setEditing(dept);
setForm({ name: dept.name, code: dept.code, parent_id: dept.parent_id });
setShowDialog(true);
};
const handleSave = () => {
if (editing) {
updateDept.mutate({ id: editing.id, data: form }, {
onSuccess: () => { toast({ title: "Department updated" }); setShowDialog(false); },
onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
});
} else {
createDept.mutate(form, {
onSuccess: () => { toast({ title: "Department created" }); setShowDialog(false); setForm({ name: "", code: "" }); },
onError: () => toast({ title: "Error", description: "Failed to create", variant: "destructive" }),
});
}
};
const handleDelete = (id: number) => {
deleteDept.mutate(id, {
onSuccess: () => toast({ title: "Department deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
};
const isSaving = createDept.isPending || updateDept.isPending;
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Departments</h1>
<p className="text-muted-foreground">Manage departments and their hierarchy.</p>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button onClick={openCreate}><Plus className="mr-2 h-4 w-4" /> Add Department</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{editing ? "Edit Department" : "Create Department"}</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Computer Science" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input placeholder="e.g. CS" value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Parent Department</Label>
<Select value={form.parent_id?.toString() ?? "none"} onValueChange={v => setForm({ ...form, parent_id: v === "none" ? undefined : Number(v) })}>
<SelectTrigger><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{depts.filter(d => d.id !== editing?.id).map(d => (
<SelectItem key={d.id} value={d.id.toString()}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleSave} disabled={isSaving || !form.name || !form.code}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editing ? "Update" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Parent</TableHead>
<TableHead>Courses</TableHead>
<TableHead>Faculty</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{depts.map((dept: Department) => (
<TableRow key={dept.id}>
<TableCell className="font-medium">{dept.name}</TableCell>
<TableCell>{dept.code}</TableCell>
<TableCell className="text-muted-foreground">{dept.parent_name ?? "—"}</TableCell>
<TableCell>{dept.course_count}</TableCell>
<TableCell>{dept.faculty_count}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(dept)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(dept.id)} disabled={deleteDept.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{depts.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No departments found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, ChevronRight, CalendarCheck, CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useCourses, useBatches } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { InstitutionalExamSession, InstitutionalExam } from "@/types/institutional-exam";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
schedule: "secondary",
held: "default",
cancel: "destructive",
done: "default",
};
export default function InstitutionalExamSessions() {
const { toast } = useToast();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const { data: sessionsData, isLoading } = useQuery({
queryKey: ["inst-exam-sessions", "list"],
queryFn: () => institutionalExamService.listSessions(),
});
const sessions = sessionsData?.items ?? [];
const { data: expandedSession } = useQuery({
queryKey: ["inst-exam-sessions", expandedId],
queryFn: () => institutionalExamService.getSession(expandedId!),
enabled: !!expandedId,
});
const { data: examTypesData } = useQuery({
queryKey: ["exam-types", "list"],
queryFn: () => institutionalExamService.listExamTypes(),
});
const examTypes = examTypesData?.items ?? [];
const { data: coursesData } = useCourses();
const courses = coursesData?.items ?? [];
const { data: batchesData } = useBatches();
const batches = batchesData?.items ?? [];
const [form, setForm] = useState({
name: "",
course_id: 0,
batch_id: 0,
start_date: "",
end_date: "",
exam_type_id: 0,
evaluation_type: "normal" as "normal" | "grade",
});
const createMutation = useMutation({
mutationFn: () => institutionalExamService.createSession(form as Parameters<typeof institutionalExamService.createSession>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] });
toast({ title: "Exam session created" });
setShowCreate(false);
setForm({ name: "", course_id: 0, batch_id: 0, start_date: "", end_date: "", exam_type_id: 0, evaluation_type: "normal" });
},
onError: () => toast({ title: "Error", description: "Failed to create session", variant: "destructive" }),
});
const scheduleMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.scheduleSession(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] }); toast({ title: "Session scheduled" }); },
onError: () => toast({ title: "Error", description: "Failed to schedule", variant: "destructive" }),
});
const doneMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.markSessionDone(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] }); toast({ title: "Session marked as done" }); },
onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
});
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Exam Sessions</h1>
<p className="text-muted-foreground">Manage institutional exam sessions and their exams.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Session</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Exam Session</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mid-Term Exams 2025" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id ? form.course_id.toString() : ""} onValueChange={v => setForm({ ...form, course_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{courses.map((c: { id: number; name: string }) => (
<SelectItem key={c.id} value={c.id.toString()}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Batch</Label>
<Select value={form.batch_id ? form.batch_id.toString() : ""} onValueChange={v => setForm({ ...form, batch_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{batches.map((b: { id: number; name: string }) => (
<SelectItem key={b.id} value={b.id.toString()}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Exam Type</Label>
<Select value={form.exam_type_id ? form.exam_type_id.toString() : ""} onValueChange={v => setForm({ ...form, exam_type_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{examTypes.map((t: { id: number; name: string }) => (
<SelectItem key={t.id} value={t.id.toString()}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Evaluation Type</Label>
<Select value={form.evaluation_type} onValueChange={v => setForm({ ...form, evaluation_type: v as "normal" | "grade" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="grade">Grade</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button className="w-full" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !form.name || !form.course_id || !form.batch_id}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Session
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead />
<TableHead>Name</TableHead>
<TableHead>Course / Batch</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Type</TableHead>
<TableHead>Exams</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session: InstitutionalExamSession) => (
<Collapsible key={session.id} asChild open={expandedId === session.id} onOpenChange={open => setExpandedId(open ? session.id : null)}>
<>
<TableRow>
<TableCell>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ChevronRight className={`h-4 w-4 transition-transform ${expandedId === session.id ? "rotate-90" : ""}`} />
</Button>
</CollapsibleTrigger>
</TableCell>
<TableCell className="font-medium">{session.name}</TableCell>
<TableCell>
<div>
<span className="text-sm">{session.course_name}</span>
<span className="text-xs text-muted-foreground ml-1">/ {session.batch_name}</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{session.start_date} {session.end_date}</TableCell>
<TableCell><Badge variant="outline">{session.exam_type_name}</Badge></TableCell>
<TableCell><Badge variant="secondary">{session.exam_count}</Badge></TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[session.state] ?? "outline"} className="capitalize">{session.state}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{session.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => scheduleMutation.mutate(session.id)} disabled={scheduleMutation.isPending}>
<CalendarCheck className="mr-1 h-3 w-3" /> Schedule
</Button>
)}
{session.state === "schedule" && (
<Button variant="ghost" size="sm" onClick={() => doneMutation.mutate(session.id)} disabled={doneMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Done
</Button>
)}
</div>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<TableRow>
<TableCell colSpan={8} className="bg-muted/50 p-4">
{expandedSession && expandedId === session.id ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">Exams in Session</h4>
{(expandedSession as InstitutionalExamSession & { exams?: InstitutionalExam[] }).exams?.length ? (
<div className="grid gap-2">
{((expandedSession as InstitutionalExamSession & { exams?: InstitutionalExam[] }).exams ?? []).map((exam: InstitutionalExam) => (
<div key={exam.id} className="flex items-center justify-between rounded border p-3 bg-background">
<div>
<span className="text-sm font-medium">{exam.name}</span>
<span className="text-xs text-muted-foreground ml-2">{exam.subject_name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{exam.total_marks} marks</span>
<Badge variant="outline" className="text-xs capitalize">{exam.state}</Badge>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No exams in this session yet.</p>
)}
</div>
) : (
<div className="flex justify-center py-4"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
)}
</TableCell>
</TableRow>
</CollapsibleContent>
</>
</Collapsible>
))}
{sessions.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">No exam sessions found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,312 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, FileSpreadsheet, CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useToast } from "@/hooks/use-toast";
import type { ResultTemplate, Marksheet, MarksheetLine, GradeConfig } from "@/types/institutional-exam";
const marksheetStateBadge: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
validated: "default",
cancelled: "destructive",
};
export default function MarksheetManager() {
const { toast } = useToast();
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<"templates" | "marksheets">("templates");
const [showCreateTemplate, setShowCreateTemplate] = useState(false);
const [selectedMarksheetId, setSelectedMarksheetId] = useState<number | null>(null);
const { data: templatesData, isLoading: loadingTemplates } = useQuery({
queryKey: ["result-templates", "list"],
queryFn: () => institutionalExamService.listResultTemplates(),
});
const templates = templatesData?.items ?? [];
const { data: marksheetsData, isLoading: loadingMarksheets } = useQuery({
queryKey: ["marksheets", "list"],
queryFn: () => institutionalExamService.listMarksheets(),
enabled: activeTab === "marksheets",
});
const marksheets = marksheetsData?.items ?? [];
const { data: selectedMarksheet } = useQuery({
queryKey: ["marksheets", selectedMarksheetId],
queryFn: () => institutionalExamService.getMarksheet(selectedMarksheetId!),
enabled: !!selectedMarksheetId,
});
const { data: sessionsData } = useQuery({
queryKey: ["inst-exam-sessions", "list"],
queryFn: () => institutionalExamService.listSessions(),
});
const sessions = sessionsData?.items ?? [];
const { data: gradeConfigsData } = useQuery({
queryKey: ["grade-configs", "list"],
queryFn: () => institutionalExamService.listGradeConfigs(),
});
const gradeConfigs = gradeConfigsData?.items ?? [];
const [templateForm, setTemplateForm] = useState({
name: "",
exam_session_id: 0,
grade_ids: [] as number[],
});
const createTemplateMutation = useMutation({
mutationFn: () => institutionalExamService.createResultTemplate(templateForm as Parameters<typeof institutionalExamService.createResultTemplate>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Template created" });
setShowCreateTemplate(false);
setTemplateForm({ name: "", exam_session_id: 0, grade_ids: [] });
},
onError: () => toast({ title: "Error", description: "Failed to create template", variant: "destructive" }),
});
const generateMutation = useMutation({
mutationFn: (templateId: number) => institutionalExamService.generateMarksheets(templateId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Marksheets generated" });
},
onError: () => toast({ title: "Error", description: "Generation failed", variant: "destructive" }),
});
const validateMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.validateMarksheet(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
toast({ title: "Marksheet validated" });
},
onError: () => toast({ title: "Error", description: "Validation failed", variant: "destructive" }),
});
const isLoading = activeTab === "templates" ? loadingTemplates : loadingMarksheets;
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Marksheet Manager</h1>
<p className="text-muted-foreground">Manage result templates and marksheets.</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant={activeTab === "templates" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("templates")}>
Result Templates
</Button>
<Button variant={activeTab === "marksheets" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("marksheets")}>
Marksheets
</Button>
</div>
{activeTab === "templates" && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Result Templates</CardTitle>
<Dialog open={showCreateTemplate} onOpenChange={setShowCreateTemplate}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Create Template</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Result Template</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mid-Term Results" value={templateForm.name} onChange={e => setTemplateForm({ ...templateForm, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Exam Session</Label>
<Select value={templateForm.exam_session_id ? templateForm.exam_session_id.toString() : ""} onValueChange={v => setTemplateForm({ ...templateForm, exam_session_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select session" /></SelectTrigger>
<SelectContent>
{sessions.map((s: { id: number; name: string }) => (
<SelectItem key={s.id} value={s.id.toString()}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Grade Configuration</Label>
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-lg p-2">
{gradeConfigs.map((gc: GradeConfig) => (
<label key={gc.id} className="flex items-center gap-2 p-1 hover:bg-accent/50 rounded cursor-pointer">
<input
type="checkbox"
checked={templateForm.grade_ids.includes(gc.id)}
onChange={e => {
setTemplateForm(prev => ({
...prev,
grade_ids: e.target.checked
? [...prev.grade_ids, gc.id]
: prev.grade_ids.filter(id => id !== gc.id),
}));
}}
className="rounded"
/>
<span className="text-sm">{gc.result} ({gc.min_per}%{gc.max_per}%)</span>
</label>
))}
{gradeConfigs.length === 0 && <p className="text-xs text-muted-foreground p-1">No grade configs available.</p>}
</div>
</div>
<Button className="w-full" onClick={() => createTemplateMutation.mutate()} disabled={createTemplateMutation.isPending || !templateForm.name || !templateForm.exam_session_id}>
{createTemplateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Template
</Button>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Evaluation</TableHead>
<TableHead>Result Date</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((tpl: ResultTemplate) => (
<TableRow key={tpl.id}>
<TableCell className="font-medium">{tpl.name}</TableCell>
<TableCell>{tpl.exam_session_name}</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{tpl.evaluation_type}</Badge></TableCell>
<TableCell className="text-muted-foreground">{tpl.result_date}</TableCell>
<TableCell>
<Badge variant={tpl.state === "result_generated" ? "default" : "outline"} className="capitalize">
{tpl.state.replace("_", " ")}
</Badge>
</TableCell>
<TableCell className="text-right">
{tpl.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
<FileSpreadsheet className="mr-1 h-3 w-3" /> Generate Marksheets
</Button>
)}
</TableCell>
</TableRow>
))}
{templates.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No result templates found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{activeTab === "marksheets" && (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Generated</TableHead>
<TableHead>Pass</TableHead>
<TableHead>Fail</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{marksheets.map((ms: Marksheet) => (
<TableRow
key={ms.id}
className="cursor-pointer hover:bg-accent/50"
onClick={() => setSelectedMarksheetId(selectedMarksheetId === ms.id ? null : ms.id)}
>
<TableCell className="font-medium">{ms.name}</TableCell>
<TableCell>{ms.exam_session_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{ms.generated_date} by {ms.generated_by_name}</TableCell>
<TableCell><Badge variant="default">{ms.total_pass}</Badge></TableCell>
<TableCell><Badge variant="destructive">{ms.total_failed}</Badge></TableCell>
<TableCell>
<Badge variant={marksheetStateBadge[ms.state] ?? "outline"} className="capitalize">{ms.state}</Badge>
</TableCell>
<TableCell className="text-right" onClick={e => e.stopPropagation()}>
{ms.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => validateMutation.mutate(ms.id)} disabled={validateMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Validate
</Button>
)}
</TableCell>
</TableRow>
))}
{marksheets.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No marksheets found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{selectedMarksheet && (
<Card>
<CardHeader>
<CardTitle>Results {selectedMarksheet.name}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Total Marks</TableHead>
<TableHead>Percentage</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(selectedMarksheet.lines ?? []).map((line: MarksheetLine) => (
<TableRow key={line.id}>
<TableCell className="font-medium">{line.student_name}</TableCell>
<TableCell>{line.total_marks}</TableCell>
<TableCell>{line.percentage.toFixed(1)}%</TableCell>
<TableCell>{line.grade ?? "—"}</TableCell>
<TableCell>
<Badge variant={line.status === "pass" ? "default" : "destructive"} className="capitalize">{line.status}</Badge>
</TableCell>
</TableRow>
))}
{(selectedMarksheet.lines ?? []).length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No results in this marksheet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useToast } from "@/hooks/use-toast";
import type { SubjectRegistration } from "@/types/institutional-exam";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
confirm: "default",
reject: "destructive",
done: "secondary",
};
export default function SubjectRegistrationPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [selectedSubjects, setSelectedSubjects] = useState<number[]>([]);
const { data: registrationsData, isLoading } = useQuery({
queryKey: ["subject-registrations", "list"],
queryFn: () => institutionalExamService.listSubjectRegistrations(),
});
const registrations = registrationsData?.items ?? [];
const { data: available = [], isLoading: loadingAvailable } = useQuery({
queryKey: ["subject-registrations", "available"],
queryFn: () => institutionalExamService.getAvailableSubjects(),
});
const createMutation = useMutation({
mutationFn: () => institutionalExamService.createSubjectRegistration({
subject_ids: selectedSubjects,
} as Parameters<typeof institutionalExamService.createSubjectRegistration>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["subject-registrations"] });
toast({ title: "Registration submitted" });
setSelectedSubjects([]);
},
onError: () => toast({ title: "Error", description: "Registration failed", variant: "destructive" }),
});
const toggleSubject = (id: number) => {
setSelectedSubjects(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Subject Registration</h1>
<p className="text-muted-foreground">Register for subjects within your enrolled courses.</p>
</div>
<Card>
<CardHeader>
<CardTitle>Available Subjects</CardTitle>
<CardDescription>Select the subjects you want to register for this term.</CardDescription>
</CardHeader>
<CardContent>
{loadingAvailable ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : available.length > 0 ? (
<div className="space-y-4">
<div className="space-y-2">
{available.map((subj: SubjectRegistration) => (
<label
key={subj.id}
className="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent/50 cursor-pointer"
>
<Checkbox
checked={selectedSubjects.includes(subj.id)}
onCheckedChange={() => toggleSubject(subj.id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{subj.subject_names?.join(", ") || subj.course_name}</p>
<p className="text-xs text-muted-foreground">
{subj.course_name} · {subj.batch_name}
{subj.min_unit_load > 0 && ` · Load: ${subj.min_unit_load}${subj.max_unit_load}`}
</p>
</div>
</label>
))}
</div>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending || selectedSubjects.length === 0}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Registration ({selectedSubjects.length} selected)
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No subjects available for registration.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>My Registrations</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Subjects</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{registrations.map((reg: SubjectRegistration) => (
<TableRow key={reg.id}>
<TableCell className="font-medium">{reg.course_name}</TableCell>
<TableCell className="text-muted-foreground">{reg.batch_name}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{reg.subject_names.slice(0, 3).map((name, i) => (
<Badge key={i} variant="secondary" className="text-xs">{name}</Badge>
))}
{reg.subject_names.length > 3 && <Badge variant="secondary" className="text-xs">+{reg.subject_names.length - 3}</Badge>}
</div>
</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[reg.state] ?? "outline"} className="capitalize">{reg.state}</Badge>
</TableCell>
</TableRow>
))}
{registrations.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">No registrations yet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { api } from "@/lib/api-client";
import type {
AcademicYear, AcademicTerm, Department,
AcademicYearCreateRequest, AcademicTermCreateRequest, DepartmentCreateRequest,
} from "@/types/academic";
import type { PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export const academicService = {
async listAcademicYears(params?: PaginationParams): Promise<PaginatedResponse<AcademicYear>> {
return api.get<PaginatedResponse<AcademicYear>>("/academic-years", params as Record<string, string | number | boolean | undefined>);
},
async getAcademicYear(id: number): Promise<AcademicYear> {
return api.get<AcademicYear>(`/academic-years/${id}`);
},
async createAcademicYear(data: AcademicYearCreateRequest): Promise<AcademicYear> {
return api.post<AcademicYear>("/academic-years", data);
},
async updateAcademicYear(id: number, data: Partial<AcademicYearCreateRequest>): Promise<AcademicYear> {
return api.patch<AcademicYear>(`/academic-years/${id}`, data);
},
async deleteAcademicYear(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/academic-years/${id}`);
},
async generateTerms(yearId: number): Promise<AcademicTerm[]> {
return api.post<AcademicTerm[]>(`/academic-years/${yearId}/generate-terms`);
},
async listTerms(params?: PaginationParams & { year_id?: number }): Promise<PaginatedResponse<AcademicTerm>> {
return api.get<PaginatedResponse<AcademicTerm>>("/academic-terms", params as Record<string, string | number | boolean | undefined>);
},
async createTerm(data: AcademicTermCreateRequest): Promise<AcademicTerm> {
return api.post<AcademicTerm>("/academic-terms", data);
},
async updateTerm(id: number, data: Partial<AcademicTermCreateRequest>): Promise<AcademicTerm> {
return api.patch<AcademicTerm>(`/academic-terms/${id}`, data);
},
async deleteTerm(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/academic-terms/${id}`);
},
async listDepartments(params?: PaginationParams): Promise<PaginatedResponse<Department>> {
return api.get<PaginatedResponse<Department>>("/departments", params as Record<string, string | number | boolean | undefined>);
},
async createDepartment(data: DepartmentCreateRequest): Promise<Department> {
return api.post<Department>("/departments", data);
},
async updateDepartment(id: number, data: Partial<DepartmentCreateRequest>): Promise<Department> {
return api.patch<Department>(`/departments/${id}`, data);
},
async deleteDepartment(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/departments/${id}`);
},
};

View File

@@ -0,0 +1,56 @@
import { api } from "@/lib/api-client";
import type {
AdmissionRegister, Admission,
AdmissionRegisterCreateRequest, AdmissionCreateRequest,
} from "@/types/admission";
import type { PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export const admissionService = {
async listRegisters(params?: PaginationParams): Promise<PaginatedResponse<AdmissionRegister>> {
return api.get<PaginatedResponse<AdmissionRegister>>("/admission-registers", params as Record<string, string | number | boolean | undefined>);
},
async createRegister(data: AdmissionRegisterCreateRequest): Promise<AdmissionRegister> {
return api.post<AdmissionRegister>("/admission-registers", data);
},
async updateRegister(id: number, data: Partial<AdmissionRegisterCreateRequest>): Promise<AdmissionRegister> {
return api.patch<AdmissionRegister>(`/admission-registers/${id}`, data);
},
async confirmRegister(id: number): Promise<AdmissionRegister> {
return api.post<AdmissionRegister>(`/admission-registers/${id}/confirm`);
},
async closeRegister(id: number): Promise<AdmissionRegister> {
return api.post<AdmissionRegister>(`/admission-registers/${id}/close`);
},
async listAdmissions(params?: PaginationParams & { state?: string; register_id?: number; course_id?: number }): Promise<PaginatedResponse<Admission>> {
return api.get<PaginatedResponse<Admission>>("/admissions", params as Record<string, string | number | boolean | undefined>);
},
async getAdmission(id: number): Promise<Admission> {
return api.get<Admission>(`/admissions/${id}`);
},
async createAdmission(data: AdmissionCreateRequest): Promise<Admission> {
return api.post<Admission>("/admissions", data);
},
async submitAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/submit`);
},
async confirmAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/confirm`);
},
async admitAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/admit`);
},
async rejectAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/reject`);
},
};

View File

@@ -19,3 +19,6 @@ export { resourcesService } from "./resources.service";
export { coachingService } from "./coaching.service";
export { analyticsService } from "./analytics.service";
export { lmsService } from "./lms.service";
export { academicService } from "./academic.service";
export { admissionService } from "./admission.service";
export { institutionalExamService } from "./institutional-exam.service";

View File

@@ -0,0 +1,165 @@
import { api } from "@/lib/api-client";
import type {
InstExamSession, InstExam, ExamType, GradeConfig,
ResultTemplate, Marksheet, CourseAssignment, AssignmentSubmission,
AssignmentType, SubjectRegistration,
InstExamSessionCreateRequest, InstExamCreateRequest,
ExamTypeCreateRequest, GradeConfigCreateRequest,
ResultTemplateCreateRequest, CourseAssignmentCreateRequest,
AssignmentTypeCreateRequest, SubjectRegistrationCreateRequest,
} from "@/types/institutional-exam";
import type { PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export const institutionalExamService = {
async listSessions(params?: PaginationParams): Promise<PaginatedResponse<InstExamSession>> {
return api.get<PaginatedResponse<InstExamSession>>("/inst-exam-sessions", params as Record<string, string | number | boolean | undefined>);
},
async getSession(id: number): Promise<InstExamSession> {
return api.get<InstExamSession>(`/inst-exam-sessions/${id}`);
},
async createSession(data: InstExamSessionCreateRequest): Promise<InstExamSession> {
return api.post<InstExamSession>("/inst-exam-sessions", data);
},
async updateSession(id: number, data: Partial<InstExamSessionCreateRequest>): Promise<InstExamSession> {
return api.patch<InstExamSession>(`/inst-exam-sessions/${id}`, data);
},
async deleteSession(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/inst-exam-sessions/${id}`);
},
async scheduleSession(id: number): Promise<InstExamSession> {
return api.post<InstExamSession>(`/inst-exam-sessions/${id}/schedule`);
},
async markSessionDone(id: number): Promise<InstExamSession> {
return api.post<InstExamSession>(`/inst-exam-sessions/${id}/done`);
},
async createExam(data: InstExamCreateRequest): Promise<InstExam> {
return api.post<InstExam>("/inst-exams", data);
},
async updateExam(id: number, data: Partial<InstExamCreateRequest>): Promise<InstExam> {
return api.patch<InstExam>(`/inst-exams/${id}`, data);
},
async addAttendees(examId: number, data: { student_ids: number[] }): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/inst-exams/${examId}/attendees`, data);
},
async enterMarks(examId: number, data: { marks: { student_id: number; score: number }[] }): Promise<ApiSuccessResponse> {
return api.patch<ApiSuccessResponse>(`/inst-exams/${examId}/marks`, data);
},
async listExamTypes(params?: PaginationParams): Promise<PaginatedResponse<ExamType>> {
return api.get<PaginatedResponse<ExamType>>("/exam-types", params as Record<string, string | number | boolean | undefined>);
},
async createExamType(data: ExamTypeCreateRequest): Promise<ExamType> {
return api.post<ExamType>("/exam-types", data);
},
async listGradeConfigs(params?: PaginationParams): Promise<PaginatedResponse<GradeConfig>> {
return api.get<PaginatedResponse<GradeConfig>>("/grade-config", params as Record<string, string | number | boolean | undefined>);
},
async createGradeConfig(data: GradeConfigCreateRequest): Promise<GradeConfig> {
return api.post<GradeConfig>("/grade-config", data);
},
async listResultTemplates(params?: PaginationParams): Promise<PaginatedResponse<ResultTemplate>> {
return api.get<PaginatedResponse<ResultTemplate>>("/result-templates", params as Record<string, string | number | boolean | undefined>);
},
async createResultTemplate(data: ResultTemplateCreateRequest): Promise<ResultTemplate> {
return api.post<ResultTemplate>("/result-templates", data);
},
async generateMarksheets(templateId: number): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/result-templates/${templateId}/generate`);
},
async listMarksheets(params?: PaginationParams): Promise<PaginatedResponse<Marksheet>> {
return api.get<PaginatedResponse<Marksheet>>("/marksheets", params as Record<string, string | number | boolean | undefined>);
},
async getMarksheet(id: number): Promise<Marksheet> {
return api.get<Marksheet>(`/marksheets/${id}`);
},
async validateMarksheet(id: number): Promise<Marksheet> {
return api.post<Marksheet>(`/marksheets/${id}/validate`);
},
async listCourseAssignments(params?: PaginationParams & { course_id?: number; batch_id?: number; state?: string }): Promise<PaginatedResponse<CourseAssignment>> {
return api.get<PaginatedResponse<CourseAssignment>>("/course-assignments", params as Record<string, string | number | boolean | undefined>);
},
async createCourseAssignment(data: CourseAssignmentCreateRequest): Promise<CourseAssignment> {
return api.post<CourseAssignment>("/course-assignments", data);
},
async getCourseAssignment(id: number): Promise<CourseAssignment> {
return api.get<CourseAssignment>(`/course-assignments/${id}`);
},
async updateCourseAssignment(id: number, data: Partial<CourseAssignmentCreateRequest>): Promise<CourseAssignment> {
return api.patch<CourseAssignment>(`/course-assignments/${id}`, data);
},
async publishCourseAssignment(id: number): Promise<CourseAssignment> {
return api.post<CourseAssignment>(`/course-assignments/${id}/publish`);
},
async finishCourseAssignment(id: number): Promise<CourseAssignment> {
return api.post<CourseAssignment>(`/course-assignments/${id}/finish`);
},
async listSubmissions(assignmentId: number, params?: PaginationParams): Promise<PaginatedResponse<AssignmentSubmission>> {
return api.get<PaginatedResponse<AssignmentSubmission>>(`/course-assignments/${assignmentId}/submissions`, params as Record<string, string | number | boolean | undefined>);
},
async submitAssignment(assignmentId: number, file: File): Promise<AssignmentSubmission> {
return api.upload<AssignmentSubmission>(`/course-assignments/${assignmentId}/submit`, file);
},
async gradeSubmission(submissionId: number, data: Partial<AssignmentSubmission>): Promise<AssignmentSubmission> {
return api.patch<AssignmentSubmission>(`/submissions/${submissionId}`, data);
},
async listAssignmentTypes(params?: PaginationParams): Promise<PaginatedResponse<AssignmentType>> {
return api.get<PaginatedResponse<AssignmentType>>("/assignment-types", params as Record<string, string | number | boolean | undefined>);
},
async createAssignmentType(data: AssignmentTypeCreateRequest): Promise<AssignmentType> {
return api.post<AssignmentType>("/assignment-types", data);
},
async listSubjectRegistrations(params?: PaginationParams & { student_id?: number; course_id?: number; state?: string }): Promise<PaginatedResponse<SubjectRegistration>> {
return api.get<PaginatedResponse<SubjectRegistration>>("/subject-registrations", params as Record<string, string | number | boolean | undefined>);
},
async createSubjectRegistration(data: SubjectRegistrationCreateRequest): Promise<SubjectRegistration> {
return api.post<SubjectRegistration>("/subject-registrations", data);
},
async updateSubjectRegistration(id: number, data: Partial<SubjectRegistrationCreateRequest>): Promise<SubjectRegistration> {
return api.patch<SubjectRegistration>(`/subject-registrations/${id}`, data);
},
async confirmSubjectRegistration(id: number): Promise<SubjectRegistration> {
return api.post<SubjectRegistration>(`/subject-registrations/${id}/confirm`);
},
async rejectSubjectRegistration(id: number): Promise<SubjectRegistration> {
return api.post<SubjectRegistration>(`/subject-registrations/${id}/reject`);
},
async getAvailableSubjects(params?: { student_id?: number; course_id?: number }): Promise<SubjectRegistration[]> {
return api.get<SubjectRegistration[]>("/subject-registrations/available", params as Record<string, string | number | boolean | undefined>);
},
};

50
src/types/academic.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface AcademicYear {
id: number;
name: string;
start_date: string;
end_date: string;
term_structure: "two_sem" | "two_sem_qua" | "three_sem" | "four_Quarter" | "final_year" | "others";
terms: AcademicTerm[];
}
export interface AcademicTerm {
id: number;
name: string;
term_start_date: string;
term_end_date: string;
academic_year_id: number;
academic_year_name: string;
parent_term_id?: number;
parent_term_name?: string;
}
export interface Department {
id: number;
name: string;
code: string;
parent_id?: number;
parent_name?: string;
course_count: number;
faculty_count: number;
}
export interface AcademicYearCreateRequest {
name: string;
start_date: string;
end_date: string;
term_structure: AcademicYear["term_structure"];
}
export interface AcademicTermCreateRequest {
name: string;
term_start_date: string;
term_end_date: string;
academic_year_id: number;
parent_term_id?: number;
}
export interface DepartmentCreateRequest {
name: string;
code: string;
parent_id?: number;
}

63
src/types/admission.ts Normal file
View File

@@ -0,0 +1,63 @@
export type AdmissionRegisterState = "draft" | "confirm" | "cancel" | "done";
export interface AdmissionRegister {
id: number;
name: string;
course_id: number;
course_name: string;
start_date: string;
end_date: string;
min_count: number;
max_count: number;
application_count: number;
state: AdmissionRegisterState;
}
export type AdmissionState = "draft" | "submit" | "confirm" | "admission" | "reject" | "cancel" | "done";
export interface Admission {
id: number;
name: string;
application_number: string;
register_id: number;
register_name: string;
course_id: number;
course_name: string;
batch_id?: number;
batch_name?: string;
first_name: string;
middle_name?: string;
last_name: string;
email: string;
phone?: string;
birth_date?: string;
gender: "m" | "f" | "o";
nationality_id?: number;
nationality_name?: string;
state: AdmissionState;
fees: number;
image?: string;
student_id?: number;
created_at: string;
}
export interface AdmissionCreateRequest {
register_id: number;
first_name: string;
middle_name?: string;
last_name: string;
email: string;
phone?: string;
birth_date?: string;
gender: "m" | "f" | "o";
nationality_id?: number;
}
export interface AdmissionRegisterCreateRequest {
name: string;
course_id: number;
start_date: string;
end_date: string;
min_count?: number;
max_count?: number;
}

View File

@@ -12,3 +12,6 @@ export * from "./taxonomy";
export * from "./adaptive";
export * from "./lms";
export * from "./ai";
export * from "./academic";
export * from "./admission";
export * from "./institutional-exam";

View File

@@ -0,0 +1,176 @@
export type ExamSessionState = "draft" | "schedule" | "held" | "cancel" | "done";
export type InstitutionalExamState = "draft" | "schedule" | "held" | "result_updated" | "cancel" | "done";
export type MarksheetState = "draft" | "validated" | "cancelled";
export type SubmissionState = "draft" | "submit" | "accept" | "reject" | "change_req";
export interface ExamType {
id: number;
name: string;
}
export interface ExamRoom {
id: number;
name: string;
classroom_id?: number;
classroom_name?: string;
capacity: number;
}
export interface InstitutionalExamSession {
id: number;
name: string;
course_id: number;
course_name: string;
batch_id: number;
batch_name: string;
exam_code: string;
start_date: string;
end_date: string;
exam_type_id: number;
exam_type_name: string;
evaluation_type: "normal" | "grade";
venue?: string;
state: ExamSessionState;
exam_count: number;
}
export interface InstitutionalExam {
id: number;
name: string;
session_id: number;
subject_id: number;
subject_name: string;
exam_code: string;
start_time: string;
end_time: string;
total_marks: number;
min_marks: number;
state: InstitutionalExamState;
responsible_names: string[];
attendee_count: number;
}
export interface ExamAttendee {
id: number;
exam_id: number;
student_id: number;
student_name: string;
room_id?: number;
room_name?: string;
marks: number;
status: "pass" | "fail";
}
export interface GradeConfig {
id: number;
min_per: number;
max_per: number;
result: string;
}
export interface ResultTemplate {
id: number;
name: string;
exam_session_id: number;
exam_session_name: string;
evaluation_type: "normal" | "grade";
result_date: string;
grade_ids: number[];
state: "draft" | "result_generated";
}
export interface Marksheet {
id: number;
name: string;
exam_session_id: number;
exam_session_name: string;
result_template_id: number;
generated_date: string;
generated_by_name: string;
state: MarksheetState;
total_pass: number;
total_failed: number;
lines: MarksheetLine[];
}
export interface MarksheetLine {
id: number;
student_id: number;
student_name: string;
total_marks: number;
percentage: number;
grade?: string;
status: "pass" | "fail";
results: ResultLine[];
}
export interface ResultLine {
id: number;
exam_id: number;
exam_name: string;
subject_name: string;
marks: number;
grade?: string;
status: "pass" | "fail";
}
export interface CourseAssignmentType {
id: number;
name: string;
}
export interface CourseAssignment {
id: number;
name: string;
course_id: number;
course_name: string;
subject_id?: number;
subject_name?: string;
batch_id: number;
batch_name: string;
faculty_id: number;
faculty_name: string;
description: string;
marks: number;
point: number;
assignment_type_id: number;
assignment_type_name: string;
issued_date: string;
submission_date: string;
state: "draft" | "publish" | "finish" | "cancel";
allocation_count: number;
submission_count: number;
reviewer_id?: number;
reviewer_name?: string;
}
export interface AssignmentSubmission {
id: number;
assignment_id: number;
student_id: number;
student_name: string;
description?: string;
submission_date?: string;
attachment_ids: number[];
attachment_names: string[];
marks: number;
state: SubmissionState;
faculty_id?: number;
faculty_name?: string;
note?: string;
}
export interface SubjectRegistration {
id: number;
student_id: number;
student_name: string;
course_id: number;
course_name: string;
batch_id: number;
batch_name: string;
subject_ids: number[];
subject_names: string[];
min_unit_load: number;
max_unit_load: number;
state: "draft" | "confirm" | "reject" | "done";
}