diff --git a/src/App.jsx b/src/App.jsx
index 99d55a675..12c291780 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -2,20 +2,33 @@ import { createTheme, MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import { Route, Routes, Navigate, useLocation } from "react-router-dom";
+import { useSelector } from "react-redux";
import { Notifications } from "@mantine/notifications";
import { Layout } from "./components/layout";
-import Dashboard from "./Modules/Dashboard/dashboardNotifications";
+import DashboardNotifications from "./Modules/Dashboard/dashboardNotifications";
+import RoleDashboard from "./Modules/Dashboard/RoleDashboard";
import Profile from "./Modules/Dashboard/StudentProfile/profilePage";
import LoginPage from "./pages/login";
import ForgotPassword from "./pages/forgotPassword";
import AcademicPage from "./Modules/Academic/index";
import ValidateAuth from "./helper/validateauth";
import FacultyProfessionalProfile from "./Modules/facultyProfessionalProfile/facultyProfessionalProfile";
-import InactivityHandler from "./helper/inactivityhandler";
import Examination from "./Modules/Examination/examination";
import Database from "./Modules/Database/database";
import ProgrammeCurriculumRoutes from "./Modules/Program_curriculum/programmCurriculum";
import NotFoundPage from "./components/NotFoundPage";
+import HR2Module from "./Modules/HRModule/index";
+import HodLeaveApprovals from "./Modules/HRModule/HodLeaveApprovals";
+import HodAppraisalReviews from "./Modules/HRModule/HodAppraisalReviews";
+import DirectorLeaveApprovals from "./Modules/HRModule/DirectorLeaveApprovals";
+import DirectorAppraisalReviews from "./Modules/HRModule/DirectorAppraisalReviews";
+import DirectorCpdaApprovals from "./Modules/HRModule/DirectorCpdaApprovals";
+import RegistrarLeaveApprovals from "./Modules/HRModule/RegistrarLeaveApprovals";
+import HrAdminLtcReview from "./Modules/HRModule/HrAdminLtcReview";
+import HrAdminCpdaReview from "./Modules/HRModule/HrAdminCpdaReview";
+import HrAdminAppraisalAssignments from "./Modules/HRModule/HrAdminAppraisalAssignments";
+import AccountantLtcReview from "./Modules/HRModule/AccountantLtcReview";
+import AccountantCpdaReview from "./Modules/HRModule/AccountantCpdaReview";
const theme = createTheme({
breakpoints: {
@@ -30,11 +43,28 @@ const theme = createTheme({
export default function App() {
const location = useLocation();
+ const currentAccessibleModules = useSelector(
+ (state) => state.user.currentAccessibleModules,
+ );
+ const currentRole = useSelector((state) => state.user.role);
+ const isHod = /hod/i.test(currentRole || "");
+ const isDirector = /director/i.test(currentRole || "");
+ const isRegistrar = /registrar/i.test(currentRole || "");
+ const isHrAdmin = /hr/i.test(currentRole || "");
+ const isAccountant = /accountant/i.test(currentRole || "");
+ const canAccessHr =
+ Boolean(currentAccessibleModules?.hr) ||
+ /hod/i.test(currentRole || "") ||
+ /director/i.test(currentRole || "") ||
+ /registrar/i.test(currentRole || "") ||
+ /accountant/i.test(currentRole || "") ||
+ /hr/i.test(currentRole || "") ||
+ Boolean(currentRole);
return (
{location.pathname !== "/accounts/login" && }
- {location.pathname !== "/accounts/login" && }
+ {/* {location.pathname !== "/accounts/login" && } */}
} />
@@ -42,7 +72,15 @@ export default function App() {
path="/dashboard"
element={
-
+
+
+ }
+ />
+
+
}
/>
@@ -82,6 +120,150 @@ export default function App() {
} />
} />
} />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ ) : (
+
+ )
+ }
+ />
} />
diff --git a/src/Modules/Dashboard/RoleDashboard.jsx b/src/Modules/Dashboard/RoleDashboard.jsx
new file mode 100644
index 000000000..4cfa6f67a
--- /dev/null
+++ b/src/Modules/Dashboard/RoleDashboard.jsx
@@ -0,0 +1,325 @@
+import { useMemo } from "react";
+import { useSelector } from "react-redux";
+import { useNavigate } from "react-router-dom";
+
+const roleMatchers = [
+ { key: "hod", label: "HOD", match: (role) => /hod/i.test(role) },
+ {
+ key: "director",
+ label: "Director",
+ match: (role) => /director/i.test(role),
+ },
+ {
+ key: "registrar",
+ label: "Registrar",
+ match: (role) => /registrar/i.test(role),
+ },
+ {
+ key: "accountant",
+ label: "Accountant",
+ match: (role) => /accountant/i.test(role),
+ },
+ {
+ key: "hr_admin",
+ label: "HR Administration",
+ match: (role) => /hr/i.test(role),
+ },
+];
+
+const employeeActions = [
+ {
+ key: "leave",
+ title: "Apply for Leave",
+ description: "Submit a new leave request and track its status.",
+ cta: "Apply leave",
+ to: "/hr2?tab=leave",
+ },
+ {
+ key: "appraisal",
+ title: "Yearly Appraisal",
+ description: "Submit self-appraisals and view evaluation status.",
+ cta: "Start appraisal",
+ to: "/hr2?tab=appraisal",
+ },
+ {
+ key: "cpda-advance",
+ title: "CPDA Advance",
+ description: "Request advances for approved professional activities.",
+ cta: "Request advance",
+ to: "/hr2?tab=cpda-advance",
+ },
+ {
+ key: "ltc",
+ title: "Apply for LTC",
+ description: "Create a Leave Travel Concession request in minutes.",
+ cta: "Request LTC",
+ to: "/hr2?tab=ltc",
+ },
+ {
+ key: "nominee",
+ title: "Nominee Dashboard",
+ description: "Accept or decline leave handover nominations.",
+ cta: "Review nominations",
+ to: "/hr2?tab=nominee",
+ },
+];
+
+const roleActions = {
+ hod: [
+ {
+ key: "hod-leave-approvals",
+ title: "Approve Leave Requests",
+ description: "Review and approve leave requests from your department.",
+ cta: "Review leave",
+ to: "/hr2/hod/leave-approvals",
+ },
+ {
+ key: "hod-appraisals",
+ title: "Appraisal Reviews",
+ description: "Check pending appraisals and add reviewer feedback.",
+ cta: "Open appraisals",
+ to: "/hr2/hod/appraisal-reviews",
+ },
+ ],
+ director: [
+ {
+ key: "director-leave",
+ title: "Leave Approvals",
+ description: "Review leave requests forwarded by HODs.",
+ cta: "Review leaves",
+ to: "/hr2/director/leave-approvals",
+ },
+ {
+ key: "director-appraisals",
+ title: "Appraisal Reviews",
+ description: "Approve or reject reviewed appraisals.",
+ cta: "Review appraisals",
+ to: "/hr2/director/appraisal-reviews",
+ },
+ {
+ key: "director-cpda",
+ title: "CPDA Approvals",
+ description: "Approve or reject CPDA advance requests.",
+ cta: "Review CPDA",
+ to: "/hr2/director/cpda-approvals",
+ },
+ ],
+ registrar: [
+ {
+ key: "registrar-leave",
+ title: "Leave Approvals",
+ description:
+ "Approve, reject, or forward leave requests from HR Admins and Accountants.",
+ cta: "Review leaves",
+ to: "/hr2/registrar/leave-approvals",
+ },
+ {
+ key: "registrar-queue",
+ title: "Registrar Queue",
+ description: "Track files awaiting registrar-level processing.",
+ cta: "Open queue",
+ disabled: true,
+ },
+ {
+ key: "registrar-compliance",
+ title: "Compliance Review",
+ description: "Verify documentation and service history compliance.",
+ cta: "Review compliance",
+ disabled: true,
+ },
+ ],
+ accountant: [
+ {
+ key: "accountant-ltc",
+ title: "LTC Accountant Review",
+ description: "Finalize LTC requests forwarded by HR.",
+ cta: "Review LTC",
+ to: "/hr2/accountant/ltc-review",
+ },
+ {
+ key: "accountant-cpda",
+ title: "CPDA Accountant Review",
+ description: "Finalize CPDA advances forwarded by HR.",
+ cta: "Review CPDA",
+ to: "/hr2/accountant/cpda-review",
+ },
+ ],
+ hr_admin: [
+ {
+ key: "hr-appraisal-assign",
+ title: "Assign Appraisals",
+ description: "Route submitted appraisals to HODs or the Director.",
+ cta: "Assign forms",
+ to: "/hr2/hr-admin/appraisal-assignments",
+ },
+ {
+ key: "hr-ltc-review",
+ title: "LTC Document Check",
+ description: "Review LTC submissions and forward to accountant.",
+ cta: "Review LTC",
+ to: "/hr2/hr-admin/ltc-review",
+ },
+ {
+ key: "hr-cpda-review",
+ title: "CPDA Document Check",
+ description: "Review CPDA advances and forward to accountant.",
+ cta: "Review CPDA",
+ to: "/hr2/hr-admin/cpda-review",
+ },
+ {
+ key: "hr-records",
+ title: "Employee Records",
+ description: "Maintain employee profiles and service records.",
+ cta: "Manage records",
+ disabled: true,
+ },
+ ],
+};
+
+function RoleDashboard() {
+ const navigate = useNavigate();
+ const username = useSelector((state) => state.user.username);
+ const role = useSelector((state) => state.user.role);
+
+ const resolvedRole = useMemo(() => {
+ const match = roleMatchers.find((matcher) => matcher.match(role || ""));
+ return match || { key: "employee", label: "Employee" };
+ }, [role]);
+
+ const employeeActionsToShow = useMemo(() => {
+ const canSeeAppraisal = [
+ "employee",
+ "hod",
+ "director",
+ "registrar",
+ "accountant",
+ "hr_admin",
+ ].includes(resolvedRole.key);
+ const hideFinanceActions = false;
+ return employeeActions.filter((action) => {
+ if (action.key === "appraisal") return canSeeAppraisal;
+ if (action.key === "nominee") return resolvedRole.key === "employee";
+ if (hideFinanceActions && ["cpda-advance", "ltc"].includes(action.key))
+ return false;
+ return true;
+ });
+ }, [resolvedRole.key]);
+
+ const actionsForRole = roleActions[resolvedRole.key] || [];
+
+ const handleAction = (action) => {
+ if (action.disabled || !action.to) {
+ return;
+ }
+ navigate(action.to);
+ };
+
+ return (
+
+
+
+
+
Role workspace
+
Welcome, {username}
+
+ Active role: {resolvedRole.label}
+
+
+
+
+
Quick access
+
+ Leave, Appraisal, CPDA, LTC
+
+
+
+
Notifications
+
+
+
+
+
+
+
+
+ Employee dashboard
+
+
+ All employee actions are available for every role.
+
+
+ {employeeActionsToShow.map((action) => (
+
+ ))}
+
+
+
+ {actionsForRole.length > 0 && (
+
+
+ Role dashboard
+
+
+ Tools specific to {resolvedRole.label.toLowerCase()} duties.
+
+
+ {actionsForRole.map((action) => {
+ const isDisabled = Boolean(action.disabled);
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+export default RoleDashboard;
diff --git a/src/Modules/Dashboard/RoleDashboard.module.css b/src/Modules/Dashboard/RoleDashboard.module.css
new file mode 100644
index 000000000..c33dd7a3c
--- /dev/null
+++ b/src/Modules/Dashboard/RoleDashboard.module.css
@@ -0,0 +1,189 @@
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap");
+
+.page {
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
+ padding: 24px;
+ background: radial-gradient(circle at top left, #f8f6f1, #ffffff 45%, #f0f6ff 100%);
+ min-height: calc(100vh - 120px);
+}
+
+.hero {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 24px;
+ align-items: center;
+ justify-content: space-between;
+ border-radius: 20px;
+ padding: 28px 32px;
+ background: linear-gradient(130deg, #111827 0%, #1f2937 60%, #111827 100%);
+ color: #f9fafb;
+ box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2);
+}
+
+.eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ font-size: 0.7rem;
+ color: rgba(226, 232, 240, 0.7);
+ margin: 0 0 8px;
+}
+
+.title {
+ font-size: 2.2rem;
+ margin: 0 0 8px;
+}
+
+.subtitle {
+ margin: 0;
+ font-size: 1rem;
+ color: rgba(226, 232, 240, 0.85);
+}
+
+.subtitle span {
+ font-weight: 600;
+ color: #fcd34d;
+}
+
+.heroMeta {
+ display: grid;
+ gap: 12px;
+ min-width: 240px;
+}
+
+.metaCard {
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 14px;
+ padding: 14px 16px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+.metaLabel {
+ margin: 0 0 6px;
+ font-size: 0.8rem;
+ color: rgba(226, 232, 240, 0.65);
+}
+
+.metaValue {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.linkButton {
+ width: 100%;
+ border: none;
+ border-radius: 10px;
+ padding: 10px 12px;
+ background: #fcd34d;
+ color: #111827;
+ font-weight: 600;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.linkButton:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 8px 18px rgba(252, 211, 77, 0.3);
+}
+
+.section {
+ margin-top: 28px;
+}
+
+.sectionHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.sectionTitle {
+ margin: 0 0 4px;
+ font-size: 1.4rem;
+ color: #111827;
+}
+
+.sectionSubtitle {
+ margin: 0;
+ color: #475569;
+ font-size: 0.95rem;
+}
+
+.cardGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 16px;
+}
+
+.card {
+ text-align: left;
+ border: 1px solid #e2e8f0;
+ border-radius: 16px;
+ padding: 18px;
+ background: #ffffff;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 14px;
+ min-height: 140px;
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease;
+}
+
+.card:hover {
+ transform: translateY(-2px);
+ border-color: #bfdbfe;
+ box-shadow: 0 16px 30px rgba(59, 130, 246, 0.2);
+}
+
+.cardDisabled {
+ text-align: left;
+ border: 1px dashed #cbd5f5;
+ border-radius: 16px;
+ padding: 18px;
+ background: #f8fafc;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 14px;
+ min-height: 140px;
+ color: #94a3b8;
+}
+
+.cardTitle {
+ margin: 0 0 6px;
+ font-size: 1.1rem;
+ color: inherit;
+}
+
+.cardDescription {
+ margin: 0;
+ color: inherit;
+ font-size: 0.9rem;
+}
+
+.cardCta {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: #2563eb;
+}
+
+.cardDisabled .cardCta {
+ color: #94a3b8;
+}
+
+@media (max-width: 720px) {
+ .page {
+ padding: 16px;
+ }
+
+ .hero {
+ padding: 20px;
+ }
+
+ .title {
+ font-size: 1.8rem;
+ }
+}
diff --git a/src/Modules/HRModule.zip b/src/Modules/HRModule.zip
new file mode 100644
index 000000000..f7e5d90e5
Binary files /dev/null and b/src/Modules/HRModule.zip differ
diff --git a/src/Modules/HRModule/AccountantCpdaReview.jsx b/src/Modules/HRModule/AccountantCpdaReview.jsx
new file mode 100644
index 000000000..e30dfd92e
--- /dev/null
+++ b/src/Modules/HRModule/AccountantCpdaReview.jsx
@@ -0,0 +1,139 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { getCPDAAdvances, approveRejectCPDAAdvance } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function AccountantCpdaReview() {
+ const [loading, setLoading] = useState(true);
+ const [advances, setAdvances] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchAdvances = async () => {
+ try {
+ const res = await getCPDAAdvances();
+ setAdvances(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchAdvances();
+ }, []);
+
+ const reviewQueue = useMemo(
+ () =>
+ advances.filter((item) => {
+ const status = (
+ item.approval_status ||
+ item.status ||
+ ""
+ ).toUpperCase();
+ const accountantStatus = (
+ item.accountant_processing_status || ""
+ ).toUpperCase();
+ if (status !== "FORWARDED") {
+ return false;
+ }
+ return ["PENDING"].includes(accountantStatus);
+ }),
+ [advances],
+ );
+
+ const handleDecision = async (cpdaId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(cpdaId);
+ await approveRejectCPDAAdvance(cpdaId, decision, remarks);
+ const res = await getCPDAAdvances();
+ setAdvances(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
Accountant CPDA Review
+
+ Finalize CPDA advances forwarded by the director.
+
+
+
+
+
+
+
+ | Employee |
+ Event |
+ Start Date |
+ Total Amount |
+ Status |
+ Actions |
+
+
+
+ {reviewQueue.map((adv) => (
+
+ |
+ {adv.employee_name || adv.employee || "Employee"}
+ |
+
+ {adv.event_name || adv.purpose || "-"}
+ |
+
+ {adv.start_date || adv.submission_date || "-"}
+ |
+
+ ₹{adv.total_amount || adv.amount_required}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {reviewQueue.length === 0 && (
+
+ |
+ No forwarded CPDA requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default AccountantCpdaReview;
diff --git a/src/Modules/HRModule/AccountantLtcReview.jsx b/src/Modules/HRModule/AccountantLtcReview.jsx
new file mode 100644
index 000000000..2e71df2ac
--- /dev/null
+++ b/src/Modules/HRModule/AccountantLtcReview.jsx
@@ -0,0 +1,132 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { getLTCApplications, approveRejectLTC } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function AccountantLtcReview() {
+ const [loading, setLoading] = useState(true);
+ const [ltcRequests, setLtcRequests] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchLtc = async () => {
+ try {
+ const res = await getLTCApplications();
+ setLtcRequests(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchLtc();
+ }, []);
+
+ const reviewQueue = useMemo(
+ () =>
+ ltcRequests.filter(
+ (item) =>
+ (item.approval_status || item.status || "").toUpperCase() ===
+ "FORWARDED",
+ ),
+ [ltcRequests],
+ );
+
+ const handleDecision = async (ltcId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(ltcId);
+ await approveRejectLTC(ltcId, decision, remarks);
+ const res = await getLTCApplications();
+ setLtcRequests(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
Accountant LTC Review
+
+ Finalize LTC requests forwarded by HR.
+
+
+
+
+
+
+
+ | Employee |
+ Block Year |
+ Travel Dates |
+ Destination |
+ Status |
+ Actions |
+
+
+
+ {reviewQueue.map((ltc) => (
+
+ |
+ {ltc.employee_name || ltc.employee || "Employee"}
+ |
+
+ {ltc.ltc_block_year || "-"}
+ |
+
+ {`${ltc.travel_start_date || "-"} to ${
+ ltc.travel_end_date || "-"
+ }`}
+ |
+
+ {ltc.destination || "-"}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {reviewQueue.length === 0 && (
+
+ |
+ No forwarded LTC requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default AccountantLtcReview;
diff --git a/src/Modules/HRModule/AppraisalForm.jsx b/src/Modules/HRModule/AppraisalForm.jsx
new file mode 100644
index 000000000..5d0139010
--- /dev/null
+++ b/src/Modules/HRModule/AppraisalForm.jsx
@@ -0,0 +1,454 @@
+import React, { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import {
+ getAppraisalForms,
+ createAppraisalForm,
+ downloadAppraisalForm,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+import FormField from "./components/FormField";
+import TextAreaField from "./components/TextAreaField";
+
+function AppraisalForm({ onBack }) {
+ const [appraisals, setAppraisals] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+ const [submitError, setSubmitError] = useState("");
+ const [submitSuccess, setSubmitSuccess] = useState("");
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [formData, setFormData] = useState({
+ employee_id: "",
+ employee_name: "",
+ department: "",
+ designation: "",
+ appraisal_year: "",
+ self_summary: "",
+ key_responsibilities: "",
+ achievements: "",
+ challenges_faced: "",
+ teaching_performance: "",
+ research_work: "",
+ publications: "",
+ projects_handled: "",
+ administrative_contributions: "",
+ trainings_attended: "",
+ certifications: "",
+ workshops: "",
+ goals_achieved: "",
+ future_goals: "",
+ supporting_documents: "",
+ });
+
+ const fetchData = async () => {
+ try {
+ const res = await getAppraisalForms();
+ setAppraisals(res.data);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData({ ...formData, [name]: value });
+ if (fieldErrors[name]) {
+ setFieldErrors((prev) => ({ ...prev, [name]: "" }));
+ }
+ };
+
+ const parseServerErrors = (errors) => {
+ if (!errors || typeof errors !== "object") return {};
+ const next = {};
+ Object.entries(errors).forEach(([key, value]) => {
+ if (Array.isArray(value)) {
+ next[key] = value.join(" ");
+ } else if (typeof value === "string") {
+ next[key] = value;
+ }
+ });
+ return next;
+ };
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSubmitError("");
+ setSubmitSuccess("");
+ setFieldErrors({});
+ try {
+ const required = [
+ { key: "employee_id", label: "Employee ID" },
+ { key: "employee_name", label: "Employee Name" },
+ { key: "department", label: "Department" },
+ { key: "designation", label: "Designation" },
+ { key: "appraisal_year", label: "Appraisal Year" },
+ { key: "self_summary", label: "Self Summary" },
+ { key: "key_responsibilities", label: "Key Responsibilities" },
+ { key: "achievements", label: "Achievements" },
+ { key: "goals_achieved", label: "Goals Achieved" },
+ { key: "future_goals", label: "Future Goals" },
+ ];
+ const missing = required
+ .filter((field) => !String(formData[field.key] || "").trim())
+ .map((field) => field.label);
+ if (missing.length > 0) {
+ const nextErrors = required.reduce((acc, field) => {
+ if (!String(formData[field.key] || "").trim()) {
+ acc[field.key] = "This field is required.";
+ }
+ return acc;
+ }, {});
+ setFieldErrors(nextErrors);
+ setSubmitError(`Please fill required fields: ${missing.join(", ")}`);
+ return;
+ }
+
+ await createAppraisalForm(formData);
+ setSubmitSuccess("Your form is submitted.");
+ setShowForm(false);
+ setFieldErrors({});
+ fetchData();
+ } catch (err) {
+ const serverErrors = err?.response?.data;
+ const parsed = parseServerErrors(serverErrors);
+ const generalError =
+ parsed.error || parsed.detail || parsed.non_field_errors;
+ if (generalError) {
+ setSubmitError(generalError);
+ delete parsed.error;
+ delete parsed.detail;
+ delete parsed.non_field_errors;
+ }
+ if (Object.keys(parsed).length > 0) {
+ setFieldErrors(parsed);
+ if (!generalError) {
+ setSubmitError("Please correct the highlighted fields.");
+ }
+ } else {
+ setSubmitError(
+ "Submission failed. Please check the form fields and try again.",
+ );
+ }
+ }
+ };
+
+ const handleDownload = async (id) => {
+ try {
+ const res = await downloadAppraisalForm(id);
+ const url = window.URL.createObjectURL(new Blob([res.data]));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `appraisal-${id}.txt`;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to download appraisal form.");
+ }
+ };
+
+ if (loading) return ;
+ return (
+
+
+
+
+
+
+
+
Performance Appraisals
+
+ Review and submit your annual self appraisals.
+
+
+
+
+
+
+
+
+ {appraisals.length} records
+
+
+
+
+
+ | Appraisal Year |
+ Department |
+ Reviewer |
+ Download |
+ Status |
+
+
+
+ {appraisals.map((app) => (
+
+ | {app.appraisal_year || app.period} |
+ {app.department || "-"} |
+ {app.reviewer_id || "-"} |
+
+
+ |
+
+
+ |
+
+ ))}
+ {appraisals.length === 0 && (
+
+ |
+ No appraisals submitted yet.
+ |
+
+ )}
+
+
+
+
+ {showForm && (
+
+
+
+ Self Appraisal Form
+
+
+ Complete the required fields to submit your appraisal.
+
+ {submitSuccess && (
+
+ {submitSuccess}
+
+ )}
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+
+ )}
+
+ );
+}
+export default AppraisalForm;
+
+AppraisalForm.propTypes = {
+ onBack: PropTypes.func.isRequired,
+};
diff --git a/src/Modules/HRModule/CPDAAdvance.jsx b/src/Modules/HRModule/CPDAAdvance.jsx
new file mode 100644
index 000000000..5019577eb
--- /dev/null
+++ b/src/Modules/HRModule/CPDAAdvance.jsx
@@ -0,0 +1,532 @@
+import React, { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import {
+ getCPDAAdvances,
+ createCPDAAdvance,
+ downloadCPDAAdvance,
+ withdrawCPDAAdvance,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+import FormField from "./components/FormField";
+import TextAreaField from "./components/TextAreaField";
+
+function CPDAAdvance({ onBack }) {
+ const [advances, setAdvances] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+ const [submitError, setSubmitError] = useState("");
+ const [submitSuccess, setSubmitSuccess] = useState("");
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [formData, setFormData] = useState({
+ employee_id: "",
+ employee_name: "",
+ department: "",
+ designation: "",
+ event_name: "",
+ event_type: "",
+ organized_by: "",
+ venue: "",
+ start_date: "",
+ end_date: "",
+ registration_fee: "",
+ travel_expense: "",
+ accommodation_expense: "",
+ other_expenses: "",
+ total_amount: "",
+ purpose_of_attending: "",
+ benefits_to_institution: "",
+ invitation_letter: "",
+ receipts: "",
+ certificates: "",
+ });
+
+ const fetchData = async () => {
+ try {
+ const res = await getCPDAAdvances();
+ setAdvances(res.data);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData({ ...formData, [name]: value });
+ if (fieldErrors[name]) {
+ setFieldErrors((prev) => ({ ...prev, [name]: "" }));
+ }
+ };
+
+ const parseServerErrors = (errors) => {
+ if (!errors || typeof errors !== "object") return {};
+ const next = {};
+ Object.entries(errors).forEach(([key, value]) => {
+ if (Array.isArray(value)) {
+ next[key] = value.join(" ");
+ } else if (typeof value === "string") {
+ next[key] = value;
+ }
+ });
+ return next;
+ };
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSubmitError("");
+ setSubmitSuccess("");
+ setFieldErrors({});
+ try {
+ const required = [
+ { key: "employee_id", label: "Employee ID" },
+ { key: "employee_name", label: "Employee Name" },
+ { key: "department", label: "Department" },
+ { key: "designation", label: "Designation" },
+ { key: "event_name", label: "Event Name" },
+ { key: "event_type", label: "Event Type" },
+ { key: "start_date", label: "Start Date" },
+ { key: "end_date", label: "End Date" },
+ { key: "total_amount", label: "Total Amount" },
+ { key: "purpose_of_attending", label: "Purpose of Attending" },
+ { key: "benefits_to_institution", label: "Benefits to Institution" },
+ ];
+ const missing = required
+ .filter((field) => !String(formData[field.key] || "").trim())
+ .map((field) => field.label);
+ if (missing.length > 0) {
+ const nextErrors = required.reduce((acc, field) => {
+ if (!String(formData[field.key] || "").trim()) {
+ acc[field.key] = "This field is required.";
+ }
+ return acc;
+ }, {});
+ setFieldErrors(nextErrors);
+ setSubmitError(`Please fill required fields: ${missing.join(", ")}`);
+ return;
+ }
+
+ if (formData.start_date && formData.end_date) {
+ if (formData.start_date > formData.end_date) {
+ setFieldErrors((prev) => ({
+ ...prev,
+ end_date: "End date must be on or after start date.",
+ }));
+ setSubmitError("End date must be on or after start date.");
+ return;
+ }
+ }
+
+ const numericFields = [
+ "registration_fee",
+ "travel_expense",
+ "accommodation_expense",
+ "other_expenses",
+ "total_amount",
+ ];
+ const hasInvalidAmount = numericFields.some((key) => {
+ if (String(formData[key] || "").trim()) {
+ const value = Number(formData[key]);
+ if (Number.isNaN(value) || value < 0) {
+ setFieldErrors((prev) => ({
+ ...prev,
+ [key]: "Amount must be a non-negative number.",
+ }));
+ setSubmitError("Amounts must be valid non-negative numbers.");
+ return true;
+ }
+ }
+ return false;
+ });
+ if (hasInvalidAmount) return;
+
+ await createCPDAAdvance(formData);
+ setSubmitSuccess("Your form is submitted.");
+ setShowForm(false);
+ setFieldErrors({});
+ fetchData();
+ } catch (err) {
+ const serverErrors = err?.response?.data;
+ const parsed = parseServerErrors(serverErrors);
+ const generalError =
+ parsed.error || parsed.detail || parsed.non_field_errors;
+ if (generalError) {
+ setSubmitError(generalError);
+ delete parsed.error;
+ delete parsed.detail;
+ delete parsed.non_field_errors;
+ }
+ if (Object.keys(parsed).length > 0) {
+ setFieldErrors(parsed);
+ if (!generalError) {
+ setSubmitError("Please correct the highlighted fields.");
+ }
+ } else {
+ setSubmitError(
+ "Submission failed. Please check the form fields and try again.",
+ );
+ }
+ }
+ };
+
+ const handleDownload = async (id) => {
+ try {
+ const res = await downloadCPDAAdvance(id);
+ const url = window.URL.createObjectURL(new Blob([res.data]));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `cpda-advance-${id}.txt`;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to download CPDA advance.");
+ }
+ };
+
+ const handleWithdraw = async (id) => {
+ const confirm = window.confirm("Withdraw this CPDA advance request?");
+ if (!confirm) return;
+ const remarks =
+ window.prompt("Reason for withdrawal (optional):", "") || "";
+ try {
+ await withdrawCPDAAdvance(id, remarks);
+ fetchData();
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to withdraw CPDA advance.");
+ }
+ };
+
+ if (loading) return ;
+ return (
+
+
+
+
+
+
+
+
CPDA Advance Requests
+
+ Submit advances for conferences, workshops, and travel.
+
+
+
+
+
+
+
+
+ {advances.length} records
+
+
+
+
+
+ | Event |
+ Type |
+ Start Date |
+ Total Amount |
+ Download |
+ Withdraw |
+ Status |
+
+
+
+ {advances.map((adv) => (
+
+ | {adv.event_name || adv.purpose} |
+ {adv.event_type || "-"} |
+ {adv.start_date || adv.submission_date} |
+ ₹{adv.total_amount || adv.amount_required} |
+
+
+ |
+
+ {(adv.approval_status || adv.status) === "PENDING" ? (
+
+ ) : (
+ -
+ )}
+ |
+
+
+ |
+
+ ))}
+ {advances.length === 0 && (
+
+ |
+ No CPDA advances submitted yet.
+ |
+
+ )}
+
+
+
+
+ {showForm && (
+
+
+
+ CPDA Advance Application
+
+
+ Complete the required fields to submit your CPDA request.
+
+ {submitSuccess && (
+
+ {submitSuccess}
+
+ )}
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+
+ )}
+
+ );
+}
+export default CPDAAdvance;
+
+CPDAAdvance.propTypes = {
+ onBack: PropTypes.func.isRequired,
+};
diff --git a/src/Modules/HRModule/CPDAReimbursement.jsx b/src/Modules/HRModule/CPDAReimbursement.jsx
new file mode 100644
index 000000000..0b6fb7458
--- /dev/null
+++ b/src/Modules/HRModule/CPDAReimbursement.jsx
@@ -0,0 +1,372 @@
+import React, { useState, useEffect } from "react";
+import { getCPDAReimbursements, createCPDAReimbursement } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+import FormField from "./components/FormField";
+import TextAreaField from "./components/TextAreaField";
+
+function CPDAReimbursement() {
+ const [reimbursements, setReimbursements] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+ const [submitError, setSubmitError] = useState("");
+ const [formData, setFormData] = useState({
+ employee_id: "",
+ employee_name: "",
+ department: "",
+ designation: "",
+ event_name: "",
+ event_type: "",
+ organized_by: "",
+ venue: "",
+ start_date: "",
+ end_date: "",
+ registration_fee: "",
+ travel_expense: "",
+ accommodation_expense: "",
+ other_expenses: "",
+ total_amount: "",
+ purpose_of_attending: "",
+ benefits_to_institution: "",
+ invitation_letter: "",
+ receipts: "",
+ certificates: "",
+ });
+
+ const fetchData = async () => {
+ try {
+ const res = await getCPDAReimbursements();
+ setReimbursements(res.data);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+ const handleChange = (e) =>
+ setFormData({ ...formData, [e.target.name]: e.target.value });
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSubmitError("");
+ try {
+ const required = [
+ { key: "employee_id", label: "Employee ID" },
+ { key: "employee_name", label: "Employee Name" },
+ { key: "department", label: "Department" },
+ { key: "designation", label: "Designation" },
+ { key: "event_name", label: "Event Name" },
+ { key: "event_type", label: "Event Type" },
+ { key: "start_date", label: "Start Date" },
+ { key: "end_date", label: "End Date" },
+ { key: "total_amount", label: "Total Amount" },
+ { key: "purpose_of_attending", label: "Purpose of Attending" },
+ { key: "benefits_to_institution", label: "Benefits to Institution" },
+ ];
+ const missing = required
+ .filter((field) => !String(formData[field.key] || "").trim())
+ .map((field) => field.label);
+ if (missing.length > 0) {
+ setSubmitError(`Please fill required fields: ${missing.join(", ")}`);
+ return;
+ }
+
+ if (formData.start_date && formData.end_date) {
+ if (formData.start_date > formData.end_date) {
+ setSubmitError("End date must be on or after start date.");
+ return;
+ }
+ }
+
+ const numericFields = [
+ "registration_fee",
+ "travel_expense",
+ "accommodation_expense",
+ "other_expenses",
+ "total_amount",
+ ];
+ const hasInvalidAmount = numericFields.some((key) => {
+ if (String(formData[key] || "").trim()) {
+ const value = Number(formData[key]);
+ if (Number.isNaN(value) || value < 0) {
+ setSubmitError("Amounts must be valid non-negative numbers.");
+ return true;
+ }
+ }
+ return false;
+ });
+ if (hasInvalidAmount) return;
+
+ await createCPDAReimbursement(formData);
+ setShowForm(false);
+ fetchData();
+ } catch (error) {
+ console.error(error);
+ setSubmitError("Submission failed. Please check the form fields.");
+ }
+ };
+
+ if (loading) return ;
+ return (
+
+
+
CPDA Reimbursement Requests
+
+
+
+
+
+ | Event |
+ Type |
+ Start Date |
+ Total Amount |
+ Status |
+
+
+
+ {reimbursements.map((reim) => (
+
+ |
+ {reim.event_name || reim.purpose}
+ |
+ {reim.event_type || "-"} |
+
+ {reim.start_date || reim.submission_date}
+ |
+
+ ₹{reim.total_amount || reim.advance_taken}
+ |
+
+
+ |
+
+ ))}
+
+
+ {showForm && (
+
+
+
+ CPDA Reimbursement Application
+
+
+ Complete the required fields to submit your CPDA request.
+
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+
+ )}
+
+ );
+}
+export default CPDAReimbursement;
diff --git a/src/Modules/HRModule/DirectorAppraisalReviews.jsx b/src/Modules/HRModule/DirectorAppraisalReviews.jsx
new file mode 100644
index 000000000..ebbd5c2f4
--- /dev/null
+++ b/src/Modules/HRModule/DirectorAppraisalReviews.jsx
@@ -0,0 +1,125 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { getAppraisalForms, reviewAppraisalForm } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function DirectorAppraisalReviews() {
+ const [loading, setLoading] = useState(true);
+ const [appraisals, setAppraisals] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchAppraisals = async () => {
+ try {
+ const res = await getAppraisalForms();
+ setAppraisals(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchAppraisals();
+ }, []);
+
+ const reviewQueue = useMemo(
+ () =>
+ appraisals.filter((item) =>
+ ["PENDING", "REVIEWED"].includes((item.status || "").toUpperCase()),
+ ),
+ [appraisals],
+ );
+
+ const handleDecision = async (appraisalId, action) => {
+ const remarks = window.prompt("Add director remarks (optional):", "") || "";
+ const rating = window.prompt("Rating (optional):", "") || "";
+ try {
+ setActionLoading(appraisalId);
+ await reviewAppraisalForm(appraisalId, { action, remarks, rating });
+ const res = await getAppraisalForms();
+ setAppraisals(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
Director Appraisal Reviews
+
+ Review appraisals assigned by HR or forwarded by HODs.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Appraisal Year |
+ Status |
+ Actions |
+
+
+
+ {reviewQueue.map((appraisal) => (
+
+ |
+ {appraisal.employee_name || appraisal.employee || "Employee"}
+ |
+
+ {appraisal.department || "-"}
+ |
+
+ {appraisal.appraisal_year || "-"}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {reviewQueue.length === 0 && (
+
+ |
+ No reviewed appraisals right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default DirectorAppraisalReviews;
diff --git a/src/Modules/HRModule/DirectorCpdaApprovals.jsx b/src/Modules/HRModule/DirectorCpdaApprovals.jsx
new file mode 100644
index 000000000..398293029
--- /dev/null
+++ b/src/Modules/HRModule/DirectorCpdaApprovals.jsx
@@ -0,0 +1,122 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { getCPDAAdvances, approveRejectCPDAAdvance } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function DirectorCpdaApprovals() {
+ const [loading, setLoading] = useState(true);
+ const [advances, setAdvances] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchAdvances = async () => {
+ try {
+ const res = await getCPDAAdvances();
+ setAdvances(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchAdvances();
+ }, []);
+
+ const pendingAdvances = useMemo(() => advances, [advances]);
+
+ const handleDecision = async (cpdaId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(cpdaId);
+ await approveRejectCPDAAdvance(cpdaId, decision, remarks);
+ const res = await getCPDAAdvances();
+ setAdvances(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
Director CPDA Approvals
+
+ Review CPDA requests forwarded by HR admin.
+
+
+
+
+
+
+
+ | Employee |
+ Event |
+ Start Date |
+ Total Amount |
+ Status |
+ Actions |
+
+
+
+ {pendingAdvances.map((adv) => (
+
+ |
+ {adv.employee_name || adv.employee || "Employee"}
+ |
+
+ {adv.event_name || adv.purpose || "-"}
+ |
+
+ {adv.start_date || adv.submission_date || "-"}
+ |
+
+ ₹{adv.total_amount || adv.amount_required}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {pendingAdvances.length === 0 && (
+
+ |
+ No pending CPDA requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default DirectorCpdaApprovals;
diff --git a/src/Modules/HRModule/DirectorLeaveApprovals.jsx b/src/Modules/HRModule/DirectorLeaveApprovals.jsx
new file mode 100644
index 000000000..89c30f6bb
--- /dev/null
+++ b/src/Modules/HRModule/DirectorLeaveApprovals.jsx
@@ -0,0 +1,365 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ getLeaveApplications,
+ approveRejectLeave,
+ decideLeaveCancellation,
+ decideLeaveExtension,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function DirectorLeaveApprovals() {
+ const [loading, setLoading] = useState(true);
+ const [leaves, setLeaves] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchLeaves = async () => {
+ try {
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchLeaves();
+ }, []);
+
+ const pendingLeaves = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.approval_status || item.status || "").toUpperCase() ===
+ "FORWARDED",
+ ),
+ [leaves],
+ );
+
+ const cancelRequests = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.cancel_status || "").toUpperCase() === "REQUESTED" &&
+ (item.cancel_current_approver_role || "").toUpperCase() ===
+ "DIRECTOR",
+ ),
+ [leaves],
+ );
+
+ const extensionRequests = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.extension_status || "").toUpperCase() === "REQUESTED" &&
+ (item.extension_current_approver_role || "").toUpperCase() ===
+ "DIRECTOR",
+ ),
+ [leaves],
+ );
+
+ const handleDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await approveRejectLeave(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleCancelDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await decideLeaveCancellation(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleExtensionDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await decideLeaveExtension(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
Director Leave Approvals
+
+ Review leave requests forwarded by HODs.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ Status |
+ Actions |
+
+
+
+ {pendingLeaves.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {pendingLeaves.length === 0 && (
+
+ |
+ No forwarded leave requests right now.
+ |
+
+ )}
+
+
+
+
+
+
Cancellation requests
+
+ Review approved leave cancellations routed to you.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ Requested by |
+ Reason |
+ Actions |
+
+
+
+ {cancelRequests.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.cancel_requested_by_role || "-"}
+ |
+
+ {leave.cancel_reason || "-"}
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {cancelRequests.length === 0 && (
+
+ |
+ No cancellation requests right now.
+ |
+
+ )}
+
+
+
+
+
+
Extension requests
+
+ Review approved leave extensions routed to you.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ New end |
+ New days |
+ Requested by |
+ Reason |
+ Actions |
+
+
+
+ {extensionRequests.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.extension_new_end_date || "-"}
+ |
+
+ {leave.extension_new_total_days || "-"}
+ |
+
+ {leave.extension_requested_by_role || "-"}
+ |
+
+ {leave.extension_reason || "-"}
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {extensionRequests.length === 0 && (
+
+ |
+ No extension requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default DirectorLeaveApprovals;
diff --git a/src/Modules/HRModule/EmployeeDashboard.jsx b/src/Modules/HRModule/EmployeeDashboard.jsx
new file mode 100644
index 000000000..a08f02618
--- /dev/null
+++ b/src/Modules/HRModule/EmployeeDashboard.jsx
@@ -0,0 +1,222 @@
+import React, { useEffect, useMemo, useState } from "react";
+import PropTypes from "prop-types";
+import { ClockCounterClockwise } from "@phosphor-icons/react";
+import {
+ getLeaveApplications,
+ getLeaveBalance,
+ getAppraisalForms,
+ getLTCApplications,
+ getCPDAAdvances,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function EmployeeDashboard({ onOpenTab }) {
+ const [loading, setLoading] = useState(true);
+ const [leaveApplications, setLeaveApplications] = useState([]);
+ const [appraisals, setAppraisals] = useState([]);
+ const [ltcApplications, setLtcApplications] = useState([]);
+ const [cpdaAdvances, setCpdaAdvances] = useState([]);
+ const [leaveBalance, setLeaveBalance] = useState([]);
+
+ useEffect(() => {
+ const fetchAll = async () => {
+ try {
+ const [leaveRes, balanceRes, appraisalRes, ltcRes, cpdaAdvanceRes] =
+ await Promise.all([
+ getLeaveApplications(),
+ getLeaveBalance(),
+ getAppraisalForms(),
+ getLTCApplications(),
+ getCPDAAdvances(),
+ ]);
+ setLeaveApplications(leaveRes.data || []);
+ setLeaveBalance(balanceRes.data || []);
+ setAppraisals(appraisalRes.data || []);
+ setLtcApplications(ltcRes.data || []);
+ setCpdaAdvances(cpdaAdvanceRes.data || []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchAll();
+ }, []);
+
+ const quickActions = [];
+
+ const historyItems = useMemo(() => {
+ const toDateValue = (value) => {
+ if (!value) return 0;
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? 0 : parsed;
+ };
+
+ const leaveHistory = leaveApplications.map((item) => ({
+ id: `leave-${item.id}`,
+ type: "Leave",
+ title: item.leave_type || item.leave_type_name || "Leave request",
+ dateLabel: item.start_date || item.from_date || "",
+ dateValue: toDateValue(item.start_date || item.from_date),
+ status: item.approval_status || item.status,
+ }));
+
+ const appraisalHistory = appraisals.map((item) => ({
+ id: `appraisal-${item.id}`,
+ type: "Appraisal",
+ title: item.appraisal_year
+ ? `Appraisal ${item.appraisal_year}`
+ : "Self appraisal",
+ dateLabel: item.applied_date || item.submission_date || "",
+ dateValue: toDateValue(item.applied_date || item.submission_date),
+ status: item.status,
+ }));
+
+ const ltcHistory = ltcApplications.map((item) => ({
+ id: `ltc-${item.id}`,
+ type: "LTC",
+ title: item.ltc_block_year
+ ? `Block year ${item.ltc_block_year}`
+ : "LTC request",
+ dateLabel: item.travel_start_date || item.leave_start_date || "",
+ dateValue: toDateValue(item.travel_start_date || item.leave_start_date),
+ status: item.approval_status || item.status,
+ }));
+
+ const cpdaAdvanceHistory = cpdaAdvances.map((item) => ({
+ id: `cpda-advance-${item.id}`,
+ type: "CPDA Advance",
+ title: item.event_name || "CPDA request",
+ dateLabel: item.applied_date || item.submission_date || "",
+ dateValue: toDateValue(item.applied_date || item.submission_date),
+ status: item.approval_status || item.status,
+ }));
+
+ return [
+ ...leaveHistory,
+ ...appraisalHistory,
+ ...ltcHistory,
+ ...cpdaAdvanceHistory,
+ ]
+ .sort((a, b) => b.dateValue - a.dateValue)
+ .slice(0, 8);
+ }, [leaveApplications, appraisals, ltcApplications, cpdaAdvances]);
+
+ if (loading) return ;
+
+ return (
+
+
+
Employee Dashboard
+
+
+ {leaveBalance.length > 0 && (
+
+
+ Leave balance
+
+
Live balance by leave type
+
+ {leaveBalance.map((item) => (
+
+
{item.leave_type_name}
+
{item.current_balance}
+
+ ))}
+
+
+ )}
+
+ {quickActions.length > 0 && (
+
+ {quickActions.map((action) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Recent request history
+
+
+
+ Latest updates across leave, appraisal, LTC, and CPDA workflows.
+
+
+
+
+
+
+ | Type |
+ Details |
+ Date |
+ Status |
+
+
+
+ {historyItems.map((item) => (
+
+ | {item.type} |
+ {item.title} |
+ {item.dateLabel || "-"} |
+
+
+ |
+
+ ))}
+ {historyItems.length === 0 && (
+
+ |
+ No recent requests yet.
+ |
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default EmployeeDashboard;
+
+EmployeeDashboard.propTypes = {
+ onOpenTab: PropTypes.func.isRequired,
+};
diff --git a/src/Modules/HRModule/HodAppraisalReviews.jsx b/src/Modules/HRModule/HodAppraisalReviews.jsx
new file mode 100644
index 000000000..b1b9707db
--- /dev/null
+++ b/src/Modules/HRModule/HodAppraisalReviews.jsx
@@ -0,0 +1,137 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { getAppraisalForms, reviewAppraisalForm } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function HodAppraisalReviews() {
+ const [loading, setLoading] = useState(true);
+ const [appraisals, setAppraisals] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchAppraisals = async () => {
+ try {
+ const res = await getAppraisalForms();
+ setAppraisals(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchAppraisals();
+ }, []);
+
+ const handleDecision = async (appraisalId, action) => {
+ const remarks = window.prompt("Add reviewer remarks (optional):", "") || "";
+ const rating = window.prompt("Rating (optional):", "") || "";
+ if ((action === "forward" || action === "approve") && !remarks.trim()) {
+ window.alert("Reviewer remarks are required for this action.");
+ return;
+ }
+ try {
+ setActionLoading(appraisalId);
+ await reviewAppraisalForm(appraisalId, { action, remarks, rating });
+ const res = await getAppraisalForms();
+ setAppraisals(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const pendingAppraisals = useMemo(
+ () =>
+ appraisals.filter(
+ (item) => (item.status || "").toUpperCase() === "PENDING",
+ ),
+ [appraisals],
+ );
+
+ if (loading) return ;
+
+ return (
+
+
+
HOD Appraisal Reviews
+
+ Review pending self-appraisals and add reviewer feedback.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Appraisal Year |
+ Status |
+ Actions |
+
+
+
+ {pendingAppraisals.map((appraisal) => (
+
+ |
+ {appraisal.employee_name || appraisal.employee || "Employee"}
+ |
+
+ {appraisal.department || "-"}
+ |
+
+ {appraisal.appraisal_year || "-"}
+ |
+
+
+ |
+
+
+
+
+
+
+ |
+
+ ))}
+ {pendingAppraisals.length === 0 && (
+
+ |
+ No pending appraisals right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default HodAppraisalReviews;
diff --git a/src/Modules/HRModule/HodLeaveApprovals.jsx b/src/Modules/HRModule/HodLeaveApprovals.jsx
new file mode 100644
index 000000000..38335023a
--- /dev/null
+++ b/src/Modules/HRModule/HodLeaveApprovals.jsx
@@ -0,0 +1,563 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ getLeaveApplications,
+ approveRejectLeave,
+ requestLeaveDocument,
+ decideLeaveCancellation,
+ decideLeaveExtension,
+ decideLeaveResumption,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function HodLeaveApprovals() {
+ const [loading, setLoading] = useState(true);
+ const [leaves, setLeaves] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchLeaves = async () => {
+ try {
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchLeaves();
+ }, []);
+
+ const handleDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await approveRejectLeave(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleDocumentRequest = async (leaveId) => {
+ const message = (window.prompt("Which document do you need?") || "").trim();
+ if (!message) {
+ return;
+ }
+ try {
+ setActionLoading(leaveId);
+ await requestLeaveDocument(leaveId, message);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ const errorMessage =
+ error?.response?.data?.error ||
+ "Unable to request document. Please try again.";
+ window.alert(errorMessage);
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleCancelDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await decideLeaveCancellation(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleExtensionDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await decideLeaveExtension(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleResumptionDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await decideLeaveResumption(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const pendingLeaves = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.approval_status || item.status || "").toUpperCase() ===
+ "PENDING",
+ ),
+ [leaves],
+ );
+
+ const cancelRequests = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.cancel_status || "").toUpperCase() === "REQUESTED" &&
+ (item.cancel_current_approver_role || "").toUpperCase() === "HOD",
+ ),
+ [leaves],
+ );
+
+ const extensionRequests = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.extension_status || "").toUpperCase() === "REQUESTED" &&
+ (item.extension_current_approver_role || "").toUpperCase() === "HOD",
+ ),
+ [leaves],
+ );
+
+ const resumptionRequests = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.resumption_status || "").toUpperCase() === "SUBMITTED" &&
+ (item.resumption_current_approver_role || "").toUpperCase() === "HOD",
+ ),
+ [leaves],
+ );
+
+ if (loading) return ;
+
+ return (
+
+
+
HOD Leave Approvals
+
+ Review pending leave requests submitted under your department.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ Nominee |
+ Nominee decision |
+ Document request |
+ Document submitted |
+ Status |
+ Actions |
+
+
+
+ {pendingLeaves.map((leave) => {
+ const isClRhLeave = ["Casual", "Restricted"].includes(
+ leave.leave_type || leave.leave_type_name || "",
+ );
+ return (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type ||
+ leave.leave_type_name ||
+ "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.handover_to
+ ? `${leave.handover_to}${leave.nominee_employee_name ? ` (${leave.nominee_employee_name})` : ""}`
+ : "-"}
+ |
+
+ {leave.handover_to
+ ? leave.nominee_status === "ACCEPTED"
+ ? "Accepted by nominee"
+ : leave.nominee_status === "DECLINED"
+ ? "Not accepted by nominee"
+ : "Pending nominee response"
+ : "Not required"}
+ |
+
+ {leave.document_request_status === "REQUESTED"
+ ? `Requested: ${leave.document_request_message}`
+ : leave.document_request_status === "SUBMITTED"
+ ? "Submitted"
+ : "Not requested"}
+ |
+
+ {leave.document_request_status === "SUBMITTED"
+ ? leave.document_submission || "Submitted"
+ : "-"}
+ |
+
+
+ |
+
+
+ {leave.document_request_status !== "REQUESTED" && (
+ <>
+ {isClRhLeave && (
+
+ )}
+ {!isClRhLeave && (
+
+ )}
+
+ >
+ )}
+
+
+ |
+
+ );
+ })}
+ {pendingLeaves.length === 0 && (
+
+ |
+ No pending leave requests right now.
+ |
+
+ )}
+
+
+
+
+
+
Cancellation requests
+
+ Review approved leave cancellations routed to you.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ Requested by |
+ Reason |
+ Actions |
+
+
+
+ {cancelRequests.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.cancel_requested_by_role || "-"}
+ |
+
+ {leave.cancel_reason || "-"}
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {cancelRequests.length === 0 && (
+
+ |
+ No cancellation requests right now.
+ |
+
+ )}
+
+
+
+
+
+
Resumption requests
+
+ Review leave resumption forms awaiting your approval.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ Resumption date |
+ Reason |
+ Actions |
+
+
+
+ {resumptionRequests.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.resumption_date || "-"}
+ |
+
+ {leave.resumption_reason || "-"}
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {resumptionRequests.length === 0 && (
+
+ |
+ No resumption requests right now.
+ |
+
+ )}
+
+
+
+
+
+
Extension requests
+
+ Review approved leave extensions routed to you.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ New end |
+ New days |
+ Requested by |
+ Reason |
+ Actions |
+
+
+
+ {extensionRequests.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.extension_new_end_date || "-"}
+ |
+
+ {leave.extension_new_total_days || "-"}
+ |
+
+ {leave.extension_requested_by_role || "-"}
+ |
+
+ {leave.extension_reason || "-"}
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {extensionRequests.length === 0 && (
+
+ |
+ No extension requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default HodLeaveApprovals;
diff --git a/src/Modules/HRModule/HrAdminAppraisalAssignments.jsx b/src/Modules/HRModule/HrAdminAppraisalAssignments.jsx
new file mode 100644
index 000000000..615a63cda
--- /dev/null
+++ b/src/Modules/HRModule/HrAdminAppraisalAssignments.jsx
@@ -0,0 +1,125 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { assignAppraisalForm, getAppraisalForms } from "./api";
+import LoadingSpinner from "./components/LoadingSpinner";
+import StatusBadge from "./components/StatusBadge";
+
+const ASSIGNMENT_OPTIONS = [
+ { value: "HOD", label: "Assign to HOD" },
+ { value: "DIRECTOR", label: "Assign to Director" },
+];
+
+function HrAdminAppraisalAssignments() {
+ const [loading, setLoading] = useState(true);
+ const [appraisals, setAppraisals] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ const fetchAppraisals = async () => {
+ try {
+ const res = await getAppraisalForms();
+ setAppraisals(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchAppraisals();
+ }, []);
+
+ const pendingUnassigned = useMemo(
+ () =>
+ appraisals.filter(
+ (item) =>
+ (item.status || "").toUpperCase() === "PENDING" &&
+ !String(item.assigned_reviewer_role || "").trim(),
+ ),
+ [appraisals],
+ );
+
+ const handleAssign = async (appraisalId, role) => {
+ try {
+ setActionLoading(appraisalId);
+ await assignAppraisalForm(appraisalId, { role });
+ await fetchAppraisals();
+ } catch (error) {
+ console.error(error);
+ window.alert("Assignment failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
Appraisal Assignment
+
+ Assign pending appraisals to HODs or the Director.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Appraisal Year |
+ Status |
+ Assign |
+
+
+
+ {pendingUnassigned.map((appraisal) => (
+
+ |
+ {appraisal.employee_name || "Employee"}
+ |
+
+ {appraisal.department || "-"}
+ |
+
+ {appraisal.appraisal_year || "-"}
+ |
+
+
+ |
+
+
+ {ASSIGNMENT_OPTIONS.map((option) => (
+
+ ))}
+
+ |
+
+ ))}
+ {pendingUnassigned.length === 0 && (
+
+ |
+ No pending appraisals waiting for assignment.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default HrAdminAppraisalAssignments;
diff --git a/src/Modules/HRModule/HrAdminCpdaReview.jsx b/src/Modules/HRModule/HrAdminCpdaReview.jsx
new file mode 100644
index 000000000..1f0d3bbc1
--- /dev/null
+++ b/src/Modules/HRModule/HrAdminCpdaReview.jsx
@@ -0,0 +1,130 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { getCPDAAdvances, approveRejectCPDAAdvance } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function HrAdminCpdaReview() {
+ const [loading, setLoading] = useState(true);
+ const [advances, setAdvances] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchAdvances = async () => {
+ try {
+ const res = await getCPDAAdvances();
+ setAdvances(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchAdvances();
+ }, []);
+
+ const pendingAdvances = useMemo(
+ () =>
+ advances.filter(
+ (item) =>
+ (item.approval_status || item.status || "").toUpperCase() ===
+ "PENDING",
+ ),
+ [advances],
+ );
+
+ const handleDecision = async (cpdaId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(cpdaId);
+ await approveRejectCPDAAdvance(cpdaId, decision, remarks);
+ const res = await getCPDAAdvances();
+ setAdvances(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
HR Admin CPDA Review
+
+ Verify CPDA advance requests and forward to director.
+
+
+
+
+
+
+
+ | Employee |
+ Event |
+ Start Date |
+ Total Amount |
+ Status |
+ Actions |
+
+
+
+ {pendingAdvances.map((adv) => (
+
+ |
+ {adv.employee_name || adv.employee || "Employee"}
+ |
+
+ {adv.event_name || adv.purpose || "-"}
+ |
+
+ {adv.start_date || adv.submission_date || "-"}
+ |
+
+ ₹{adv.total_amount || adv.amount_required}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {pendingAdvances.length === 0 && (
+
+ |
+ No pending CPDA requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default HrAdminCpdaReview;
diff --git a/src/Modules/HRModule/HrAdminLtcReview.jsx b/src/Modules/HRModule/HrAdminLtcReview.jsx
new file mode 100644
index 000000000..e465db4ec
--- /dev/null
+++ b/src/Modules/HRModule/HrAdminLtcReview.jsx
@@ -0,0 +1,133 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { getLTCApplications, approveRejectLTC } from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function HrAdminLtcReview() {
+ const [loading, setLoading] = useState(true);
+ const [ltcRequests, setLtcRequests] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchLtc = async () => {
+ try {
+ const res = await getLTCApplications();
+ setLtcRequests(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchLtc();
+ }, []);
+
+ const pendingRequests = useMemo(
+ () =>
+ ltcRequests.filter(
+ (item) =>
+ (item.approval_status || item.status || "").toUpperCase() ===
+ "PENDING",
+ ),
+ [ltcRequests],
+ );
+
+ const handleDecision = async (ltcId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(ltcId);
+ await approveRejectLTC(ltcId, decision, remarks);
+ const res = await getLTCApplications();
+ setLtcRequests(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
HR Admin LTC Review
+
+ Verify LTC documents and forward to accountant. HR cannot directly
+ approve.
+
+
+
+
+
+
+
+ | Employee |
+ Block Year |
+ Travel Dates |
+ Destination |
+ Status |
+ Actions |
+
+
+
+ {pendingRequests.map((ltc) => (
+
+ |
+ {ltc.employee_name || ltc.employee || "Employee"}
+ |
+
+ {ltc.ltc_block_year || "-"}
+ |
+
+ {`${ltc.travel_start_date || "-"} to ${
+ ltc.travel_end_date || "-"
+ }`}
+ |
+
+ {ltc.destination || "-"}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {pendingRequests.length === 0 && (
+
+ |
+ No pending LTC requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default HrAdminLtcReview;
diff --git a/src/Modules/HRModule/LTCForm.jsx b/src/Modules/HRModule/LTCForm.jsx
new file mode 100644
index 000000000..bf9256c1a
--- /dev/null
+++ b/src/Modules/HRModule/LTCForm.jsx
@@ -0,0 +1,587 @@
+import React, { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import {
+ getLTCApplications,
+ createLTCApplication,
+ downloadLTCApplication,
+ withdrawLTCApplication,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+import FormField from "./components/FormField";
+import TextAreaField from "./components/TextAreaField";
+
+function LTCForm({ onBack }) {
+ const [applications, setApplications] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+ const [submitError, setSubmitError] = useState("");
+ const [submitSuccess, setSubmitSuccess] = useState("");
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [formData, setFormData] = useState({
+ employee_id: "",
+ employee_name: "",
+ department: "",
+ designation: "",
+ ltc_block_year: "",
+ travel_start_date: "",
+ travel_end_date: "",
+ destination: "",
+ purpose_of_travel: "",
+ family_members: "",
+ relationship_details: "",
+ travel_mode: "",
+ ticket_number: "",
+ ticket_cost: "",
+ accommodation_cost: "",
+ other_expenses: "",
+ total_amount_claimed: "",
+ tickets_upload: "",
+ bills_upload: "",
+ previous_ltc_used: "",
+ last_ltc_date: "",
+ });
+
+ const fetchData = async () => {
+ try {
+ const res = await getLTCApplications();
+ setApplications(res.data);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+ const handleChange = (e) => {
+ const val =
+ e.target.type === "checkbox" ? e.target.checked : e.target.value;
+ const { name } = e.target;
+ setFormData({ ...formData, [name]: val });
+ if (fieldErrors[name]) {
+ setFieldErrors((prev) => ({ ...prev, [name]: "" }));
+ }
+ };
+ const parseServerErrors = (errors) => {
+ if (!errors || typeof errors !== "object") return {};
+ const next = {};
+ Object.entries(errors).forEach(([key, value]) => {
+ if (Array.isArray(value)) {
+ next[key] = value.join(" ");
+ } else if (typeof value === "string") {
+ next[key] = value;
+ }
+ });
+ return next;
+ };
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSubmitError("");
+ setSubmitSuccess("");
+ setFieldErrors({});
+ try {
+ const required = [
+ { key: "employee_id", label: "Employee ID" },
+ { key: "employee_name", label: "Employee Name" },
+ { key: "department", label: "Department" },
+ { key: "designation", label: "Designation" },
+ { key: "ltc_block_year", label: "LTC Block Year" },
+ { key: "travel_start_date", label: "Travel Start Date" },
+ { key: "travel_end_date", label: "Travel End Date" },
+ { key: "destination", label: "Destination" },
+ { key: "purpose_of_travel", label: "Purpose of Travel" },
+ { key: "travel_mode", label: "Travel Mode" },
+ { key: "total_amount_claimed", label: "Total Amount Claimed" },
+ { key: "previous_ltc_used", label: "Previous LTC Used" },
+ ];
+
+ const missing = required
+ .filter((field) => !String(formData[field.key] || "").trim())
+ .map((field) => field.label);
+ if (missing.length > 0) {
+ const nextErrors = required.reduce((acc, field) => {
+ if (!String(formData[field.key] || "").trim()) {
+ acc[field.key] = "This field is required.";
+ }
+ return acc;
+ }, {});
+ setFieldErrors(nextErrors);
+ setSubmitError(`Please fill required fields: ${missing.join(", ")}`);
+ return;
+ }
+
+ if (formData.travel_start_date && formData.travel_end_date) {
+ if (formData.travel_start_date > formData.travel_end_date) {
+ setFieldErrors((prev) => ({
+ ...prev,
+ travel_end_date: "Travel end date must be on or after start date.",
+ }));
+ setSubmitError("Travel end date must be on or after start date.");
+ return;
+ }
+ }
+
+ const numericFields = [
+ "ticket_cost",
+ "accommodation_cost",
+ "other_expenses",
+ "total_amount_claimed",
+ ];
+ const hasInvalidAmount = numericFields.some((key) => {
+ if (String(formData[key] || "").trim()) {
+ const value = Number(formData[key]);
+ if (Number.isNaN(value) || value < 0) {
+ setFieldErrors((prev) => ({
+ ...prev,
+ [key]: "Amount must be a non-negative number.",
+ }));
+ setSubmitError("Amounts must be valid non-negative numbers.");
+ return true;
+ }
+ }
+ return false;
+ });
+ if (hasInvalidAmount) return;
+
+ const prevRaw = String(formData.previous_ltc_used || "")
+ .trim()
+ .toLowerCase();
+ let previousLtcUsed = null;
+ if (prevRaw === "yes" || prevRaw === "true") {
+ previousLtcUsed = true;
+ } else if (prevRaw === "no" || prevRaw === "false") {
+ previousLtcUsed = false;
+ } else {
+ setFieldErrors((prev) => ({
+ ...prev,
+ previous_ltc_used: "Use Yes or No.",
+ }));
+ setSubmitError("Previous LTC Used must be Yes or No.");
+ return;
+ }
+
+ if (previousLtcUsed && !String(formData.last_ltc_date || "").trim()) {
+ setFieldErrors((prev) => ({
+ ...prev,
+ last_ltc_date: "Last LTC Date is required.",
+ }));
+ setSubmitError("Last LTC Date is required when previous LTC was used.");
+ return;
+ }
+
+ const payload = {
+ ...formData,
+ previous_ltc_used: previousLtcUsed,
+ };
+
+ await createLTCApplication(payload);
+ setSubmitSuccess("Your form is submitted.");
+ setShowForm(false);
+ setFieldErrors({});
+ fetchData();
+ } catch (err) {
+ const serverErrors = err?.response?.data;
+ const parsed = parseServerErrors(serverErrors);
+ const generalError =
+ parsed.error || parsed.detail || parsed.non_field_errors;
+ if (generalError) {
+ setSubmitError(generalError);
+ delete parsed.error;
+ delete parsed.detail;
+ delete parsed.non_field_errors;
+ }
+ if (Object.keys(parsed).length > 0) {
+ setFieldErrors(parsed);
+ if (!generalError) {
+ setSubmitError("Please correct the highlighted fields.");
+ }
+ } else {
+ setSubmitError(
+ "Submission failed. Please check the form fields and try again.",
+ );
+ }
+ }
+ };
+
+ const handleDownload = async (id) => {
+ try {
+ const res = await downloadLTCApplication(id);
+ const url = window.URL.createObjectURL(new Blob([res.data]));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `ltc-application-${id}.txt`;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to download LTC application.");
+ }
+ };
+ const handleWithdraw = async (id) => {
+ const confirm = window.confirm("Withdraw this LTC request?");
+ if (!confirm) return;
+ const remarks =
+ window.prompt("Reason for withdrawal (optional):", "") || "";
+ try {
+ await withdrawLTCApplication(id, remarks);
+ fetchData();
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to withdraw LTC request.");
+ }
+ };
+ if (loading) return ;
+ return (
+
+
+
+
+
+
+
+
LTC Applications
+
+ Plan and submit your LTC travel requests.
+
+
+
+
+
+
+
+
+ {applications.length} records
+
+
+
+
+
+ | Employee |
+ Block Year |
+ Travel Start |
+ Destination |
+ Download |
+ Withdraw |
+ Status |
+
+
+
+ {applications.map((app) => (
+
+ | {app.employee_name || app.name} |
+ {app.ltc_block_year || app.block_year} |
+ {app.travel_start_date || app.leave_start_date} |
+ {app.destination || app.place_of_visit} |
+
+
+ |
+
+ {(app.approval_status || app.status) === "PENDING" ? (
+
+ ) : (
+ -
+ )}
+ |
+
+
+ |
+
+ ))}
+ {applications.length === 0 && (
+
+ |
+ No LTC applications submitted yet.
+ |
+
+ )}
+
+
+
+
+ {showForm && (
+
+
+
+ LTC Application Form
+
+
+ Complete the required fields to submit your LTC request.
+
+ {submitSuccess && (
+
+ {submitSuccess}
+
+ )}
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+
+ )}
+
+ );
+}
+export default LTCForm;
+
+LTCForm.propTypes = {
+ onBack: PropTypes.func.isRequired,
+};
diff --git a/src/Modules/HRModule/LeaveApplication.jsx b/src/Modules/HRModule/LeaveApplication.jsx
new file mode 100644
index 000000000..7b55a480a
--- /dev/null
+++ b/src/Modules/HRModule/LeaveApplication.jsx
@@ -0,0 +1,1058 @@
+import React, { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import {
+ getLeaveApplications,
+ createLeaveApplication,
+ getLeaveBalance,
+ submitLeaveDocument,
+ downloadLeaveApplication,
+ withdrawLeaveApplication,
+ requestLeaveCancellation,
+ requestLeaveExtension,
+ submitLeaveResumption,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+import FormField from "./components/FormField";
+import TextAreaField from "./components/TextAreaField";
+
+function LeaveApplication({ onBack }) {
+ const [applications, setApplications] = useState([]);
+ const [balance, setBalance] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [statusFilter, setStatusFilter] = useState("ALL");
+ const [submitError, setSubmitError] = useState("");
+ const [submitSuccess, setSubmitSuccess] = useState("");
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [formData, setFormData] = useState({
+ employee_id: "",
+ employee_name: "",
+ department: "",
+ designation: "",
+ leave_type: "",
+ station_leave: "",
+ is_half_day: false,
+ half_day_slot: "",
+ start_date: "",
+ end_date: "",
+ total_days: "",
+ reason: "",
+ contact_during_leave: "",
+ address_during_leave: "",
+ nominee_employee_id: "",
+ handover_to: "",
+ handover_notes: "",
+ medical_certificate: "",
+ attachment_file: "",
+ });
+
+ const fetchData = async () => {
+ try {
+ const [appsRes, balRes] = await Promise.all([
+ getLeaveApplications(),
+ getLeaveBalance(),
+ ]);
+ const appsData = appsRes?.data?.results ?? appsRes?.data ?? [];
+ const balanceData = balRes?.data?.results ?? balRes?.data ?? [];
+ setApplications(Array.isArray(appsData) ? appsData : []);
+ setBalance(Array.isArray(balanceData) ? balanceData : []);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+
+ const computeTotalDays = (start, end) => {
+ if (!start || !end) return "";
+ const startDate = new Date(start);
+ const endDate = new Date(end);
+ if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()))
+ return "";
+ const diffMs = endDate.getTime() - startDate.getTime();
+ if (diffMs < 0) return "";
+ const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1;
+ return String(days);
+ };
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ const next = { ...formData, [name]: value };
+ if (fieldErrors[name]) {
+ setFieldErrors((prev) => ({ ...prev, [name]: "" }));
+ }
+ if (name === "start_date" || name === "end_date") {
+ next.total_days = computeTotalDays(next.start_date, next.end_date);
+ }
+ if (name === "leave_type") {
+ if (!["Casual", "Restricted"].includes(value)) {
+ next.station_leave = "";
+ }
+ if (value === "Vacation") {
+ next.nominee_employee_id = "";
+ }
+ if (value !== "Casual") {
+ next.is_half_day = false;
+ next.half_day_slot = "";
+ }
+ }
+ if (name === "station_leave" && value === "NOT_REQUIRED") {
+ next.nominee_employee_id = "";
+ }
+ if (name === "is_half_day") {
+ const { checked } = e.target;
+ next.is_half_day = checked;
+ if (checked) {
+ next.total_days = "0.5";
+ if (next.start_date) {
+ next.end_date = next.start_date;
+ }
+ } else {
+ next.half_day_slot = "";
+ next.total_days = computeTotalDays(next.start_date, next.end_date);
+ }
+ }
+ if ((name === "start_date" || name === "end_date") && next.is_half_day) {
+ next.end_date = next.start_date;
+ next.total_days = next.start_date ? "0.5" : "";
+ }
+ setFormData(next);
+ };
+
+ const parseServerErrors = (errors) => {
+ if (!errors || typeof errors !== "object") return {};
+ const next = {};
+ Object.entries(errors).forEach(([key, value]) => {
+ if (Array.isArray(value)) {
+ next[key] = value.join(" ");
+ } else if (typeof value === "string") {
+ next[key] = value;
+ }
+ });
+ return next;
+ };
+
+ const validateLeaveForm = () => {
+ const required = [
+ { key: "employee_id", label: "Employee ID" },
+ { key: "employee_name", label: "Employee Name" },
+ { key: "department", label: "Department" },
+ { key: "designation", label: "Designation" },
+ { key: "leave_type", label: "Leave Type" },
+ { key: "start_date", label: "Start Date" },
+ { key: "end_date", label: "End Date" },
+ { key: "reason", label: "Reason" },
+ { key: "contact_during_leave", label: "Contact during leave" },
+ { key: "address_during_leave", label: "Address during leave" },
+ ];
+ const nextErrors = {};
+ const missing = required
+ .filter((field) => !String(formData[field.key] || "").trim())
+ .map((field) => field.label);
+ required.forEach((field) => {
+ if (!String(formData[field.key] || "").trim()) {
+ nextErrors[field.key] = "This field is required.";
+ }
+ });
+
+ const isClRhLeave = ["Casual", "Restricted"].includes(formData.leave_type);
+ const isStationOnly =
+ isClRhLeave && formData.station_leave === "NOT_REQUIRED";
+ const isVacationLeave = formData.leave_type === "Vacation";
+
+ if (isClRhLeave && !formData.station_leave) {
+ missing.push("Station Leave");
+ nextErrors.station_leave = "Select a station leave option.";
+ }
+
+ if (formData.is_half_day) {
+ if (formData.leave_type !== "Casual") {
+ nextErrors.is_half_day = "Half-day is only for Casual leave.";
+ setSubmitError("Half-day is only allowed for Casual leave.");
+ setFieldErrors(nextErrors);
+ return false;
+ }
+ if (!formData.half_day_slot) {
+ missing.push("Half-day Slot");
+ nextErrors.half_day_slot = "Select AM or PM.";
+ }
+ if (
+ formData.start_date &&
+ formData.end_date &&
+ formData.start_date !== formData.end_date
+ ) {
+ nextErrors.end_date = "Half-day leave must be for a single day.";
+ setSubmitError("Half-day leave must be for a single day.");
+ setFieldErrors(nextErrors);
+ return false;
+ }
+ }
+
+ if (
+ !isStationOnly &&
+ !isVacationLeave &&
+ !String(formData.nominee_employee_id || "").trim()
+ ) {
+ missing.push("Nominee Employee ID");
+ nextErrors.nominee_employee_id = "Nominee Employee ID is required.";
+ }
+
+ if (
+ String(formData.nominee_employee_id || "").trim() &&
+ String(formData.employee_id || "").trim() ===
+ String(formData.nominee_employee_id || "").trim()
+ ) {
+ nextErrors.nominee_employee_id = "Nominee must be different.";
+ setSubmitError("Nominee must be different from the applicant.");
+ setFieldErrors(nextErrors);
+ return false;
+ }
+
+ if (missing.length > 0) {
+ setFieldErrors(nextErrors);
+ setSubmitError(`Please fill required fields: ${missing.join(", ")}`);
+ return false;
+ }
+
+ if (!formData.total_days) {
+ setFieldErrors((prev) => ({
+ ...prev,
+ total_days: "Total days is required.",
+ }));
+ setSubmitError("Total Days is required.");
+ return false;
+ }
+
+ return true;
+ };
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setSubmitError("");
+ setSubmitSuccess("");
+ setFieldErrors({});
+ if (!validateLeaveForm()) {
+ return;
+ }
+ try {
+ const payload = { ...formData };
+ if (!payload.nominee_employee_id) {
+ delete payload.nominee_employee_id;
+ }
+ await createLeaveApplication(payload);
+ setSubmitSuccess("Your form is submitted.");
+ setShowForm(false);
+ setFieldErrors({});
+ setFormData({
+ employee_id: "",
+ employee_name: "",
+ department: "",
+ designation: "",
+ leave_type: "",
+ station_leave: "",
+ is_half_day: false,
+ half_day_slot: "",
+ start_date: "",
+ end_date: "",
+ total_days: "",
+ reason: "",
+ contact_during_leave: "",
+ address_during_leave: "",
+ nominee_employee_id: "",
+ handover_to: "",
+ handover_notes: "",
+ medical_certificate: "",
+ attachment_file: "",
+ });
+ fetchData();
+ } catch (err) {
+ const serverErrors = err?.response?.data;
+ const parsed = parseServerErrors(serverErrors);
+ const generalError =
+ parsed.error || parsed.detail || parsed.non_field_errors;
+ if (generalError) {
+ setSubmitError(generalError);
+ delete parsed.error;
+ delete parsed.detail;
+ delete parsed.non_field_errors;
+ }
+ if (Object.keys(parsed).length > 0) {
+ setFieldErrors(parsed);
+ if (!generalError) {
+ setSubmitError("Please correct the highlighted fields.");
+ }
+ } else {
+ setSubmitError(
+ "Submission failed. Please check the form fields and try again.",
+ );
+ }
+ }
+ };
+
+ const handleDocumentSubmit = async (leaveId) => {
+ const submission = (
+ window.prompt("Provide the requested document (link/number/details):") ||
+ ""
+ ).trim();
+ if (!submission) {
+ return;
+ }
+ try {
+ await submitLeaveDocument(leaveId, submission);
+ fetchData();
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to submit document. Please try again.");
+ }
+ };
+
+ const handleDownload = async (leaveId) => {
+ try {
+ const res = await downloadLeaveApplication(leaveId);
+ const url = window.URL.createObjectURL(new Blob([res.data]));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `leave-application-${leaveId}.txt`;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to download leave application.");
+ }
+ };
+
+ const handleWithdraw = async (leaveId) => {
+ const confirm = window.confirm("Withdraw this leave request?");
+ if (!confirm) return;
+ const remarks =
+ window.prompt("Reason for withdrawal (optional):", "") || "";
+ try {
+ await withdrawLeaveApplication(leaveId, remarks);
+ fetchData();
+ } catch (err) {
+ console.error(err);
+ window.alert("Unable to withdraw leave request.");
+ }
+ };
+
+ const handleCancelRequest = async (leaveId) => {
+ const confirm = window.confirm(
+ "Request cancellation for this approved leave?",
+ );
+ if (!confirm) return;
+ const reason =
+ window.prompt("Reason for cancellation (optional):", "") || "";
+ try {
+ await requestLeaveCancellation(leaveId, reason);
+ fetchData();
+ } catch (err) {
+ console.error(err);
+ const message =
+ err?.response?.data?.error || "Unable to request cancellation.";
+ window.alert(message);
+ }
+ };
+
+ const handleExtensionRequest = async (app) => {
+ const confirm = window.confirm(
+ "Request an extension for this approved leave?",
+ );
+ if (!confirm) return;
+ const newEndDate = (
+ window.prompt("New end date (YYYY-MM-DD):", "") || ""
+ ).trim();
+ if (!newEndDate) return;
+ const parsed = new Date(newEndDate);
+ if (Number.isNaN(parsed.getTime())) {
+ window.alert("New end date must be in YYYY-MM-DD format.");
+ return;
+ }
+ if (app.end_date && newEndDate <= app.end_date) {
+ window.alert("New end date must be after the current end date.");
+ return;
+ }
+ const reason = window.prompt("Reason for extension (optional):", "") || "";
+ try {
+ await requestLeaveExtension(app.id, { new_end_date: newEndDate, reason });
+ fetchData();
+ } catch (err) {
+ console.error(err);
+ const message =
+ err?.response?.data?.error || "Unable to request extension.";
+ window.alert(message);
+ }
+ };
+
+ const handleResumptionSubmit = async (app) => {
+ const confirm = window.confirm("Submit resumption request for this leave?");
+ if (!confirm) return;
+ const resumptionDate = (
+ window.prompt("Resumption date (YYYY-MM-DD):", "") || ""
+ ).trim();
+ const reason = window.prompt("Resumption remarks (optional):", "") || "";
+ if (resumptionDate) {
+ const parsed = new Date(resumptionDate);
+ if (Number.isNaN(parsed.getTime())) {
+ window.alert("Resumption date must be in YYYY-MM-DD format.");
+ return;
+ }
+ if (app.end_date && resumptionDate <= app.end_date) {
+ window.alert("Resumption date must be after the leave end date.");
+ return;
+ }
+ }
+ try {
+ await submitLeaveResumption(app.id, {
+ resumption_date: resumptionDate,
+ reason,
+ });
+ fetchData();
+ } catch (err) {
+ console.error(err);
+ const message =
+ err?.response?.data?.error || "Unable to submit resumption.";
+ window.alert(message);
+ }
+ };
+
+ const normalizedStatus = (status) => (status || "").toUpperCase();
+ const isCancelAllowed = (app) => {
+ const startDateRaw = app.start_date || app.from_date;
+ if (!startDateRaw) return false;
+ const startDate = new Date(startDateRaw);
+ const today = new Date();
+ startDate.setHours(0, 0, 0, 0);
+ today.setHours(0, 0, 0, 0);
+ return today < startDate;
+ };
+ const isExtensionAllowed = (app) => {
+ const endDateRaw = app.end_date || app.to_date;
+ if (!endDateRaw) return false;
+ const endDate = new Date(endDateRaw);
+ const today = new Date();
+ endDate.setHours(0, 0, 0, 0);
+ today.setHours(0, 0, 0, 0);
+ return today < endDate;
+ };
+ const isResumptionAllowed = (app) => {
+ const endDateRaw = app.end_date || app.to_date;
+ if (!endDateRaw) return false;
+ const endDate = new Date(endDateRaw);
+ const today = new Date();
+ endDate.setHours(0, 0, 0, 0);
+ today.setHours(0, 0, 0, 0);
+ return today > endDate;
+ };
+ const statusCounts = applications.reduce((acc, app) => {
+ const key =
+ normalizedStatus(app.approval_status || app.status) || "UNKNOWN";
+ acc[key] = (acc[key] || 0) + 1;
+ return acc;
+ }, {});
+
+ const filteredApplications = applications.filter((app) => {
+ const term = searchTerm.trim().toLowerCase();
+ const name = (app.leave_type || app.leave_type_name || "").toLowerCase();
+ const matchesTerm = !term || name.includes(term);
+ const matchesStatus =
+ statusFilter === "ALL" ||
+ normalizedStatus(app.approval_status || app.status) === statusFilter;
+ return matchesTerm && matchesStatus;
+ });
+
+ if (loading) return ;
+
+ return (
+
+
+
+
+
+
+
+
Leave Applications
+
+ Track your requests and manage balances.
+
+
+
+
+
+
+
+
+
Total requests
+
{applications.length}
+
+
+
Pending
+
{statusCounts.PENDING || 0}
+
+
+
Approved
+
{statusCounts.APPROVED || 0}
+
+
+
Rejected
+
{statusCounts.REJECTED || 0}
+
+
+
+
+
+
+ Your Leave Balance
+
+
Live balance by leave type
+
+ {balance.length > 0 ? (
+
+ {balance.map((b) => (
+
+
+ {b.leave_type_name ||
+ b.leave_type?.name ||
+ b.leave_type ||
+ "Leave"}
+
+
{b.current_balance ?? 0}
+
+ ))}
+
+ ) : (
+
+ No leave balance available yet.
+
+ )}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ placeholder="Search by leave type"
+ className="fusion-input"
+ style={{ maxWidth: "280px" }}
+ />
+
+
+ {filteredApplications.length} records
+
+
+
+
+
+
+
+ | Leave Type |
+ From |
+ To |
+ Days |
+ Document request |
+ Download |
+ Withdraw/Cancel/Extend |
+ Resumption |
+ Cancel status |
+ Extension |
+ Status |
+
+
+
+ {filteredApplications.map((app) => (
+
+ |
+ {app.leave_type || app.leave_type_name || "Leave request"}
+ |
+ {app.start_date || app.from_date} |
+ {app.end_date || app.to_date} |
+ {app.total_days || app.num_days} |
+
+ {app.document_request_status === "REQUESTED" ? (
+
+
+ {app.document_request_message || "Document requested"}
+
+
+
+ ) : app.document_request_status === "SUBMITTED" ? (
+
+ Submitted
+
+ ) : (
+
+ Not requested
+
+ )}
+ |
+
+
+ |
+
+ {app.is_owner &&
+ ["PENDING", "FORWARDED"].includes(
+ app.approval_status || app.status,
+ ) ? (
+
+ ) : app.is_owner &&
+ (app.approval_status || app.status) === "APPROVED" &&
+ (app.cancel_status || "NOT_REQUESTED") ===
+ "NOT_REQUESTED" &&
+ isCancelAllowed(app) ? (
+
+ ) : app.is_owner &&
+ (app.approval_status || app.status) === "APPROVED" &&
+ (app.cancel_status || "NOT_REQUESTED") ===
+ "NOT_REQUESTED" ? (
+
+ Cancel window closed
+
+ ) : app.is_owner &&
+ (app.cancel_status || "NOT_REQUESTED") === "REQUESTED" ? (
+
+ Cancel requested
+
+ ) : app.is_owner &&
+ (app.approval_status || app.status) === "APPROVED" &&
+ (app.extension_status || "NOT_REQUESTED") ===
+ "NOT_REQUESTED" &&
+ isExtensionAllowed(app) ? (
+
+ ) : app.is_owner &&
+ (app.approval_status || app.status) === "APPROVED" &&
+ (app.extension_status || "NOT_REQUESTED") ===
+ "NOT_REQUESTED" ? (
+
+ Extension window closed
+
+ ) : app.is_owner &&
+ (app.extension_status || "NOT_REQUESTED") ===
+ "REQUESTED" ? (
+
+ Extension requested
+
+ ) : (
+ -
+ )}
+ |
+
+ {app.is_owner &&
+ (app.approval_status || app.status) === "APPROVED" &&
+ (app.resumption_status || "NOT_REQUESTED") ===
+ "NOT_REQUESTED" &&
+ isResumptionAllowed(app) ? (
+
+ ) : app.resumption_status &&
+ app.resumption_status !== "NOT_REQUESTED" ? (
+
+ {app.resumption_status}
+
+ ) : (
+ -
+ )}
+ |
+
+ {app.cancel_status && app.cancel_status !== "NOT_REQUESTED"
+ ? app.cancel_status
+ : "-"}
+ |
+
+ {app.extension_status &&
+ app.extension_status !== "NOT_REQUESTED"
+ ? `${app.extension_status}${app.extension_new_end_date ? ` (to ${app.extension_new_end_date})` : ""}`
+ : "-"}
+ |
+
+
+ |
+
+ ))}
+ {filteredApplications.length === 0 && (
+
+ |
+ No leave applications found.
+ |
+
+ )}
+
+
+
+
+
+ {showForm && (
+
+
+
New Leave Application
+
+ Fields marked required must be completed before submission.
+
+ {submitSuccess && (
+
+ {submitSuccess}
+
+ )}
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+
+ )}
+
+ );
+}
+export default LeaveApplication;
+
+LeaveApplication.propTypes = {
+ onBack: PropTypes.func.isRequired,
+};
diff --git a/src/Modules/HRModule/LeaveApplication.module.css b/src/Modules/HRModule/LeaveApplication.module.css
new file mode 100644
index 000000000..6ad7336d9
--- /dev/null
+++ b/src/Modules/HRModule/LeaveApplication.module.css
@@ -0,0 +1,66 @@
+.page {
+ background: linear-gradient(180deg, #f8fafc 0%, #ffffff 35%);
+}
+
+.modalOverlay {
+ background: rgba(15, 23, 42, 0.55);
+ backdrop-filter: blur(2px);
+}
+
+.modalCard {
+ border-radius: 16px;
+ box-shadow: 0 24px 64px rgba(15, 23, 42, 0.25);
+ border: 1px solid #e2e8f0;
+}
+
+.section {
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 16px;
+ background: #f8fafc;
+}
+
+.sectionAlt {
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 16px;
+ background: #ffffff;
+}
+
+.modalCard label {
+ color: #334155;
+ font-weight: 600;
+}
+
+.modalCard input,
+.modalCard select,
+.modalCard textarea {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid #cbd5f5;
+ border-radius: 10px;
+ font-size: 0.95rem;
+ color: #0f172a;
+ background: #ffffff;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.modalCard input:focus,
+.modalCard select:focus,
+.modalCard textarea:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
+}
+
+.modalCard h2 {
+ color: #0f172a;
+}
+
+.modalCard p {
+ color: #64748b;
+}
+
+.actionsRow button {
+ border-radius: 10px;
+}
diff --git a/src/Modules/HRModule/NomineeDashboard.jsx b/src/Modules/HRModule/NomineeDashboard.jsx
new file mode 100644
index 000000000..d7b55e002
--- /dev/null
+++ b/src/Modules/HRModule/NomineeDashboard.jsx
@@ -0,0 +1,151 @@
+import React, { useEffect, useMemo, useState } from "react";
+import PropTypes from "prop-types";
+import { ClockCounterClockwise } from "@phosphor-icons/react";
+import { decideLeaveNominee, getLeaveNomineeQueue } from "./api";
+import LoadingSpinner from "./components/LoadingSpinner";
+import StatusBadge from "./components/StatusBadge";
+
+function NomineeDashboard({ onBack }) {
+ const [loading, setLoading] = useState(true);
+ const [queue, setQueue] = useState([]);
+ const [error, setError] = useState("");
+
+ const fetchQueue = async () => {
+ setError("");
+ try {
+ const res = await getLeaveNomineeQueue();
+ const data = res?.data?.results ?? res?.data ?? [];
+ setQueue(Array.isArray(data) ? data : []);
+ } catch (err) {
+ setError("Unable to load nominee requests.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchQueue();
+ }, []);
+
+ const handleDecision = async (id, action) => {
+ try {
+ await decideLeaveNominee(id, action);
+ fetchQueue();
+ } catch (err) {
+ setError("Unable to submit your decision.");
+ }
+ };
+
+ const items = useMemo(() => queue, [queue]);
+
+ if (loading) return ;
+
+ return (
+
+
+
+
+
+
+
+
Nominee Dashboard
+
+ Respond to leave handover nominations.
+
+
+
{items.length} pending
+
+
+
+ {error && (
+
+ )}
+
+
+
+
+
+
+ Pending nominations
+
+
+
+ Accept or decline responsibility requests.
+
+
+
+
+
+
+ | Employee |
+ Leave Type |
+ Dates |
+ Status |
+ Action |
+
+
+
+ {items.map((item) => (
+
+ | {item.employee_name} |
+ {item.leave_type} |
+
+ {item.start_date} to {item.end_date}
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {items.length === 0 && (
+
+ |
+ No nominee requests right now.
+ |
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default NomineeDashboard;
+
+NomineeDashboard.propTypes = {
+ onBack: PropTypes.func.isRequired,
+};
diff --git a/src/Modules/HRModule/RegistrarLeaveApprovals.jsx b/src/Modules/HRModule/RegistrarLeaveApprovals.jsx
new file mode 100644
index 000000000..f3a5474aa
--- /dev/null
+++ b/src/Modules/HRModule/RegistrarLeaveApprovals.jsx
@@ -0,0 +1,373 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ getLeaveApplications,
+ approveRejectLeave,
+ decideLeaveCancellation,
+ decideLeaveExtension,
+} from "./api";
+import StatusBadge from "./components/StatusBadge";
+import LoadingSpinner from "./components/LoadingSpinner";
+
+function RegistrarLeaveApprovals() {
+ const [loading, setLoading] = useState(true);
+ const [leaves, setLeaves] = useState([]);
+ const [actionLoading, setActionLoading] = useState(null);
+
+ useEffect(() => {
+ const fetchLeaves = async () => {
+ try {
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchLeaves();
+ }, []);
+
+ const pendingLeaves = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.approval_status || item.status || "").toUpperCase() ===
+ "FORWARDED",
+ ),
+ [leaves],
+ );
+
+ const cancelRequests = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.cancel_status || "").toUpperCase() === "REQUESTED" &&
+ (item.cancel_current_approver_role || "").toUpperCase() ===
+ "REGISTRAR",
+ ),
+ [leaves],
+ );
+
+ const extensionRequests = useMemo(
+ () =>
+ leaves.filter(
+ (item) =>
+ (item.extension_status || "").toUpperCase() === "REQUESTED" &&
+ (item.extension_current_approver_role || "").toUpperCase() ===
+ "REGISTRAR",
+ ),
+ [leaves],
+ );
+
+ const handleDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await approveRejectLeave(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleCancelDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await decideLeaveCancellation(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ const handleExtensionDecision = async (leaveId, decision) => {
+ const remarks = window.prompt("Add remarks (optional):", "") || "";
+ try {
+ setActionLoading(leaveId);
+ await decideLeaveExtension(leaveId, decision, remarks);
+ const res = await getLeaveApplications();
+ setLeaves(res?.data ?? []);
+ } catch (error) {
+ console.error(error);
+ window.alert("Action failed. Please try again.");
+ } finally {
+ setActionLoading(null);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
Registrar Leave Approvals
+
+ Review leave requests forwarded by HR Admins or Accountants.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ Status |
+ Actions |
+
+
+
+ {pendingLeaves.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+
+ |
+
+
+
+
+
+
+ |
+
+ ))}
+ {pendingLeaves.length === 0 && (
+
+ |
+ No forwarded leave requests right now.
+ |
+
+ )}
+
+
+
+
+
+
Cancellation requests
+
+ Review approved leave cancellations routed to you.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ Requested by |
+ Reason |
+ Actions |
+
+
+
+ {cancelRequests.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.cancel_requested_by_role || "-"}
+ |
+
+ {leave.cancel_reason || "-"}
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {cancelRequests.length === 0 && (
+
+ |
+ No cancellation requests right now.
+ |
+
+ )}
+
+
+
+
+
+
Extension requests
+
+ Review approved leave extensions routed to you.
+
+
+
+
+
+
+
+ | Employee |
+ Department |
+ Leave Type |
+ From |
+ To |
+ New end |
+ New days |
+ Requested by |
+ Reason |
+ Actions |
+
+
+
+ {extensionRequests.map((leave) => (
+
+ |
+ {leave.employee_name || leave.employee || "Employee"}
+ |
+
+ {leave.department || "-"}
+ |
+
+ {leave.leave_type || leave.leave_type_name || "Leave request"}
+ |
+
+ {leave.start_date || leave.from_date || "-"}
+ |
+
+ {leave.end_date || leave.to_date || "-"}
+ |
+
+ {leave.extension_new_end_date || "-"}
+ |
+
+ {leave.extension_new_total_days || "-"}
+ |
+
+ {leave.extension_requested_by_role || "-"}
+ |
+
+ {leave.extension_reason || "-"}
+ |
+
+
+
+
+
+ |
+
+ ))}
+ {extensionRequests.length === 0 && (
+
+ |
+ No extension requests right now.
+ |
+
+ )}
+
+
+
+
+ );
+}
+
+export default RegistrarLeaveApprovals;
diff --git a/src/Modules/HRModule/api.js b/src/Modules/HRModule/api.js
new file mode 100644
index 000000000..d27d31268
--- /dev/null
+++ b/src/Modules/HRModule/api.js
@@ -0,0 +1,129 @@
+import axios from "axios";
+import { host } from "../../routes/globalRoutes"; // adjust path to your existing globalRoutes
+
+// If you prefer not to import, you can set baseURL directly:
+// const API_BASE = '/hr2/api';
+const API_BASE = `${host}/hr2/api`;
+
+const api = axios.create({
+ baseURL: API_BASE,
+ headers: { "Content-Type": "application/json" },
+});
+
+// Request interceptor to add auth token
+api.interceptors.request.use(
+ (config) => {
+ const token = localStorage.getItem("authToken");
+ if (token) {
+ config.headers.Authorization = `Token ${token}`;
+ }
+ return config;
+ },
+ (error) => Promise.reject(error),
+);
+
+// Response interceptor to handle 401
+api.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401) {
+ window.location.href = "/login";
+ }
+ return Promise.reject(error);
+ },
+);
+
+// ==================== LEAVE ====================
+export const getLeaveApplications = () => api.get("/leave-applications/");
+export const createLeaveApplication = (data) =>
+ api.post("/leave-applications/", data);
+export const getLeaveApplication = (id) =>
+ api.get(`/leave-applications/${id}/`);
+export const updateLeaveApplication = (id, data) =>
+ api.put(`/leave-applications/${id}/`, data);
+export const deleteLeaveApplication = (id) =>
+ api.delete(`/leave-applications/${id}/`);
+export const downloadLeaveApplication = (id) =>
+ api.get(`/leave-applications/${id}/download/`, { responseType: "blob" });
+export const withdrawLeaveApplication = (id, remarks) =>
+ api.post(`/leave-applications/${id}/withdraw/`, { remarks });
+export const requestLeaveCancellation = (id, reason) =>
+ api.post(`/leave-applications/${id}/cancel-request/`, { reason });
+export const decideLeaveCancellation = (id, decision, remarks) =>
+ api.post(`/leave-applications/${id}/cancel-decision/${decision}/`, {
+ remarks,
+ });
+export const getLeaveBalance = (employeeId = null) => {
+ const url = employeeId ? `/leave-balance/${employeeId}/` : "/leave-balance/";
+ return api.get(url);
+};
+export const handleLeaveResponsibility = (id, type, action, remarks) =>
+ api.post(`/leave-applications/${id}/responsibility/${type}/`, {
+ action,
+ remarks,
+ });
+export const approveRejectLeave = (id, decision, remarks) =>
+ api.post(`/leave-applications/${id}/${decision}/`, { remarks });
+export const requestLeaveDocument = (id, message) =>
+ api.post(`/leave-applications/${id}/request-document/`, { message });
+export const submitLeaveDocument = (id, submission) =>
+ api.post(`/leave-applications/${id}/submit-document/`, { submission });
+export const requestLeaveExtension = (id, payload = {}) =>
+ api.post(`/leave-applications/${id}/extension-request/`, payload);
+export const decideLeaveExtension = (id, decision, remarks) =>
+ api.post(`/leave-applications/${id}/extension-decision/${decision}/`, {
+ remarks,
+ });
+export const submitLeaveResumption = (id, payload = {}) =>
+ api.post(`/leave-applications/${id}/resumption/`, payload);
+export const decideLeaveResumption = (id, decision, remarks) =>
+ api.post(`/leave-applications/${id}/resumption-decision/${decision}/`, {
+ remarks,
+ });
+export const getLeaveNomineeQueue = () => api.get("/leave-nominee/");
+export const decideLeaveNominee = (id, action) =>
+ api.post(`/leave-nominee/${id}/`, { action });
+
+// ==================== LTC ====================
+export const getLTCApplications = () => api.get("/ltc/");
+export const createLTCApplication = (data) => api.post("/ltc/", data);
+export const getLTCApplication = (id) => api.get(`/ltc/${id}/`);
+export const updateLTCApplication = (id, data) => api.put(`/ltc/${id}/`, data);
+export const downloadLTCApplication = (id) =>
+ api.get(`/ltc/${id}/download/`, { responseType: "blob" });
+export const withdrawLTCApplication = (id, remarks) =>
+ api.post(`/ltc/${id}/withdraw/`, { remarks });
+export const approveRejectLTC = (id, decision, remarks) =>
+ api.post(`/ltc/${id}/${decision}/`, { remarks });
+
+// ==================== CPDA ADVANCE ====================
+export const getCPDAAdvances = () => api.get("/cpda-advances/");
+export const createCPDAAdvance = (data) => api.post("/cpda-advances/", data);
+export const getCPDAAdvance = (id) => api.get(`/cpda-advances/${id}/`);
+export const downloadCPDAAdvance = (id) =>
+ api.get(`/cpda-advances/${id}/download/`, { responseType: "blob" });
+export const withdrawCPDAAdvance = (id, remarks) =>
+ api.post(`/cpda-advances/${id}/withdraw/`, { remarks });
+export const approveRejectCPDAAdvance = (id, decision, remarks) =>
+ api.post(`/cpda-advances/${id}/${decision}/`, { remarks });
+
+// ==================== CPDA REIMBURSEMENT ====================
+export const getCPDAReimbursements = () => api.get("/cpda-reimbursements/");
+export const createCPDAReimbursement = (data) =>
+ api.post("/cpda-reimbursements/", data);
+export const getCPDAReimbursement = (id) =>
+ api.get(`/cpda-reimbursements/${id}/`);
+export const approveRejectCPDAReimbursement = (id, decision, remarks) =>
+ api.post(`/cpda-reimbursements/${id}/${decision}/`, { remarks });
+
+// ==================== APPRAISAL FORMS ====================
+export const getAppraisalForms = () => api.get("/appraisal-forms/");
+export const createAppraisalForm = (data) =>
+ api.post("/appraisal-forms/", data);
+export const getAppraisalForm = (id) => api.get(`/appraisal-forms/${id}/`);
+export const downloadAppraisalForm = (id) =>
+ api.get(`/appraisal-forms/${id}/download/`, { responseType: "blob" });
+export const reviewAppraisalForm = (id, payload = {}) =>
+ api.post(`/appraisal-forms/${id}/review/`, payload);
+export const assignAppraisalForm = (id, payload = {}) =>
+ api.post(`/appraisal-forms/${id}/assign/`, payload);
diff --git a/src/Modules/HRModule/components/FormField.jsx b/src/Modules/HRModule/components/FormField.jsx
new file mode 100644
index 000000000..e2e585a07
--- /dev/null
+++ b/src/Modules/HRModule/components/FormField.jsx
@@ -0,0 +1,78 @@
+import PropTypes from "prop-types";
+
+function FormField({
+ label,
+ name,
+ type = "text",
+ value,
+ onChange,
+ required = false,
+ readOnly = false,
+ step,
+ placeholder,
+ min,
+ max,
+ disabled = false,
+ autoComplete,
+ pattern,
+ error,
+}) {
+ const inputId = name ? `field-${name}` : undefined;
+ const errorId = error ? `${inputId}-error` : undefined;
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+FormField.propTypes = {
+ label: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ type: PropTypes.string,
+ value: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ PropTypes.bool,
+ ]),
+ onChange: PropTypes.func.isRequired,
+ required: PropTypes.bool,
+ readOnly: PropTypes.bool,
+ step: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ placeholder: PropTypes.string,
+ min: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ disabled: PropTypes.bool,
+ autoComplete: PropTypes.string,
+ pattern: PropTypes.string,
+ error: PropTypes.string,
+};
+
+export default FormField;
diff --git a/src/Modules/HRModule/components/LoadingSpinner.jsx b/src/Modules/HRModule/components/LoadingSpinner.jsx
new file mode 100644
index 000000000..5c37f372e
--- /dev/null
+++ b/src/Modules/HRModule/components/LoadingSpinner.jsx
@@ -0,0 +1,8 @@
+function LoadingSpinner() {
+ return (
+
+ );
+}
+export default LoadingSpinner;
diff --git a/src/Modules/HRModule/components/StatusBadge.jsx b/src/Modules/HRModule/components/StatusBadge.jsx
new file mode 100644
index 000000000..2d39871d8
--- /dev/null
+++ b/src/Modules/HRModule/components/StatusBadge.jsx
@@ -0,0 +1,26 @@
+import PropTypes from "prop-types";
+
+function StatusBadge({ status }) {
+ const colors = {
+ PENDING: "bg-yellow-100 text-yellow-800",
+ FORWARDED: "bg-blue-100 text-blue-800",
+ APPROVED: "bg-green-100 text-green-800",
+ REJECTED: "bg-red-100 text-red-800",
+ CANCELLED: "bg-gray-200 text-gray-800",
+ DRAFT: "bg-gray-100 text-gray-800",
+ SUBMITTED: "bg-indigo-100 text-indigo-800",
+ };
+ return (
+
+ {status}
+
+ );
+}
+
+StatusBadge.propTypes = {
+ status: PropTypes.string,
+};
+
+export default StatusBadge;
diff --git a/src/Modules/HRModule/components/TextAreaField.jsx b/src/Modules/HRModule/components/TextAreaField.jsx
new file mode 100644
index 000000000..5e8de835b
--- /dev/null
+++ b/src/Modules/HRModule/components/TextAreaField.jsx
@@ -0,0 +1,48 @@
+import PropTypes from "prop-types";
+
+function TextAreaField({
+ label,
+ name,
+ value,
+ onChange,
+ required = false,
+ error,
+}) {
+ const inputId = name ? `field-${name}` : undefined;
+ const errorId = error ? `${inputId}-error` : undefined;
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
+TextAreaField.propTypes = {
+ label: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ onChange: PropTypes.func.isRequired,
+ required: PropTypes.bool,
+ error: PropTypes.string,
+};
+
+export default TextAreaField;
diff --git a/src/Modules/HRModule/index.jsx b/src/Modules/HRModule/index.jsx
new file mode 100644
index 000000000..c65db5281
--- /dev/null
+++ b/src/Modules/HRModule/index.jsx
@@ -0,0 +1,92 @@
+import React, { useEffect, useState } from "react";
+import { useLocation } from "react-router-dom";
+import { Tabs } from "@mantine/core";
+import { useSelector } from "react-redux";
+import EmployeeDashboard from "./EmployeeDashboard";
+import LeaveApplication from "./LeaveApplication";
+import AppraisalForm from "./AppraisalForm";
+import CPDAAdvance from "./CPDAAdvance";
+import LTCForm from "./LTCForm";
+import NomineeDashboard from "./NomineeDashboard";
+
+function HR2Module() {
+ const location = useLocation();
+ const [activeTab, setActiveTab] = useState("dashboard");
+ const role = useSelector((state) => state.user.role);
+ const isPrivilegedRole = /hod|director|registrar|accountant|hr/i.test(
+ role || "",
+ );
+ const hideFinanceTabs = false;
+ const showNominee = !isPrivilegedRole;
+
+ useEffect(() => {
+ const params = new URLSearchParams(location.search);
+ const tab = params.get("tab");
+ const allowedTabs = ["dashboard", "leave", "appraisal"];
+ if (!hideFinanceTabs) {
+ allowedTabs.push("cpda-advance", "ltc");
+ }
+ if (showNominee) {
+ allowedTabs.push("nominee");
+ }
+ if (tab && allowedTabs.includes(tab)) {
+ setActiveTab(tab);
+ }
+ }, [location.search, showNominee]);
+
+ return (
+
+
+
+
+ {showNominee && (
+
+ )}
+
+
+ {!hideFinanceTabs && (
+
+ )}
+ {!hideFinanceTabs && }
+
+
+
+
+
+
+ {showNominee && (
+
+ setActiveTab("dashboard")} />
+
+ )}
+
+
+ setActiveTab("dashboard")} />
+
+
+ {!hideFinanceTabs && (
+
+ setActiveTab("dashboard")} />
+
+ )}
+
+
+ setActiveTab("dashboard")} />
+
+
+ {!hideFinanceTabs && (
+
+ setActiveTab("dashboard")} />
+
+ )}
+
+
+ );
+}
+
+export default HR2Module;
diff --git a/src/components/sidebarContent.jsx b/src/components/sidebarContent.jsx
index b77581b39..d1d3d4d56 100644
--- a/src/components/sidebarContent.jsx
+++ b/src/components/sidebarContent.jsx
@@ -42,6 +42,9 @@ import { setCurrentModule } from "../redux/moduleslice";
function SidebarContent({ isCollapsed, toggleSidebar }) {
const role = useSelector((state) => state.user.role);
+ const isEmployee = /employee/i.test(role || "");
+ const isFacultyEmployee =
+ /assistant\s+professor|associate\s+professor|professor/i.test(role || "");
const Modules = [
{
@@ -132,7 +135,7 @@ function SidebarContent({ isCollapsed, toggleSidebar }) {
label: "Human Resource",
id: "hr",
icon: ,
- url: "/",
+ url: "/hr2",
},
{
label: "Examination",
@@ -140,7 +143,7 @@ function SidebarContent({ isCollapsed, toggleSidebar }) {
icon: ,
url: "/examination",
},
- {
+ {
label: "Database",
id: "database",
icon: ,
@@ -193,11 +196,17 @@ function SidebarContent({ isCollapsed, toggleSidebar }) {
const navigate = useNavigate();
useEffect(() => {
- const filterModules = Modules.filter(
- (module) => accessibleModules[module.id] || module.id === "home",
- );
+ const filterModules = Modules.filter((module) => {
+ if (
+ (isEmployee || isFacultyEmployee) &&
+ ["course_registration", "examinations"].includes(module.id)
+ ) {
+ return false;
+ }
+ return accessibleModules[module.id] || module.id === "home";
+ });
setFilteredModules(filterModules);
- }, [accessibleModules]);
+ }, [accessibleModules, isEmployee]);
const handleModuleClick = (item) => {
setSelected(item.label);
diff --git a/src/index.css b/src/index.css
index 39b56ad15..4e93eb74f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -20,4 +20,237 @@
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
+
+:root {
+ --fusion-primary: #15abff;
+ --fusion-bg: #f5f7f8;
+ --fusion-surface: #ffffff;
+ --fusion-border: #e2e8f0;
+ --fusion-text: #0f172a;
+ --fusion-muted: #64748b;
+}
+
+.fusion-page {
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.fusion-card {
+ background: var(--fusion-surface);
+ border: 1px solid var(--fusion-border);
+ border-radius: 16px;
+ padding: 20px;
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
+}
+
+.fusion-heading {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--fusion-text);
+ margin: 0;
+}
+
+.fusion-subtitle {
+ font-size: 14px;
+ color: var(--fusion-muted);
+ margin: 4px 0 0;
+}
+
+.fusion-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.fusion-button-primary {
+ background: var(--fusion-primary);
+ color: #ffffff;
+ border: none;
+ border-radius: 10px;
+ padding: 10px 16px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.fusion-button-primary:hover {
+ background: #1394df;
+}
+
+.fusion-button-ghost {
+ border: 1px solid var(--fusion-border);
+ background: #ffffff;
+ color: #334155;
+ border-radius: 8px;
+ padding: 8px 12px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.fusion-grid {
+ display: grid;
+ gap: 16px;
+}
+
+.fusion-grid-4 {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+}
+
+.fusion-stat {
+ background: var(--fusion-surface);
+ border: 1px solid var(--fusion-border);
+ border-radius: 14px;
+ padding: 16px;
+ box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
+}
+
+.fusion-stat-label {
+ font-size: 13px;
+ color: var(--fusion-muted);
+}
+
+.fusion-stat-value {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--fusion-text);
+ margin-top: 6px;
+}
+
+.fusion-balance-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.fusion-balance-card {
+ background: #f8fafc;
+ border-radius: 12px;
+ padding: 12px;
+}
+
+.fusion-balance-label {
+ font-size: 13px;
+ color: var(--fusion-muted);
+}
+
+.fusion-balance-value {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--fusion-text);
+ margin-top: 6px;
+}
+
+.fusion-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+}
+
+.fusion-table th {
+ text-align: left;
+ padding: 12px 16px;
+ background: #f8fafc;
+ color: #475569;
+ font-weight: 600;
+}
+
+.fusion-table td {
+ padding: 12px 16px;
+ border-top: 1px solid #edf2f7;
+ color: #334155;
+}
+
+.fusion-table-wrap {
+ border: 1px solid var(--fusion-border);
+ border-radius: 16px;
+ overflow: hidden;
+ background: var(--fusion-surface);
+}
+
+.fusion-filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 16px;
+ border-bottom: 1px solid var(--fusion-border);
+}
+
+.fusion-input,
+.fusion-select,
+.fusion-textarea {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid #cbd5f5;
+ border-radius: 10px;
+ font-size: 14px;
+ background: #ffffff;
+ color: var(--fusion-text);
+}
+
+.fusion-input:focus,
+.fusion-select:focus,
+.fusion-textarea:focus {
+ outline: none;
+ border-color: var(--fusion-primary);
+ box-shadow: 0 0 0 3px rgba(21, 171, 255, 0.15);
+}
+
+.fusion-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: #475569;
+}
+
+.fusion-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.fusion-modal {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(15, 23, 42, 0.55);
+ z-index: 50;
+}
+
+.fusion-modal-card {
+ width: min(860px, 100%);
+ max-height: 90vh;
+ overflow-y: auto;
+ background: #ffffff;
+ border-radius: 18px;
+ padding: 24px;
+ box-shadow: 0 24px 64px rgba(15, 23, 42, 0.25);
+}
+
+.fusion-section {
+ border: 1px solid var(--fusion-border);
+ border-radius: 12px;
+ padding: 16px;
+ background: #f8fafc;
+}
+
+.fusion-section-alt {
+ border: 1px solid var(--fusion-border);
+ border-radius: 12px;
+ padding: 16px;
+ background: #ffffff;
+}
+
+@media (max-width: 720px) {
+ .fusion-page {
+ padding: 16px;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/globalRoutes.zip b/src/routes/globalRoutes.zip
new file mode 100644
index 000000000..b2fcf779d
Binary files /dev/null and b/src/routes/globalRoutes.zip differ
diff --git a/src/routes/hrModuleRoutes.zip b/src/routes/hrModuleRoutes.zip
new file mode 100644
index 000000000..29a29bd72
Binary files /dev/null and b/src/routes/hrModuleRoutes.zip differ
diff --git a/src/routes/hrModuleRoutes/index.jsx b/src/routes/hrModuleRoutes/index.jsx
new file mode 100644
index 000000000..16d41f662
--- /dev/null
+++ b/src/routes/hrModuleRoutes/index.jsx
@@ -0,0 +1,8 @@
+import React from "react";
+import HR2Module from "../../Modules/HRModule";
+
+function HRModuleRoutes() {
+ return ;
+}
+
+export default HRModuleRoutes;