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. +

+
+ +
+ + + + + + + + + + + + + {reviewQueue.map((adv) => ( + + + + + + + + + ))} + {reviewQueue.length === 0 && ( + + + + )} + +
EmployeeEventStart DateTotal AmountStatusActions
+ {adv.employee_name || adv.employee || "Employee"} + + {adv.event_name || adv.purpose || "-"} + + {adv.start_date || adv.submission_date || "-"} + + ₹{adv.total_amount || adv.amount_required} + + + +
+ + +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + + {reviewQueue.map((ltc) => ( + + + + + + + + + ))} + {reviewQueue.length === 0 && ( + + + + )} + +
EmployeeBlock YearTravel DatesDestinationStatusActions
+ {ltc.employee_name || ltc.employee || "Employee"} + + {ltc.ltc_block_year || "-"} + + {`${ltc.travel_start_date || "-"} to ${ + ltc.travel_end_date || "-" + }`} + + {ltc.destination || "-"} + + + +
+ + +
+
+ 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 +
+
+ + + + + + + + + + + + {appraisals.map((app) => ( + + + + + + + + ))} + {appraisals.length === 0 && ( + + + + )} + +
Appraisal YearDepartmentReviewerDownloadStatus
{app.appraisal_year || app.period}{app.department || "-"}{app.reviewer_id || "-"} + + + +
+ No appraisals submitted yet. +
+
+
+ {showForm && ( +
+
+

+ Self Appraisal Form +

+

+ Complete the required fields to submit your appraisal. +

+ {submitSuccess && ( +
+ {submitSuccess} +
+ )} + {submitError && ( +
+ {submitError} +
+ )} +
+
+

+ Basic Details +

+
+ + + + + +
+
+ +
+

+ Self Assessment +

+ + + + +
+ +
+

+ Performance Sections +

+
+ + + + + +
+
+ +
+

+ Development +

+
+ + + +
+
+ +
+

+ Goals +

+ + + +
+ +
+ + +
+
+
+
+ )} +
+ ); +} +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 +
+
+ + + + + + + + + + + + + + {advances.map((adv) => ( + + + + + + + + + + ))} + {advances.length === 0 && ( + + + + )} + +
EventTypeStart DateTotal AmountDownloadWithdrawStatus
{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" ? ( + + ) : ( + - + )} + + +
+ No CPDA advances submitted yet. +
+
+
+ {showForm && ( +
+
+

+ CPDA Advance Application +

+

+ Complete the required fields to submit your CPDA request. +

+ {submitSuccess && ( +
+ {submitSuccess} +
+ )} + {submitError && ( +
+ {submitError} +
+ )} +
+
+

+ Basic Details +

+
+ + + + +
+
+ +
+

+ Event Details +

+
+ + + + + + +
+
+ +
+

+ Expense Details +

+
+ + + + + +
+
+ +
+

+ Purpose +

+ + +
+ +
+

+ Documents +

+
+ + + +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} +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

+ +
+ + + + + + + + + + + + {reimbursements.map((reim) => ( + + + + + + + + ))} + +
EventTypeStart DateTotal AmountStatus
+ {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} +
+ )} +
+
+

+ Basic Details +

+
+ + + + +
+
+ +
+

+ Event Details +

+
+ + + + + + +
+
+ +
+

+ Expense Details +

+
+ + + + + +
+
+ +
+

+ Purpose +

+ + +
+ +
+

+ Documents +

+
+ + + +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} +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. +

