Merge branch 'Project_Branch_Management' of https://git.marcoaiot.com/admin/marco.pms.web into Project_Branch_Management

This commit is contained in:
Kartik Sharma 2025-11-20 09:51:29 +05:30
commit 8f86b05d35
13 changed files with 537 additions and 194 deletions

View File

@ -25,8 +25,7 @@ const Sidebar = () => {
/>
</span> */}
<a
href="/"
<small
className="app-brand-link fw-bold navbar-brand text-green fs-6"
>
<span className="app-brand-logo demo">
@ -35,12 +34,13 @@ const Sidebar = () => {
<span className="text-blue">OnField</span>
<span>Work</span>
<span className="text-dark">.com</span>
</a>
</small>
</Link>
<a className="layout-menu-toggle menu-link text-large ms-auto">
<small className="layout-menu-toggle menu-link text-large ms-auto">
<i className="bx bx-chevron-left bx-sm d-flex align-items-center justify-content-center"></i>
</a>
</small>
</div>
<div className="menu-inner-shadow"></div>

View File

@ -5,8 +5,8 @@ import { useServiceProjects } from "../../hooks/useServiceProject";
import { ITEMS_PER_PAGE } from "../../utils/constants";
import OffcanvasComponent from "../common/OffcanvasComponent";
import showToast from "../../services/toastService";
import ManageJob from "./ManageJob";
import ManageJobTicket from "./ManageJobTicket";
import ManageJob from "./ServiceProjectJob/ManageJob";
import ManageJobTicket from "./ServiceProjectJob/ManageJobTicket";
import GlobalModel from "../common/GlobalModel";
import PreviewDocument from "../Expenses/PreviewDocument";

View File

@ -0,0 +1,86 @@
import React, { useState } from "react";
import { useBranch } from "../../../hooks/useServiceProject";
import { SpinnerLoader } from "../../common/Loader";
import Error from "../../common/Error";
import { BranchDetailsSkeleton } from "../ServiceProjectSeketon";
const BranchDetails = ({ branch }) => {
const [copied, setCopied] = useState(false);
const { data, isLoading, isError, error } = useBranch(branch);
const googleMapUrl = data?.googleMapUrl || data?.locationLink;
const handleCopy = async () => {
if (!googleMapUrl) return;
await navigator.clipboard.writeText(googleMapUrl);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
};
if (isLoading) return <BranchDetailsSkeleton />;
if (isError)
return (
<div>
<Error error={error} />
</div>
);
return (
<div className="">
<div className="d-flex mb-2">
<span className="fs-6 fw-medium">
<i className="bx bx-sm me-2 bx-buildings"></i>Branch Details
</span>
</div>
<div className="row mb-1">
<div className="col-4 col-md-4 text-secondary">Name:</div>
<div className="col-8 col-md-8">{data?.branchName}</div>
</div>
<div className="row mb-1">
<div className="col-4 col-md-4 text-secondary">Type:</div>
<div className="col-8 col-md-8">{data?.branchType}</div>
</div>
<div className="row mb-1">
<div className="col-4 col-md-4 text-secondary">Contact No:</div>
<div className="col-8 col-md-8">{data?.contactInformation}</div>
</div>
<div className="row mb-1">
<div className="col-4 col-md-4 text-secondary">Address:</div>
<div className="col-8 col-md-8">{data?.address}</div>
</div>
{googleMapUrl && (
<div className="row mb-1">
<div className="col-4 col-md-4 text-secondary">Map:</div>
<div className="col-8 col-md-8 d-flex align-items-center gap-2">
<a
href={googleMapUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-decoration-underline text-break"
style={{ wordBreak: "break-all" }}
>
Open in Google Maps
</a>
<i
className={`bx ${
copied ? "bx-check-double text-secondry " : "bx-copy"
}`}
style={{ cursor: "pointer" }}
onClick={handleCopy}
></i>
{copied && <span className="text-secondry small">Copied!</span>}
</div>
</div>
)}
</div>
);
};
export default BranchDetails;

View File

@ -1,17 +1,16 @@
import SelectField from "../common/Forms/SelectField";
import { useJobStatus } from "../../hooks/masterHook/useMaster";
import { SpinnerLoader } from "../common/Loader";
import Error from "../common/Error";
import SelectField from "../../common/Forms/SelectField";
import Error from "../../common/Error";
import { z } from "zod";
import {
AppFormController,
AppFormProvider,
useAppForm,
} from "../../hooks/appHooks/useAppForm";
} from "../../../hooks/appHooks/useAppForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useDispatch, useSelector } from "react-redux";
import { closePopup } from "../../slices/localVariablesSlice";
import { useUpdateServiceProjectJob } from "../../hooks/useServiceProject";
import { closePopup } from "../../../slices/localVariablesSlice";
import { useUpdateServiceProjectJob } from "../../../hooks/useServiceProject";
import { useJobStatus } from "../../../hooks/masterHook/useMaster";
export const ChangeStatusSchema = z.object({
statusId: z.string().min(1, { message: "Please select status" }),
@ -53,6 +52,11 @@ const ChangeStatus = ({ statusId, projectId, jobId, popUpId }) => {
};
return (
<AppFormProvider {...methods}>
<div className="d-flex mb-2">
<span className="fs-6 fw-medium">
Change Status
</span>
</div>
<form className="row text-start" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-2">
<AppFormController

View File

@ -1,16 +1,16 @@
import React, { useEffect, useState } from "react";
import Avatar from "../common/Avatar";
import { useAppForm } from "../../hooks/appHooks/useAppForm";
import Avatar from "../../common/Avatar";
import { useAppForm } from "../../../hooks/appHooks/useAppForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { JobCommentSchema } from "./ServiceProjectSchema";
import { JobCommentSchema } from "../ServiceProjectSchema";
import {
useAddCommentJob,
useJobComments,
} from "../../hooks/useServiceProject";
import { ITEMS_PER_PAGE } from "../../utils/constants";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import Filelist from "../Expenses/Filelist";
import { formatFileSize, getIconByFileType } from "../../utils/appUtils";
} from "../../../hooks/useServiceProject";
import { ITEMS_PER_PAGE } from "../../../utils/constants";
import { formatUTCToLocalTime } from "../../../utils/dateUtils";
import Filelist from "../../Expenses/Filelist";
import { formatFileSize, getIconByFileType } from "../../../utils/appUtils";
const JobComments = ({ data }) => {
const {
@ -161,46 +161,48 @@ const JobComments = ({ data }) => {
const user = item?.createdBy;
return (
<div
key={item.id}
className="list-group-item border-0 border-bottom p-0"
>
<div className="d-flex align-items-start mt-2 mx-0 px-0">
<Avatar
firstName={user?.firstName}
lastName={user?.lastName}
/>
<div className="">
<div className="d-flex flex-row gap-3">
<span className="fw-semibold">
{user?.firstName} {user?.lastName}
</span>
<span className="text-secondary">
<em>{formatUTCToLocalTime(item?.createdAt, true)}</em>
</span>
</div>
<div className="text-muted text-secondary">
{user?.jobRoleName}
</div>
<div className="text-wrap">
<p className="mb-1 mt-2 text-muted">{item.comment}</p>
<div className="d-flex flex-wrap jusify-content-end gap-2 gap-sm-6 ">
{item.attachments?.map((file) => (
<div className="d-flex align-items-center">
<i
className={`bx bx-xxl ${getIconByFileType(
file?.contentType
)} fs-3`}
></i>
<div className="d-flex flex-column">
<p className="m-0">{file.fileName}</p>
<small className="text-secondary">
{formatFileSize(file.fileSize)}
</small>
</div>
<div className="d-flex align-items-start mt-2 mx-0 px-0">
<Avatar
size="xs"
firstName={user?.firstName}
lastName={user?.lastName}
/>
<div className="w-100">
<div className="d-flex flex-row align-items-center gap-3 w-100">
<span className="fw-semibold">
{user?.firstName} {user?.lastName}
</span>
<span className="text-secondary">
<em>{formatUTCToLocalTime(item?.createdAt, true)}</em>
</span>
</div>
<div className="text-muted text-secondary">
{user?.jobRoleName}
</div>
<div className="text-wrap">
<p className="mb-1 mt-2 text-muted">{item.comment}</p>
<div className="d-flex flex-wrap jusify-content-end gap-2 gap-sm-6">
{item.attachments?.map((file) => (
<div className="d-flex align-items-center">
<i
className={`bx bx-xxl ${getIconByFileType(
file?.contentType
)} fs-3`}
></i>
<div className="d-flex flex-column">
<p className="m-0">{file.fileName}</p>
<small className="text-secondary">
{formatFileSize(file.fileSize)}
</small>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
import React from "react";
import Avatar from "../common/Avatar";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import Avatar from "../../common/Avatar";
import { formatUTCToLocalTime } from "../../../utils/dateUtils";
const JobStatusLog = ({ data }) => {
return (

View File

@ -1,8 +1,8 @@
import React, { useEffect, useState } from "react";
import Breadcrumb from "../common/Breadcrumb";
import Label from "../common/Label";
import Breadcrumb from "../../common/Breadcrumb";
import Label from "../../common/Label";
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultJobValue, jobSchema } from "./ServiceProjectSchema";
import { defaultJobValue, jobSchema } from "../ServiceProjectSchema";
import {
useBranches,
useCreateServiceProjectJob,
@ -10,23 +10,23 @@ import {
useServiceProjectJobDetails,
useServiceProjects,
useUpdateServiceProjectJob,
} from "../../hooks/useServiceProject";
import { ITEMS_PER_PAGE } from "../../utils/constants";
import DatePicker from "../common/DatePicker";
import PmsEmployeeInputTag from "../common/PmsEmployeeInputTag";
import TagInput from "../common/TagInput";
import { localToUtc } from "../../utils/appUtils";
import SelectField from "../common/Forms/SelectField";
} from "../../../hooks/useServiceProject";
import { ITEMS_PER_PAGE } from "../../../utils/constants";
import DatePicker from "../../common/DatePicker";
import PmsEmployeeInputTag from "../../common/PmsEmployeeInputTag";
import TagInput from "../../common/TagInput";
import { localToUtc } from "../../../utils/appUtils";
import SelectField from "../../common/Forms/SelectField";
import {
AppFormController,
AppFormProvider,
useAppForm,
} from "../../hooks/appHooks/useAppForm";
} from "../../../hooks/appHooks/useAppForm";
import { useParams } from "react-router-dom";
import { useDispatch } from "react-redux";
import { useJobStatus } from "../../hooks/masterHook/useMaster";
import { useServiceProjectJobContext } from "./Jobs";
import { SelectFieldSearch } from "../common/Forms/SelectFieldServerSide";
import { useJobStatus } from "../../../hooks/masterHook/useMaster";
import { useServiceProjectJobContext } from "../Jobs";
import { SelectFieldSearch } from "../../common/Forms/SelectFieldServerSide";
const ManageJob = ({ Job }) => {
const { setManageJob, setSelectedJob } = useServiceProjectJobContext();
@ -165,7 +165,7 @@ const ManageJob = ({ Job }) => {
dueDate: JobData.dueDate ?? null,
tags: JobData.tags ?? [],
statusId: JobData.status.id,
branchId : JobData?.projectBranch?.id
projectBranchId : JobData?.projectBranch?.id
});
}, [JobData, Job, projectId]);
return (
@ -248,8 +248,8 @@ const ManageJob = ({ Job }) => {
<SelectFieldSearch
label="Select Branch"
placeholder="Select Branch"
value={watch("branchId")}
onChange={(val) => setValue("branchId", val)}
value={watch("projectBranchId")}
onChange={(val) => setValue("projectBranchId", val)}
valueKey="id"
labelKey="branchName"
hookParams={[projectId, true, 10, 1]}

View File

@ -1,25 +1,27 @@
import React, { useEffect } from "react";
import { useServiceProjectJobDetails } from "../../hooks/useServiceProject";
import { SpinnerLoader } from "../common/Loader";
import Error from "../common/Error";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import Avatar from "../common/Avatar";
import EmployeeAvatarGroup from "../common/EmployeeAvatarGroup";
import JobStatusLog from "./JobStatusLog";
import JobComments from "./JobComments";
import { daysLeft, getJobStatusBadge } from "../../utils/appUtils";
import HoverPopup from "../common/HoverPopup";
import React, { useEffect, useRef } from "react";
import { useServiceProjectJobDetails } from "../../../hooks/useServiceProject";
import { SpinnerLoader } from "../../common/Loader";
import Error from "../../common/Error";
import { formatUTCToLocalTime } from "../../../utils/dateUtils";
import Avatar from "../../common/Avatar";
import EmployeeAvatarGroup from "../../common/EmployeeAvatarGroup";
import { daysLeft, getJobStatusBadge } from "../../../utils/appUtils";
import HoverPopup from "../../common/HoverPopup";
import ChangeStatus from "./ChangeStatus";
import { useParams } from "react-router-dom";
import { STATUS_JOB_CLOSED } from "../../utils/constants";
import Tooltip from "../common/Tooltip";
import { STATUS_JOB_CLOSED } from "../../../utils/constants";
import Tooltip from "../../common/Tooltip";
import BranchDetails from "../ServiceProjectBranch/BranchDetails";
import { JobDetailsSkeleton } from "../ServiceProjectSeketon";
import JobComments from "./JobComments";
import JobStatusLog from "./JobStatusLog";
const ManageJobTicket = ({ Job }) => {
const { projectId } = useParams();
const { data, isLoading, isError, error } = useServiceProjectJobDetails(
Job?.job
);
const drawerRef = useRef();
const tabsData = [
{
id: "comment",
@ -37,7 +39,7 @@ const ManageJobTicket = ({ Job }) => {
},
];
if (isLoading) return <SpinnerLoader />;
if (isLoading) return <JobDetailsSkeleton />;
if (isError)
return (
<div>
@ -45,7 +47,7 @@ const ManageJobTicket = ({ Job }) => {
</div>
);
return (
<div className="row text-start">
<div className="row text-start" ref={drawerRef}>
<div className="col-12">
<h6 className="fs-5 fw-semibold">{data?.title}</h6>
<div className="d-flex justify-content-between align-items-start flex-wrap mb-2">
@ -61,7 +63,6 @@ const ManageJobTicket = ({ Job }) => {
{STATUS_JOB_CLOSED !== data?.status?.id && (
<HoverPopup
id="STATUS_CHANEG"
title="Change Status"
Mode="click"
className=""
content={
@ -124,68 +125,29 @@ const ManageJobTicket = ({ Job }) => {
);
})()}
</div>
<div className="d-flex my-2">
<span className="fw-semibold">
{" "}
<i className="bx bx-buildings me-1"></i>Branch Name :
</span>
</div>
<div className="d-block mt-4 mb-3">
<div className="row align-items-start align-items-md-start gap-2 mb-1">
<div className="col-12 col-md-auto">
<small className="fs-6 ">Created By</small>
</div>
<div className="col d-flex flex-row align-items-center ">
<Avatar
size="xs"
firstName={data?.createdBy?.firstName}
lastName={data?.createdBy?.lastName}
/>{" "}
<div className="d-flex flex-row align-items-center">
<p className="m-0">{`${data?.createdBy?.firstName} ${data?.createdBy?.lastName}`}</p>
<small className="text-secondary ms-1">
({data?.createdBy?.jobRoleName})
</small>
</div>
</div>
{data?.projectBranch && (
<div className="d-flex flex-row gap-3 my-2">
<span className="fw-semibold">
<i className="bx bx-buildings me-1"></i> Branch Name :
</span>
<HoverPopup
id="BRANCH_DETAILS"
Mode="click"
align="auto"
boundaryRef={drawerRef}
content={<BranchDetails branch={data?.projectBranch?.id} />}
>
<span className="text text-decoration-underline ">
{data?.projectBranch?.branchName}
</span>
</HoverPopup>
</div>
)}
{data?.assignees?.length > 0 && (
<div className="row align-items-start align-items-md-start gap-2">
<div className="col-12 col-md-auto">
<small className="fs-6">Assigned To</small>
</div>
<div className="col">
<div className="row gap-4">
{data?.assignees?.map((emp) => (
<div
key={emp.id}
className="col-6 col-sm-6 col-md-4 col-lg-4"
>
<div className="d-flex align-items-center gap-2">
<Avatar
size="xs"
firstName={emp.firstName}
lastName={emp.lastName}
/>
<div className="d-flex flex-column">
<span className=" text-truncate">
{emp.firstName} {emp.lastName}
</span>
<small className="text-secondary text-truncate">
{emp.jobRoleName}
</small>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
<div className="card shadow-none border ">
<span className="text-secondry">People</span>
</div>
</div>
<div className="nav-align-top nav-tabs-shadow p-0 shadow-none">

View File

@ -0,0 +1,80 @@
import React from "react";
import { useAppForm } from "../../../hooks/appHooks/useAppForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { JobCommentSchema } from "../ServiceProjectSchema";
import Avatar from "../../common/Avatar";
import Filelist from "../../Expenses/Filelist";
const UpdateJobComment = () => {
const {
register,
handleSubmit,
watch,
reset,
setValue,
formState: { errors },
} = useAppForm({
resolver: zodResolver(JobCommentSchema),
defaultValues: { comment: "", attachments: [] },
});
const onSubmit = () => {};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="d-flex">
<Avatar firstName={"A"} lastName={"D"} />
<div className="flex-grow-1">
<textarea
className="form-control"
rows={3}
placeholder="Write your comment..."
{...register("comment")}
></textarea>
{errors?.comment && (
<small className="danger-text">{errors?.comment?.message}</small>
)}
</div>
</div>
{/* <div className="flex-grow-1 ms-10 mt-2">
{files?.length > 0 && (
<Filelist files={} removeFile={removeFile} />
)}
</div> */}
<div className="d-flex justify-content-end gap-2 align-items-center text-end mt-3 ms-10 ms-md-0">
<div
onClick={() => document.getElementById("attachments").click()}
className="cursor-pointer"
style={{ whiteSpace: "nowrap" }}
>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
id="attachments"
multiple
className="d-none"
{...register("attachments")}
onChange={(e) => {
onFileChange(e);
e.target.value = "";
}}
/>
<i className="bx bx-sm bx-paperclip mb-1 me-1"></i>
Add Attachment
</div>
<button
className="btn btn-primary btn-sm px-1 py-1"
type="submit"
disabled={!watch("comment")?.trim() || isPending}
>
Submit
</button>
</div>
</form>
</div>
);
};
export default UpdateJobComment;

View File

@ -75,7 +75,7 @@ export const jobSchema = z.object({
statusId: z.string().optional().nullable(),
branchId: z.string().optional().nullable(),
projectBranchId: z.string().optional().nullable(),
});
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

View File

@ -0,0 +1,138 @@
import React from "react";
const SkeletonLine = ({ height = 18, width = "100%", className = "" }) => (
<div
className={`skeleton ${className}`}
style={{
height,
width,
borderRadius: "4px",
}}
></div>
);
export const BranchDetailsSkeleton = () => {
return (
<div className="w-100">
<div className="d-flex mb-3">
<SkeletonLine height={22} width="280px" />
</div>
<div className="row mb-2">
<div className="col-4">
<SkeletonLine height={16} width="70%" />
</div>
<div className="col-8">
<SkeletonLine height={16} />
</div>
</div>
<div className="row mb-2">
<div className="col-4">
<SkeletonLine height={16} width="70%" />
</div>
<div className="col-8">
<SkeletonLine height={16} />
</div>
</div>
<div className="row mb-2">
<div className="col-4">
<SkeletonLine height={16} width="70%" />
</div>
<div className="col-8">
<SkeletonLine height={16} />
</div>
</div>
<div className="row mb-2">
<div className="col-4">
<SkeletonLine height={16} width="70%" />
</div>
<div className="col-8">
<SkeletonLine height={16} width="90%" />
</div>
</div>
<div className="row mb-2">
<div className="col-4">
<SkeletonLine height={16} width="70%" />
</div>
<div className="col-8 d-flex gap-2 align-items-center">
<SkeletonLine height={16} width="60%" />
<SkeletonLine height={16} width="20px" />
</div>
</div>
</div>
);
};
export const JobDetailsSkeleton = () => {
return (
<div className="row text-start">
<div className="col-12">
{/* Title */}
<SkeletonLine height={24} width="50%" />
{/* Job ID + Status */}
<div className="d-flex justify-content-between align-items-start flex-wrap mb-3 mt-2">
<SkeletonLine height={18} width="30%" />
<div className="d-flex flex-row gap-2">
<SkeletonLine height={22} width="70px" />
<SkeletonLine height={22} width="22px" />
</div>
</div>
{/* Description */}
<SkeletonLine height={40} width="100%" />
{/* Created Date */}
<div className="d-flex my-3">
<SkeletonLine height={16} width="40%" />
</div>
{/* Start / Due Date */}
<div className="d-flex justify-content-between mb-4">
<SkeletonLine height={16} width="50%" />
<SkeletonLine height={22} width="70px" />
</div>
{/* Branch Name */}
<div className="d-flex flex-row gap-3 my-2">
<SkeletonLine height={16} width="30%" />
<SkeletonLine height={16} width="40%" />
</div>
{/* Created By */}
<div className="row align-items-center my-3">
<div className="col-12 col-md-auto mb-2">
<SkeletonLine height={16} width="80px" />
</div>
<div className="col d-flex align-items-center gap-2">
<SkeletonLine height={30} width="30px" /> {/* Avatar */}
<SkeletonLine height={16} width="40%" />
</div>
</div>
{/* Assigned To */}
<div className="row mt-2">
<div className="col-12 col-md-auto mb-2">
<SkeletonLine height={16} width="90px" />
</div>
</div>
{/* Tabs */}
<div className="mt-4">
<div className="d-flex gap-3 mb-3">
<SkeletonLine height={35} width="80px" />
<SkeletonLine height={35} width="80px" />
<SkeletonLine height={35} width="80px" />
</div>
<SkeletonLine height={150} width="100%" />
</div>
</div>
</div>
);
};

View File

@ -1,11 +1,11 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
closePopup,
openPopup,
togglePopup,
} from "../../slices/localVariablesSlice";
import { closePopup, openPopup, togglePopup } from "../../slices/localVariablesSlice";
/**
* align: "auto" | "left" | "right"
* boundaryRef: optional ref to the drawer/container element to use as boundary
*/
const HoverPopup = ({
id,
title,
@ -13,6 +13,8 @@ const HoverPopup = ({
children,
className = "",
Mode = "hover",
align = "auto",
boundaryRef = null,
}) => {
const dispatch = useDispatch();
const visible = useSelector((s) => s.localVariables.popups[id] || false);
@ -23,11 +25,9 @@ const HoverPopup = ({
const handleMouseEnter = () => {
if (Mode === "hover") dispatch(openPopup(id));
};
const handleMouseLeave = () => {
if (Mode === "hover") dispatch(closePopup(id));
};
const handleClick = (e) => {
if (Mode === "click") {
e.stopPropagation();
@ -35,43 +35,111 @@ const HoverPopup = ({
}
};
// Close on outside click when using click mode
useEffect(() => {
if (Mode !== "click" || !visible) return;
const handleOutside = (e) => {
const handler = (e) => {
if (
!popupRef.current?.contains(e.target) &&
!triggerRef.current?.contains(e.target)
popupRef.current &&
!popupRef.current.contains(e.target) &&
triggerRef.current &&
!triggerRef.current.contains(e.target)
) {
dispatch(closePopup(id));
}
};
document.addEventListener("click", handleOutside);
return () => document.removeEventListener("click", handleOutside);
}, [visible, Mode, id]);
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}, [Mode, visible, dispatch, id]);
// Positioning effect: respects align prop and stays inside boundary (drawer)
useEffect(() => {
if (!visible || !popupRef.current) return;
if (!visible || !popupRef.current || !triggerRef.current) return;
const popup = popupRef.current;
const rect = popup.getBoundingClientRect();
// run in next frame so DOM/layout settles
requestAnimationFrame(() => {
const popup = popupRef.current;
popup.style.left = "50%";
popup.style.right = "auto";
popup.style.transform = "translateX(-50%)";
// choose boundary: provided boundaryRef or nearest positioned parent (popup.parentElement)
const boundaryEl = (boundaryRef && boundaryRef.current) || popup.parentElement;
if (!boundaryEl) return;
if (rect.right > window.innerWidth) {
popup.style.left = "auto";
popup.style.right = "0";
popup.style.transform = "none";
}
const boundaryRect = boundaryEl.getBoundingClientRect();
const triggerRect = triggerRef.current.getBoundingClientRect();
if (rect.left < 0) {
popup.style.left = "0";
popup.style.right = "auto";
popup.style.transform = "none";
}
}, [visible]);
// reset styles first
popup.style.left = "";
popup.style.right = "";
popup.style.transform = "";
popup.style.top = "";
// default: place below trigger and center horizontally relative to parent
// We'll use absolute positioning with respect to the positioned parent container.
// Ensure popup is positioned using left/right in parent's coordinate system.
// Compute desired left (centered under trigger)
const popupRect = popup.getBoundingClientRect();
const parentRect = boundaryRect; // alias
// Convert trigger center to parent coordinates
const triggerCenterX = triggerRect.left + triggerRect.width / 2 - parentRect.left;
// preferred left so popup center aligns to trigger center:
const preferredLeft = triggerCenterX - popupRect.width / 2;
// Helpers to set styles in parent's coordinate system:
const setLeft = (leftPx) => {
popup.style.left = `${leftPx}px`;
popup.style.right = "auto";
popup.style.transform = "none";
};
const setRight = (rightPx) => {
popup.style.left = "auto";
popup.style.right = `${rightPx}px`;
popup.style.transform = "none";
};
// If user forced align:
if (align === "left") {
// align popup's left to parent's left (0)
setLeft(0);
return;
}
if (align === "right") {
// align popup's right to parent's right (0)
setRight(0);
return;
}
// align === "auto": try preferred centered position, but flip fully if overflow
// clamp preferredLeft to boundaries so it doesn't render partially outside
const leftIfCentered = Math.max(0, Math.min(preferredLeft, parentRect.width - popupRect.width));
// if centered fits, use it
if (leftIfCentered === preferredLeft) {
setLeft(leftIfCentered);
return;
}
// if centering would overflow right -> stick popup fully to left (left=0)
if (preferredLeft > parentRect.width - popupRect.width) {
// place popup so its right aligns to parent's right
// i.e., left = parent width - popup width
setLeft(parentRect.width - popupRect.width);
return;
}
// if centering would overflow left -> stick popup fully to left=0
if (preferredLeft < 0) {
setLeft(0);
return;
}
// fallback center
setLeft(leftIfCentered);
});
}, [visible, align, boundaryRef]);
return (
<div className="d-inline-block position-relative">
@ -88,16 +156,18 @@ const HoverPopup = ({
{visible && (
<div
ref={popupRef}
className={`bg-white border rounded shadow-sm p-3 w-max position-absolute top-100 mt-2 ${className}`}
// position absolute; it should be inside a positioned parent (the drawer)
className={`hover-popup bg-white border rounded shadow-sm p-3 position-absolute mt-2 ${className}`}
style={{
zIndex: 2000,
left: "50%",
transform: "translateX(-50%)",
top: "100%", // open below trigger
// left/right will be set by effect in parent coordinates
width: "max-content",
minWidth: "120px",
}}
onClick={(e) => e.stopPropagation()}
>
{title && <h6 className="fw-semibold mb-2">{title}</h6>}
<div>{content}</div>
</div>
)}

View File

@ -60,7 +60,7 @@ import PaymentRequestPage from "../pages/PaymentRequest/PaymentRequestPage";
import RecurringExpensePage from "../pages/RecurringExpense/RecurringExpensePage";
import AdvancePaymentPage from "../pages/AdvancePayment/AdvancePaymentPage";
import ServiceProjectDetail from "../pages/ServiceProject/ServiceProjectDetail";
import ManageJob from "../components/ServiceProject/ManageJob";
import ManageJob from "../components/ServiceProject/ServiceProjectJob/ManageJob";
const router = createBrowserRouter(
[
{