diff --git a/src/Constants.js b/src/Constants.js index fb10744..9b87666 100644 --- a/src/Constants.js +++ b/src/Constants.js @@ -32,6 +32,7 @@ export const EMPLOYEE_INVITE_ENDPOINT = "/api/profiles/:profile_id/employee_invite"; export const PROFILE_COMPLETE_ENDPOINT = "/api/profiles/:profile_id/profile_complete"; +export const ADMIN_INVITE_ENDPOINT = "/api/admin_invite"; export const PROFILE_GET_ENDPOINT = "/api/profiles/:profile_id"; export const PROJECT_LIST_ENDPOINT = "/api/profiles/:profile_id/projects"; @@ -54,6 +55,7 @@ export const UPDATE_EXPERIENCE_ENDPOINT = export const UPDATE_CERTIFICATE_ENDPOINT = "/api/profiles/:profile_id/certificates/:certificate_id"; export const UPDATE_SEQUENCE_ENDPOINT = "/api/updateSequence"; +export const INTRANET_EMPLOYEE_ENDPOINT = "/api/intranet/employees/:employee_id"; export const DELETE_PROFILE_ENDPOINT = "/api/profiles/:profile_id"; export const DELETE_ACHIEVEMENT_ENDPOINT = diff --git a/src/api/axiosBaseQuery/service.jsx b/src/api/axiosBaseQuery/service.jsx index 44eba21..587042a 100644 --- a/src/api/axiosBaseQuery/service.jsx +++ b/src/api/axiosBaseQuery/service.jsx @@ -2,7 +2,7 @@ import axiosInstance from "../../services/axios"; const axiosBaseQuery = () => - async ({ url, method, data, params, headers, body }) => { + async ({ url, method, data, params, headers, body, ...rest }) => { try { const result = await axiosInstance({ url: url, @@ -11,10 +11,17 @@ const axiosBaseQuery = params, headers, body, + ...rest, }); - return Promise.resolve(result); + return { data: result.data }; } catch (axiosError) { - return Promise.reject(axiosError?.response?.data); + return { + error: { + status: axiosError?.response?.status, + data: axiosError?.response?.data, + message: axiosError?.message, + }, + }; } }; diff --git a/src/api/emailApi.jsx b/src/api/emailApi.jsx index 64b12ca..8e3b978 100644 --- a/src/api/emailApi.jsx +++ b/src/api/emailApi.jsx @@ -1,5 +1,6 @@ import { createApi } from "@reduxjs/toolkit/query/react"; import { + ADMIN_INVITE_ENDPOINT, EMPLOYEE_INVITE_ENDPOINT, HTTP_METHODS, USER_EMAIL_REDUCER_PATH, @@ -18,7 +19,15 @@ export const userEmailApi = createApi({ invalidatesTags: ["user_email"], transformResponse: (response) => response.data, }), + adminInvite: builder.mutation({ + query: ({ name, email }) => ({ + url: ADMIN_INVITE_ENDPOINT, + method: HTTP_METHODS.POST, + data: { name, email }, + }), + transformResponse: (response) => response.data, + }), }), }); -export const { useUserEmailMutation } = userEmailApi; +export const { useUserEmailMutation, useAdminInviteMutation } = userEmailApi; diff --git a/src/api/profileApi.jsx b/src/api/profileApi.jsx index 9c52474..7003050 100644 --- a/src/api/profileApi.jsx +++ b/src/api/profileApi.jsx @@ -3,6 +3,7 @@ import { CREATE_PROFILE_ENDPOINT, DELETE_PROFILE_ENDPOINT, HTTP_METHODS, + INTRANET_EMPLOYEE_ENDPOINT, PROFILE_COMPLETE_ENDPOINT, PROFILE_GET_ENDPOINT, PROFILE_LIST_ENDPOINT, @@ -86,6 +87,14 @@ export const profileApi = createApi({ invalidatesTags: ["profile"], transformResponse: (response) => response.data, }), + getIntranetEmployee: builder.query({ + query: (employeeId) => ({ + url: INTRANET_EMPLOYEE_ENDPOINT.replace(":employee_id", employeeId), + method: HTTP_METHODS.GET, + _suppressToastForStatuses: [409], + }), + transformResponse: (response) => response.data, + }), }), }); @@ -98,4 +107,5 @@ export const { useUpdateSequenceMutation, useUpdateProfileStatusMutation, useCompleteProfileMutation, + useLazyGetIntranetEmployeeQuery, } = profileApi; diff --git a/src/components/Builder/BasicInfo/index.jsx b/src/components/Builder/BasicInfo/index.jsx index a40aa42..6ad431b 100644 --- a/src/components/Builder/BasicInfo/index.jsx +++ b/src/components/Builder/BasicInfo/index.jsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from "react"; import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; -import { Button, Col, DatePicker, Form, Input, Row, Select, Space } from "antd"; +import { useSelector } from "react-redux"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Alert, Button, Col, DatePicker, Form, Input, Row, Select, Space } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import dayjs from "dayjs"; import PropTypes from "prop-types"; @@ -10,6 +11,7 @@ import { useUpdateProfileMutation, } from "../../../api/profileApi"; import { + ADMIN, EDITOR_PROFILE_ROUTE, GENDER, PROFILE_DETAILS, @@ -19,13 +21,17 @@ import { import { parseDate } from "../../../helpers"; const BasicInfo = ({ profileData }) => { + const role = useSelector((state) => state.auth.role); const [createProfileService, { isLoading: isCreating }] = useCreateProfileMutation(); const [updateProfileService, { isLoading: isUpdating }] = useUpdateProfileMutation(); const [formChange, setFormChange] = useState(false); + const [showIntranetBanner, setShowIntranetBanner] = useState(false); const navigate = useNavigate(); + const location = useLocation(); const [form] = Form.useForm(); + const intranetData = location.state?.intranetData; useEffect(() => { if (profileData) { @@ -37,6 +43,30 @@ const BasicInfo = ({ profileData }) => { } }, [profileData, form]); + // Pre-fill from Intranet data when coming from the sync flow + useEffect(() => { + if (intranetData && !profileData) { + form.setFieldsValue({ + name: intranetData.name, + email: intranetData.email, + employee_id: intranetData.employeeId, + mobile: intranetData.mobileNumber, + gender: intranetData.gender, + years_of_experience: intranetData.yearsOfExperience, + designation: intranetData.designation, + linkedin_link: intranetData.linkedinUrl, + github_link: intranetData.githubUrl, + primary_skills: intranetData.primarySkills ?? [], + secondary_skills: intranetData.secondarySkills ?? [], + josh_joining_date: intranetData.joshJoiningDate + ? dayjs(intranetData.joshJoiningDate) + : undefined, + }); + setShowIntranetBanner(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [intranetData]); + const onFinish = async (values) => { try { if (values.years_of_experience || values.josh_joining_date) { @@ -49,6 +79,11 @@ const BasicInfo = ({ profileData }) => { values.josh_joining_date.format("MMM-YYYY"); } } + + if (values.employee_id && typeof values.employee_id === "string") { + values.employee_id = values.employee_id.trim(); + } + let response; if (profileData) { if (formChange) { @@ -87,8 +122,19 @@ const BasicInfo = ({ profileData }) => { onValuesChange={() => setFormChange(true)} initialValues={profileData || { description: PROFILE_DETAILS }} > + {/* Intranet pre-fill info banner */} + {showIntranetBanner && ( + setShowIntranetBanner(false)} + style={{ marginBottom: "20px" }} + /> + )} - + { - + { + + + + + @@ -302,6 +359,10 @@ BasicInfo.propTypes = { primary_skills: PropTypes.array, secondary_skills: PropTypes.array, career_objectives: PropTypes.string, + employee_id: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), }), josh_joining_date: PropTypes.oneOfType([ PropTypes.string, diff --git a/src/components/Profile/IntranetSyncModal/index.jsx b/src/components/Profile/IntranetSyncModal/index.jsx new file mode 100644 index 0000000..2740826 --- /dev/null +++ b/src/components/Profile/IntranetSyncModal/index.jsx @@ -0,0 +1,240 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Alert, + Button, + Card, + Input, + Modal, + Space, + Typography, +} from "antd"; +import { + ArrowLeftOutlined, + ArrowRightOutlined, + EditOutlined, + SearchOutlined, + UserOutlined, +} from "@ant-design/icons"; +import PropTypes from "prop-types"; +import { useLazyGetIntranetEmployeeQuery } from "../../../api/profileApi"; +import { EDITOR_ROUTE } from "../../../Constants"; + +const { Title, Text, Paragraph } = Typography; + +const STEP_SELECT = "select"; +const STEP_LOOKUP = "lookup"; + +const IntranetSyncModal = ({ open, onClose, onManualCreate }) => { + const navigate = useNavigate(); + const [step, setStep] = useState(STEP_SELECT); + const [employeeId, setEmployeeId] = useState(""); + const [fetchedEmployee, setFetchedEmployee] = useState(null); + const [errorMsg, setErrorMsg] = useState(""); + const [fetchIntranetEmployee, { isFetching }] = useLazyGetIntranetEmployeeQuery(); + + const resetState = () => { + setStep(STEP_SELECT); + setEmployeeId(""); + setFetchedEmployee(null); + setErrorMsg(""); + }; + + const handleClose = () => { + resetState(); + onClose(); + }; + + const handleManualCreate = () => { + resetState(); + onManualCreate(); + }; + + const handleFetch = async () => { + if (!employeeId.trim()) { + setErrorMsg("Please enter an Employee ID."); + return; + } + setErrorMsg(""); + setFetchedEmployee(null); + try { + const employee = await fetchIntranetEmployee(employeeId.trim()).unwrap(); + setFetchedEmployee(employee); + } catch (error) { + if (error.status === 409) { + setErrorMsg( + error.data?.error_message || + "Profile already exists for this Employee ID." + ); + } else if (error.status === 404) { + setErrorMsg("No employee found with this ID. Please check the ID or create manually."); + } else { + setErrorMsg("Could not reach the Intranet service. Please try again or create manually."); + } + } + }; + + const handleProceed = () => { + navigate(EDITOR_ROUTE, { state: { intranetData: fetchedEmployee } }); + handleClose(); + }; + + const stepSelectContent = ( + + + Choose how you'd like to get started + + + setStep(STEP_LOOKUP)} + style={{ + flex: 1, + cursor: "pointer", + borderColor: "#4361ee", + background: "#f0f3ff", + textAlign: "center", + }} + > + + + Sync from Intranet + + + Auto-fill form from the employee database + + + + + + + Create Manually + + + Start with a blank form + + + + + ); + + const stepLookupContent = ( + + + +
+
+ Enter Employee ID +
+ { + setEmployeeId(e.target.value.trim()); + setErrorMsg(""); + setFetchedEmployee(null); + }} + onSearch={handleFetch} + enterButton={ + + } + size="large" + autoFocus + /> +
+ + {errorMsg && } + + {fetchedEmployee && !errorMsg && ( + + + {fetchedEmployee.name} + +  ·  {fetchedEmployee.designation}  · {" "} + {fetchedEmployee.employeeId} + +
+ } + type="success" + showIcon={false} + /> + )} + + ); + + return ( + + + Create New Employee Profile + + } + footer={ + step === STEP_LOOKUP + ? [ + , + , + ] + : null + } + width={520} + destroyOnClose + > + {step === STEP_SELECT ? stepSelectContent : stepLookupContent} + + ); +}; + +IntranetSyncModal.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onManualCreate: PropTypes.func.isRequired, +}; + +export default IntranetSyncModal; diff --git a/src/components/Profile/List/index.jsx b/src/components/Profile/List/index.jsx index f0a8c5b..d27bc2c 100644 --- a/src/components/Profile/List/index.jsx +++ b/src/components/Profile/List/index.jsx @@ -1,10 +1,12 @@ import React, { useRef, useState } from "react"; import Highlighter from "react-highlight-words"; import toast from "react-hot-toast"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { Button, + Form, Input, + Modal, Radio, Row, Space, @@ -20,10 +22,16 @@ import { CloseOutlined, DeleteOutlined, EditOutlined, + MailOutlined, SearchOutlined, + UserAddOutlined, + UserOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; -import { useUserEmailMutation } from "../../../api/emailApi"; +import { + useAdminInviteMutation, + useUserEmailMutation, +} from "../../../api/emailApi"; import { useDeleteProfileMutation, useGetProfileListQuery, @@ -34,6 +42,7 @@ import { EDITOR_ROUTE, LOADING_SPIN, SPIN_SIZE, + SUCCESS_TOASTER, } from "../../../Constants"; import { calculateTotalExperience, @@ -41,12 +50,16 @@ import { showConfirm, } from "../../../helpers"; import Navbar from "../../Navbar"; +import IntranetSyncModal from "../IntranetSyncModal"; import styles from "./ListProfiles.module.css"; const ListProfiles = () => { const [searchText, setSearchText] = useState(""); const [searchedColumn, setSearchedColumn] = useState(""); const [activeStatus, setActiveStatus] = useState(true); + const [showIntranetModal, setShowIntranetModal] = useState(false); + const [inviteAdminModalOpen, setInviteAdminModalOpen] = useState(false); + const [adminInviteForm] = Form.useForm(); const searchInput = useRef(null); const navigate = useNavigate(); const { data, isFetching, refetch } = useGetProfileListQuery(); @@ -55,6 +68,8 @@ const ListProfiles = () => { const [updateProfileStatusService, { isLoading: profileStatusUpdating }] = useUpdateProfileStatusMutation(); const [sendInvitationService, { isLoading }] = useUserEmailMutation(); + const [adminInviteService, { isLoading: invitingAdmin }] = + useAdminInviteMutation(); const showActiveInactiveModal = (profile_id, isActive) => { showConfirm({ @@ -140,6 +155,26 @@ const ListProfiles = () => { }); }; + const handleAdminInviteSubmit = async () => { + try { + const values = await adminInviteForm.validateFields(); + const response = await adminInviteService(values); + if (response?.data) { + toast.success("Admin invited successfully!", SUCCESS_TOASTER); + setInviteAdminModalOpen(false); + adminInviteForm.resetFields(); + } + } catch (validationError) { + // adminInviteForm.validateFields() rejects when fields are empty/invalid. + // Ant Design automatically shows inline errors on the fields — nothing extra needed here. + } + }; + + const handleAdminInviteCancel = () => { + setInviteAdminModalOpen(false); + adminInviteForm.resetFields(); + }; + const handleClick = (id, is_josh_employee) => { navigate(EDITOR_PROFILE_ROUTE.replace(":profile_id", id), { state: { is_josh_employee }, @@ -248,6 +283,13 @@ const ListProfiles = () => { ), }, + { + title: "Employee ID", + dataIndex: "employee_id", + key: "employee_id", + width: "8%", + ...getColumnSearchProps("employee_id"), + }, { title: "Email", dataIndex: "email", @@ -305,7 +347,7 @@ const ListProfiles = () => { render: (date) => formatDate(date), }, { - title: "Is Josh Employee", + title: "Active Status", dataIndex: "is_josh_employee", key: "is_josh_employee", render: (_, record) => ( @@ -391,18 +433,38 @@ const ListProfiles = () => { setActiveStatus(e.target.value === "active")} + style={{ margin: "0 auto" }} > Active Inactive - - - + +
+ setShowIntranetModal(false)} + onManualCreate={() => { + setShowIntranetModal(false); + navigate(EDITOR_ROUTE); + }} + /> + { loading={isFetching} /> + + + + Invite Admin + + } + open={inviteAdminModalOpen} + onCancel={handleAdminInviteCancel} + onOk={handleAdminInviteSubmit} + okText="Send Invitation" + cancelText="Cancel" + confirmLoading={invitingAdmin} + destroyOnClose + width={440} + > +
+ + } + placeholder="e.g. John Doe" + size="large" + autoComplete="off" + /> + + + } + placeholder="e.g. john@example.com" + size="large" + autoComplete="off" + /> + + +
); }; diff --git a/src/components/Resume/index.js b/src/components/Resume/index.js index 64d82f3..c7dc726 100644 --- a/src/components/Resume/index.js +++ b/src/components/Resume/index.js @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import React, { forwardRef, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { useDispatch, useSelector } from "react-redux"; @@ -15,6 +14,7 @@ import { MailOutlined, MobileOutlined, } from "@ant-design/icons"; +import dayjs from "dayjs"; import PropTypes from "prop-types"; import { useLogoutMutation } from "../../api/loginApi"; import { useCompleteProfileMutation } from "../../api/profileApi"; diff --git a/src/helpers.js b/src/helpers.js index ac9cd68..b1980d7 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -104,12 +104,21 @@ export const showConfirm = ({ onOk, onCancel, message }) => { }; export const parseDate = (date) => { - if (!date) return null; - if (dayjs.isDayjs(date)) return date; - if (typeof date === "string") return dayjs(date); - if (typeof date === "object" && date.String && date.Valid) + if (!date) { + return null; + } + if (dayjs.isDayjs(date)) { + return date; + } + if (typeof date === "string") { + return dayjs(date); + } + if (typeof date === "object" && date.String && date.Valid) { return dayjs(date.String); - if (date instanceof Date) return dayjs(date); + } + if (date instanceof Date) { + return dayjs(date); + } return null; }; diff --git a/src/services/axios.js b/src/services/axios.js index ca64467..404ccb9 100644 --- a/src/services/axios.js +++ b/src/services/axios.js @@ -33,8 +33,14 @@ axiosInstance.interceptors.response.use( if (error.response && error.response.status === 401) { store.dispatch(logout()); window.localStorage.clear(); - toast.error("Unauthorized Access"); - history.push(ROOT_ROUTE); + toast.error("Unauthorized Access", { id: "unauthorized" }); + setTimeout(() => { + window.location.href = ROOT_ROUTE; + }, 500); + return Promise.reject(error); + } + const suppressFor = error.config?._suppressToastForStatuses; + if (Array.isArray(suppressFor) && suppressFor.includes(error.response?.status)) { return Promise.reject(error); } error.response?.data?.error_code