+
+ +
+ + + + + + + + + + + + {reviewQueue.map((appraisal) => ( + + + + + + + + ))} + {reviewQueue.length === 0 && ( + + + + )} + +
EmployeeDepartmentAppraisal YearStatusActions
+ {appraisal.employee_name || appraisal.employee || "Employee"} + + {appraisal.department || "-"} + + {appraisal.appraisal_year || "-"} + + + +
+ + +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + + {pendingAdvances.map((adv) => ( + + + + + + + + + ))} + {pendingAdvances.length === 0 && ( + + + + )} + +
EmployeeEventStart DateTotal AmountStatusActions
+ {adv.employee_name || adv.employee || "Employee"} + + {adv.event_name || adv.purpose || "-"} + + {adv.start_date || adv.submission_date || "-"} + + ₹{adv.total_amount || adv.amount_required} + + + +
+ + +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + + + {pendingLeaves.map((leave) => ( + + + + + + + + + + ))} + {pendingLeaves.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToStatusActions
+ {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 || "-"} + + + +
+ + +
+
+ No forwarded leave requests right now. +
+
+ +
+

Cancellation requests

+

+ Review approved leave cancellations routed to you. +

+
+ +
+ + + + + + + + + + + + + + + {cancelRequests.map((leave) => ( + + + + + + + + + + + ))} + {cancelRequests.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToRequested byReasonActions
+ {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 || "-"} + +
+ + +
+
+ No cancellation requests right now. +
+
+ +
+

Extension requests

+

+ Review approved leave extensions routed to you. +

+
+ +
+ + + + + + + + + + + + + + + + + {extensionRequests.map((leave) => ( + + + + + + + + + + + + + ))} + {extensionRequests.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToNew endNew daysRequested byReasonActions
+ {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 || "-"} + +
+ + +
+
+ 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. +

+
+
+ + + + + + + + + + + {historyItems.map((item) => ( + + + + + + + ))} + {historyItems.length === 0 && ( + + + + )} + +
TypeDetailsDateStatus
{item.type}{item.title}{item.dateLabel || "-"} + +
+ 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. +

+
+ +
+ + + + + + + + + + + + {pendingAppraisals.map((appraisal) => ( + + + + + + + + ))} + {pendingAppraisals.length === 0 && ( + + + + )} + +
EmployeeDepartmentAppraisal YearStatusActions
+ {appraisal.employee_name || appraisal.employee || "Employee"} + + {appraisal.department || "-"} + + {appraisal.appraisal_year || "-"} + + + +
+ + + +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + + + + + + + {pendingLeaves.map((leave) => { + const isClRhLeave = ["Casual", "Restricted"].includes( + leave.leave_type || leave.leave_type_name || "", + ); + return ( + + + + + + + + + + + + + + ); + })} + {pendingLeaves.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToNomineeNominee decisionDocument requestDocument submittedStatusActions
+ {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 && ( + + )} + + + )} + +
+
+ No pending leave requests right now. +
+
+ +
+

Cancellation requests

+

+ Review approved leave cancellations routed to you. +

+
+ +
+ + + + + + + + + + + + + + + {cancelRequests.map((leave) => ( + + + + + + + + + + + ))} + {cancelRequests.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToRequested byReasonActions
+ {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 || "-"} + +
+ + +
+
+ No cancellation requests right now. +
+
+ +
+

Resumption requests

+

+ Review leave resumption forms awaiting your approval. +

+
+ +
+ + + + + + + + + + + + + + + {resumptionRequests.map((leave) => ( + + + + + + + + + + + ))} + {resumptionRequests.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToResumption dateReasonActions
+ {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 || "-"} + +
+ + +
+
+ No resumption requests right now. +
+
+ +
+

Extension requests

+

+ Review approved leave extensions routed to you. +

+
+ +
+ + + + + + + + + + + + + + + + + {extensionRequests.map((leave) => ( + + + + + + + + + + + + + ))} + {extensionRequests.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToNew endNew daysRequested byReasonActions
+ {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 || "-"} + +
+ + +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + {pendingUnassigned.map((appraisal) => ( + + + + + + + + ))} + {pendingUnassigned.length === 0 && ( + + + + )} + +
EmployeeDepartmentAppraisal YearStatusAssign
+ {appraisal.employee_name || "Employee"} + + {appraisal.department || "-"} + + {appraisal.appraisal_year || "-"} + + + +
+ {ASSIGNMENT_OPTIONS.map((option) => ( + + ))} +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + + {pendingAdvances.map((adv) => ( + + + + + + + + + ))} + {pendingAdvances.length === 0 && ( + + + + )} + +
EmployeeEventStart DateTotal AmountStatusActions
+ {adv.employee_name || adv.employee || "Employee"} + + {adv.event_name || adv.purpose || "-"} + + {adv.start_date || adv.submission_date || "-"} + + ₹{adv.total_amount || adv.amount_required} + + + +
+ + +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + + {pendingRequests.map((ltc) => ( + + + + + + + + + ))} + {pendingRequests.length === 0 && ( + + + + )} + +
EmployeeBlock YearTravel DatesDestinationStatusActions
+ {ltc.employee_name || ltc.employee || "Employee"} + + {ltc.ltc_block_year || "-"} + + {`${ltc.travel_start_date || "-"} to ${ + ltc.travel_end_date || "-" + }`} + + {ltc.destination || "-"} + + + +
+ + +
+
+ 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 +
+
+ + + + + + + + + + + + + + {applications.map((app) => ( + + + + + + + + + + ))} + {applications.length === 0 && ( + + + + )} + +
EmployeeBlock YearTravel StartDestinationDownloadWithdrawStatus
{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" ? ( + + ) : ( + - + )} + + +
+ No LTC applications submitted yet. +
+
+
+ {showForm && ( +
+
+

+ LTC Application Form +

+

+ Complete the required fields to submit your LTC request. +

+ {submitSuccess && ( +
+ {submitSuccess} +
+ )} + {submitError && ( +
+ {submitError} +
+ )} +
+
+

+ Basic Details +

+
+ + + + +
+
+ +
+

+ Travel Details +

+
+ + + + +
+ +
+ +
+

+ Family Details +

+ + +
+ +
+

+ Expense Details +

+
+ + + + + + +
+
+ +
+

+ Documents +

+
+ + +
+
+ +
+

+ History & Validation +

+
+ + +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} +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 + +
+ +
+ + + + + + + + + + + + + + + + + + {filteredApplications.map((app) => ( + + + + + + + + + + + + + + ))} + {filteredApplications.length === 0 && ( + + + + )} + +
Leave TypeFromToDaysDocument requestDownloadWithdraw/Cancel/ExtendResumptionCancel statusExtensionStatus
+ {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})` : ""}` + : "-"} + + +
+ No leave applications found. +
+
+
+ + {showForm && ( +
+
+

New Leave Application

+

+ Fields marked required must be completed before submission. +

+ {submitSuccess && ( +
+ {submitSuccess} +
+ )} + {submitError && ( +
+ {submitError} +
+ )} +
+
+

+ Basic Details +

+
+ + + + +
+
+ +
+

+ Leave Details +

+
+
+ +
+ {formData.leave_type === "Casual" && ( +
+ +
+ )} + {(formData.leave_type === "Casual" || + formData.leave_type === "Restricted") && ( +
+ +
+ )} + {formData.leave_type === "Casual" && formData.is_half_day && ( +
+ +
+ )} + + + +
+ +
+ +
+

+ Contact & Responsibility +

+
+ + + {!(formData.leave_type === "Vacation") && + !( + formData.leave_type === "Casual" && + formData.station_leave === "NOT_REQUIRED" + ) && + !( + formData.leave_type === "Restricted" && + formData.station_leave === "NOT_REQUIRED" + ) && ( + + )} +
+ +
+ +
+

+ Documents +

+
+ + +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} +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 && ( +
+

+ {error} +

+
+ )} + +
+
+
+ +

+ Pending nominations +

+
+

+ Accept or decline responsibility requests. +

+
+
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
EmployeeLeave TypeDatesStatusAction
{item.employee_name}{item.leave_type} + {item.start_date} to {item.end_date} + + + +
+ + +
+
+ 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. +

+
+ +
+ + + + + + + + + + + + + + {pendingLeaves.map((leave) => ( + + + + + + + + + + ))} + {pendingLeaves.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToStatusActions
+ {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 || "-"} + + + +
+ + + +
+
+ No forwarded leave requests right now. +
+
+ +
+

Cancellation requests

+

+ Review approved leave cancellations routed to you. +

+
+ +
+ + + + + + + + + + + + + + + {cancelRequests.map((leave) => ( + + + + + + + + + + + ))} + {cancelRequests.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToRequested byReasonActions
+ {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 || "-"} + +
+ + +
+
+ No cancellation requests right now. +
+
+ +
+

Extension requests

+

+ Review approved leave extensions routed to you. +

+
+ +
+ + + + + + + + + + + + + + + + + {extensionRequests.map((leave) => ( + + + + + + + + + + + + + ))} + {extensionRequests.length === 0 && ( + + + + )} + +
EmployeeDepartmentLeave TypeFromToNew endNew daysRequested byReasonActions
+ {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 || "-"} + +
+ + +
+
+ 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 ( +
+ +