From 7549c6b7c5767557de227fb2c7d60c949c5db64d Mon Sep 17 00:00:00 2001 From: tejdevarakonda Date: Mon, 20 Apr 2026 00:23:54 +0530 Subject: [PATCH 1/3] Final HR (EIS) frontend submission --- src/App.jsx | 177 +++- src/Modules/Dashboard/RoleDashboard.jsx | 315 +++++++ .../Dashboard/RoleDashboard.module.css | 189 ++++ src/Modules/HRModule.zip | Bin 0 -> 41284 bytes src/Modules/HRModule/AccountantCpdaReview.jsx | 155 +++ src/Modules/HRModule/AccountantLtcReview.jsx | 132 +++ src/Modules/HRModule/AppraisalForm.jsx | 380 ++++++++ src/Modules/HRModule/CPDAAdvance.jsx | 426 +++++++++ src/Modules/HRModule/CPDAReimbursement.jsx | 318 +++++++ .../HRModule/DirectorAppraisalReviews.jsx | 125 +++ .../HRModule/DirectorCpdaApprovals.jsx | 122 +++ .../HRModule/DirectorLeaveApprovals.jsx | 365 ++++++++ src/Modules/HRModule/EmployeeDashboard.jsx | 222 +++++ src/Modules/HRModule/HodAppraisalReviews.jsx | 133 +++ src/Modules/HRModule/HodLeaveApprovals.jsx | 563 +++++++++++ src/Modules/HRModule/HrAdminCpdaReview.jsx | 132 +++ src/Modules/HRModule/HrAdminLtcReview.jsx | 133 +++ src/Modules/HRModule/LTCForm.jsx | 444 +++++++++ src/Modules/HRModule/LeaveApplication.jsx | 883 ++++++++++++++++++ .../HRModule/LeaveApplication.module.css | 66 ++ src/Modules/HRModule/NomineeDashboard.jsx | 151 +++ .../HRModule/RegistrarLeaveApprovals.jsx | 373 ++++++++ src/Modules/HRModule/api.js | 127 +++ src/Modules/HRModule/components/FormField.jsx | 68 ++ .../HRModule/components/LoadingSpinner.jsx | 8 + .../HRModule/components/StatusBadge.jsx | 26 + .../HRModule/components/TextAreaField.jsx | 32 + src/Modules/HRModule/index.jsx | 92 ++ src/components/sidebarContent.jsx | 21 +- src/index.css | 233 +++++ src/routes/hrModuleRoutes/index.jsx | 8 + 31 files changed, 6409 insertions(+), 10 deletions(-) create mode 100644 src/Modules/Dashboard/RoleDashboard.jsx create mode 100644 src/Modules/Dashboard/RoleDashboard.module.css create mode 100644 src/Modules/HRModule.zip create mode 100644 src/Modules/HRModule/AccountantCpdaReview.jsx create mode 100644 src/Modules/HRModule/AccountantLtcReview.jsx create mode 100644 src/Modules/HRModule/AppraisalForm.jsx create mode 100644 src/Modules/HRModule/CPDAAdvance.jsx create mode 100644 src/Modules/HRModule/CPDAReimbursement.jsx create mode 100644 src/Modules/HRModule/DirectorAppraisalReviews.jsx create mode 100644 src/Modules/HRModule/DirectorCpdaApprovals.jsx create mode 100644 src/Modules/HRModule/DirectorLeaveApprovals.jsx create mode 100644 src/Modules/HRModule/EmployeeDashboard.jsx create mode 100644 src/Modules/HRModule/HodAppraisalReviews.jsx create mode 100644 src/Modules/HRModule/HodLeaveApprovals.jsx create mode 100644 src/Modules/HRModule/HrAdminCpdaReview.jsx create mode 100644 src/Modules/HRModule/HrAdminLtcReview.jsx create mode 100644 src/Modules/HRModule/LTCForm.jsx create mode 100644 src/Modules/HRModule/LeaveApplication.jsx create mode 100644 src/Modules/HRModule/LeaveApplication.module.css create mode 100644 src/Modules/HRModule/NomineeDashboard.jsx create mode 100644 src/Modules/HRModule/RegistrarLeaveApprovals.jsx create mode 100644 src/Modules/HRModule/api.js create mode 100644 src/Modules/HRModule/components/FormField.jsx create mode 100644 src/Modules/HRModule/components/LoadingSpinner.jsx create mode 100644 src/Modules/HRModule/components/StatusBadge.jsx create mode 100644 src/Modules/HRModule/components/TextAreaField.jsx create mode 100644 src/Modules/HRModule/index.jsx create mode 100644 src/routes/hrModuleRoutes/index.jsx diff --git a/src/App.jsx b/src/App.jsx index 99d55a675..1550928ee 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,20 +2,32 @@ 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 AccountantLtcReview from "./Modules/HRModule/AccountantLtcReview"; +import AccountantCpdaReview from "./Modules/HRModule/AccountantCpdaReview"; const theme = createTheme({ breakpoints: { @@ -30,11 +42,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 +71,15 @@ export default function App() { path="/dashboard" element={ - + + + } + /> + + } /> @@ -82,6 +119,138 @@ export default function App() { } /> } /> } /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> + + + + ) : ( + + ) + } + /> } /> diff --git a/src/Modules/Dashboard/RoleDashboard.jsx b/src/Modules/Dashboard/RoleDashboard.jsx new file mode 100644 index 000000000..3123a39ca --- /dev/null +++ b/src/Modules/Dashboard/RoleDashboard.jsx @@ -0,0 +1,315 @@ +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-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"].includes( + resolvedRole.key, + ); + const hideFinanceActions = ["hr_admin", "accountant", "registrar"].includes( + resolvedRole.key, + ); + 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 0000000000000000000000000000000000000000..9849a9540cdc265987936870aab5ec573711e71e GIT binary patch literal 41284 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�wpGAr%rsTSXbkcu%d7QyuSB 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 literal 0 HcmV?d00001 diff --git a/src/Modules/HRModule/AccountantCpdaReview.jsx b/src/Modules/HRModule/AccountantCpdaReview.jsx new file mode 100644 index 000000000..e9a907862 --- /dev/null +++ b/src/Modules/HRModule/AccountantCpdaReview.jsx @@ -0,0 +1,155 @@ +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", "DIRECTOR_APPROVED"].includes(accountantStatus) || + !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 HR. +

    +
    + +
    + + + + + + + + + + + + + {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} + + + +
    + {(adv.accountant_processing_status || "").toUpperCase() !== + "DIRECTOR_APPROVED" && ( + + )} + + +
    +
    + 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..02ae22a72 --- /dev/null +++ b/src/Modules/HRModule/AppraisalForm.jsx @@ -0,0 +1,380 @@ +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 [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) => + setFormData({ ...formData, [e.target.name]: e.target.value }); + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitError(""); + setSubmitSuccess(""); + try { + await createAppraisalForm(formData); + setSubmitSuccess("Your form is submitted."); + setShowForm(false); + fetchData(); + } catch (err) { + const message = err?.response?.data + ? JSON.stringify(err.response.data) + : "Submission failed. Please check the form fields and try again."; + setSubmitError(message); + } + }; + + 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..b92aaca4d --- /dev/null +++ b/src/Modules/HRModule/CPDAAdvance.jsx @@ -0,0 +1,426 @@ +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 [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) => + setFormData({ ...formData, [e.target.name]: e.target.value }); + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitError(""); + setSubmitSuccess(""); + try { + await createCPDAAdvance(formData); + setSubmitSuccess("Your form is submitted."); + setShowForm(false); + fetchData(); + } catch (err) { + const message = err?.response?.data + ? JSON.stringify(err.response.data) + : "Submission failed. Please check the form fields and try again."; + setSubmitError(message); + } + }; + + 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..b9b2287c3 --- /dev/null +++ b/src/Modules/HRModule/CPDAReimbursement.jsx @@ -0,0 +1,318 @@ +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 [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(); + try { + await createCPDAReimbursement(formData); + setShowForm(false); + fetchData(); + } catch (error) { + console.error(error); + window.alert("Submission failed"); + } + }; + + 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. +

    +
    +
    +

    + 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..86dac82aa --- /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) => (item.status || "").toUpperCase() === "REVIEWED", + ), + [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 marked as reviewed 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..70435b72e --- /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 the accountant. +

    +
    + +
    + + + + + + + + + + + + + {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..38e1ecb3d --- /dev/null +++ b/src/Modules/HRModule/HodAppraisalReviews.jsx @@ -0,0 +1,133 @@ +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):", "") || ""; + 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/HrAdminCpdaReview.jsx b/src/Modules/HRModule/HrAdminCpdaReview.jsx new file mode 100644 index 000000000..1d44fac78 --- /dev/null +++ b/src/Modules/HRModule/HrAdminCpdaReview.jsx @@ -0,0 +1,132 @@ +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 accountant. +

    +
    + +
    + + + + + + + + + + + + + {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..df681ccc5 --- /dev/null +++ b/src/Modules/HRModule/LTCForm.jsx @@ -0,0 +1,444 @@ +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 [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; + setFormData({ ...formData, [e.target.name]: val }); + }; + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitError(""); + setSubmitSuccess(""); + try { + await createLTCApplication(formData); + setSubmitSuccess("Your form is submitted."); + setShowForm(false); + fetchData(); + } catch (err) { + const message = err?.response?.data + ? JSON.stringify(err.response.data) + : "Submission failed. Please check the form fields and try again."; + setSubmitError(message); + } + }; + + 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..a18ff4251 --- /dev/null +++ b/src/Modules/HRModule/LeaveApplication.jsx @@ -0,0 +1,883 @@ +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 [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 (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 handleSubmit = async (e) => { + e.preventDefault(); + setSubmitError(""); + setSubmitSuccess(""); + try { + const payload = { ...formData }; + if (!payload.nominee_employee_id) { + delete payload.nominee_employee_id; + } + await createLeaveApplication(payload); + setSubmitSuccess("Your form is submitted."); + setShowForm(false); + 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; + if (serverErrors?.nominee_employee_id) { + setSubmitError(serverErrors.nominee_employee_id); + } else if (serverErrors) { + setSubmitError(JSON.stringify(serverErrors)); + } 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 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):", "") || ""; + 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..d210e5c20 --- /dev/null +++ b/src/Modules/HRModule/api.js @@ -0,0 +1,127 @@ +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); diff --git a/src/Modules/HRModule/components/FormField.jsx b/src/Modules/HRModule/components/FormField.jsx new file mode 100644 index 000000000..017f12baf --- /dev/null +++ b/src/Modules/HRModule/components/FormField.jsx @@ -0,0 +1,68 @@ +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, +}) { + const inputId = name ? `field-${name}` : undefined; + return ( +
    + + +
    + ); +} + +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, +}; + +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..b792778bc --- /dev/null +++ b/src/Modules/HRModule/components/TextAreaField.jsx @@ -0,0 +1,32 @@ +import PropTypes from "prop-types"; + +function TextAreaField({ label, name, value, onChange, required = false }) { + const inputId = name ? `field-${name}` : undefined; + return ( +
    + +