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

This commit is contained in:
ashutosh.nehete 2025-11-20 12:41:39 +05:30
commit e4f053ee65
28 changed files with 1702 additions and 304 deletions

View File

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

View File

@ -40,7 +40,6 @@ const ActionPaymentRequest = ({ requestId }) => {
error: PaymentModeError, error: PaymentModeError,
} = usePaymentMode(); } = usePaymentMode();
console.log("Kartik", data)
const IsReview = useHasUserPermission(REVIEW_EXPENSE); const IsReview = useHasUserPermission(REVIEW_EXPENSE);
const [imageLoaded, setImageLoaded] = useState({}); const [imageLoaded, setImageLoaded] = useState({});

View File

@ -85,7 +85,7 @@ const PaymentRequestList = ({ filters, filterData, removeFilterChip, clearFilter
key: "paymentRequestUID", key: "paymentRequestUID",
label: "Request ID", label: "Request ID",
align: "text-start mx-2", align: "text-start mx-2",
getValue: (e) => e.paymentRequestUID || "N/A", getValue: (e) => <div className="d-flex"><span>{e.paymentRequestUID || "N/A"}</span> {e.isAdvancePayment && <span class="ms-1 badge bg-label-warning text-xxs" >Adv</span>}</div>,
}, },
{ {
key: "title", key: "title",

View File

@ -148,7 +148,7 @@ const ViewPaymentRequest = ({ requestId }) => {
<div className="col-12 col-sm-6 "> <div className="col-12 col-sm-6 ">
<div className="row "> <div className="row ">
<div className="col-12 d-flex justify-content-between mb-6"> <div className="col-12 d-flex justify-content-between mb-6">
<div className="d-flex align-items-center"><span className="fw-semibold">PR No : </span><span className="fw-semibold ms-2"> {data?.paymentRequestUID}</span></div> <div className="d-flex align-items-center"><span className="fw-semibold">PR No : </span><span className="fw-semibold ms-2"> {data?.paymentRequestUID}</span> {data.isAdvancePayment && <span class="ms-1 badge bg-label-warning text-xs" >Advance</span>}</div>
<span <span
className={`badge bg-label-${getColorNameFromHex(data?.expenseStatus?.color) || "secondary" className={`badge bg-label-${getColorNameFromHex(data?.expenseStatus?.color) || "secondary"
}`} }`}

View File

@ -82,7 +82,6 @@ const EditActivityModal = ({
useEffect(() => { useEffect(() => {
if (!workItem) return; if (!workItem) return;
console.log(workItem)
reset({ reset({
activityID: String( activityID: String(
workItem?.workItem?.activityId || workItem?.activityMaster?.id workItem?.workItem?.activityId || workItem?.activityMaster?.id

View File

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

View File

@ -0,0 +1,95 @@
import React from 'react'
import { formatUTCToLocalTime } from '../../utils/dateUtils'
const ServiceProfile = ({data,setIsOpenModal}) => {
return (
<div className="card mb-4 h-100">
<div className="card-header text-start">
<h5 className="card-action-title mb-0 ps-1">
{" "}
<i className="fa fa-building rounded-circle text-primary"></i>
<span className="ms-2 fw-bold">Project Profile</span>
</h5>
</div>
<div className="card-body">
<ul className="list-unstyled my-3 ps-0 text-start">
<li className="d-flex mb-3">
<div className="d-flex align-items-start" style={{ minWidth: "150px" }}>
<i className="bx bx-cog"></i>
<span className="fw-medium mx-2 text-nowrap">Name:</span>
</div>
<div className="flex-grow-1 text-start text-wrap">
{data.name}
</div>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-fingerprint"></i>
<span className="fw-medium mx-2">Nick Name:</span>
</div>
<span>{data.shortName}</span>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-check"></i>
<span className="fw-medium mx-2">Assign Date:</span>
</div>
<span>
{data.assignedDate ? formatUTCToLocalTime(data.assignedDate) : "NA"}
</span>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-trophy"></i>
<span className="fw-medium mx-2">Status:</span>
</div>
<span>{data?.status.status}</span>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-user"></i>
<span className="fw-medium mx-2">Contact:</span>
</div>
<span>{data.contactName}</span>
</li>
<li className="d-flex mb-3">
{/* Label section with icon */}
<div className="d-flex align-items-start" style={{ minWidth: "150px" }}>
<i className="bx bx-flag mt-1"></i>
<span className="fw-medium mx-2 text-nowrap">Address:</span>
</div>
{/* Content section that wraps nicely */}
<div className="flex-grow-1 text-start text-wrap">
{data.address}
</div>
</li>
<li className="d-flex justify-content-center mt-4">
<a className="d-flex justify-content-center mt-4">
<button
type="button"
className="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#edit-project-modal"
onClick={() => setIsOpenModal(true)}
>
Modify Details
</button>
</a>
</li>
</ul>
</div>
</div>
)
}
export default ServiceProfile

View File

@ -0,0 +1,86 @@
import React, { useState } from "react";
import { useBranchDetails } 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 } = useBranchDetails(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

@ -0,0 +1,359 @@
import React, { useEffect } from "react";
import { useProjectName } from "../../../hooks/useProjects";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import Label from "../../common/Label";
import {
useBranchDetails,
useCreateBranch,
useServiceProjects,
useUpdateBranch,
} from "../../../hooks/useServiceProject";
import { useAppForm } from "../../../hooks/appHooks/useAppForm";
import { useParams } from "react-router-dom";
import { BranchSchema, defaultBranches } from "../ServiceProjectSchema";
const ManageBranch = ({ closeModal, BranchToEdit = null }) => {
const {
data,
isLoading,
isError,
error: requestError,
} = useBranchDetails(BranchToEdit);
const [contacts, setContacts] = React.useState([
{
contactPerson: "",
designation: "",
contactEmails: [""],
contactNumbers: [""]
}
]);
const { projectId } = useParams();
const schema = BranchSchema();
const {
register,
control,
watch,
handleSubmit,
setValue,
reset,
formState: { errors },
} = useAppForm({
resolver: zodResolver(schema),
defaultValues: {
...defaultBranches,
projectId: projectId || "",
},
});
const handleClose = () => {
reset();
closeModal();
};
useEffect(() => {
if (BranchToEdit && data) {
reset({
branchName: data.branchName || "",
projectId: data.project?.id || projectId || "",
address: data.address || "",
branchType: data.branchType || "",
googleMapUrl: data.googleMapUrl || "",
});
if (data.contactInformation) {
try {
setContacts(JSON.parse(data.contactInformation));
} catch {
setContacts([]);
}
}
}
}, [data, reset]);
const { mutate: CreateServiceBranch, isPending: createPending } =
useCreateBranch(() => {
handleClose();
});
const { mutate: ServiceBranchUpdate, isPending } = useUpdateBranch(() =>
handleClose()
);
const onSubmit = (formdata) => {
let payload = {
...data,
...formdata,
projectId,
contactInformation: JSON.stringify(contacts), // important
};
if (BranchToEdit) {
ServiceBranchUpdate({ id: data.id, payload });
} else {
CreateServiceBranch(payload);
}
};
return (
<div className="container p-3">
<h5 className="m-0">
{BranchToEdit ? "Update Branch" : "Create Branch"}
</h5>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="branchName" className="form-label" required>
Branch Name
</Label>
<input
type="text"
id="branchName"
className="form-control form-control-sm"
{...register("branchName")}
placeholder="Enter Branch"
/>
{errors.branchName && (
<small className="danger-text">{errors.branchName.message}</small>
)}
</div>
<div className="col-md-6">
<Label htmlFor="branchType" className="form-label" required>
Branch Type
</Label>
<input
type="text"
id="branchType"
className="form-control form-control-sm"
{...register("branchType")}
/>
{errors.branchType && (
<small className="danger-text">{errors.branchType.message}</small>
)}
</div>
</div>
<div className="row my-2 text-start">
<div className="col-md-6">
<Label htmlFor="googleMapUrl" className="form-label">
Google Map URL
</Label>
<input
type="text"
id="googleMapUrl"
className="form-control form-control-sm"
{...register("googleMapUrl")}
/>
{errors.googleMapUrl && (
<small className="danger-text">
{errors.googleMapUrl.message}
</small>
)}
</div>
</div>
<div className="row my-2 text-start">
<div className="col-12">
<Label className="form-label" required>Contact Persons</Label>
{contacts.map((item, index) => (
<div key={index} className="border rounded p-2 mb-3">
{/* Contact Person + Designation */}
<div className="row mb-2">
<div className="col-md-4">
<input
type="text"
placeholder="Contact Person"
className="form-control form-control-sm"
value={item.contactPerson}
onChange={(e) => {
const list = [...contacts];
list[index].contactPerson = e.target.value;
setContacts(list);
}}
/>
</div>
<div className="col-md-4">
<input
type="text"
placeholder="Designation"
className="form-control form-control-sm"
value={item.designation}
onChange={(e) => {
const list = [...contacts];
list[index].designation = e.target.value;
setContacts(list);
}}
/>
</div>
{/* Remove entire contact */}
<div className="col-md-2 d-flex align-items-center">
<i
className="bx bx-trash text-danger cursor-pointer"
onClick={() =>
setContacts(contacts.filter((_, i) => i !== index))
}
></i>
</div>
</div>
{/* Numbers Section */}
<Label className="form-label">Contact Numbers</Label>
{item.contactNumbers.map((num, numIndex) => (
<div key={numIndex} className="d-flex gap-2 mb-2 align-items-center">
<input
type="text"
placeholder="Number"
className="form-control form-control-sm"
maxLength={10}
value={num}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, ""); // remove non-digit characters
const list = [...contacts];
list[index].contactNumbers[numIndex] = value;
setContacts(list);
}}
/>
{/* Show PLUS only on last row */}
{numIndex === item.contactNumbers.length - 1 ? (
<i
className="bx bx-plus-circle text-primary cursor-pointer fs-5"
onClick={() => {
const list = [...contacts];
list[index].contactNumbers.push("");
setContacts(list);
}}
></i>
) : (
<i
className="bx bx-minus-circle text-danger cursor-pointer fs-5"
onClick={() => {
const list = [...contacts];
list[index].contactNumbers.splice(numIndex, 1);
setContacts(list);
}}
></i>
)}
</div>
))}
<hr />
{/* Emails Section */}
<Label className="form-label">Contact Emails</Label>
{item.contactEmails.map((email, emailIndex) => (
<div key={emailIndex} className="d-flex gap-2 mb-2 align-items-center">
<input
type="email"
placeholder="Email"
className="form-control form-control-sm"
value={email}
onChange={(e) => {
const list = [...contacts];
list[index].contactEmails[emailIndex] = e.target.value;
setContacts(list);
}}
/>
{/* Show PLUS only on the last row */}
{emailIndex === item.contactEmails.length - 1 ? (
<i
className="bx bx-plus-circle text-primary cursor-pointer fs-5"
onClick={() => {
const list = [...contacts];
list[index].contactEmails.push("");
setContacts(list);
}}
></i>
) : (
<i
className="bx bx-minus-circle text-danger cursor-pointer fs-5"
onClick={() => {
const list = [...contacts];
list[index].contactEmails.splice(emailIndex, 1);
setContacts(list);
}}
></i>
)}
</div>
))}
</div>
))}
<button
type="button"
className="btn btn-sm btn-primary mt-2"
onClick={() =>
setContacts([
...contacts,
{
contactPerson: "",
designation: "",
contactEmails: [""], // important
contactNumbers: [""], // important
},
])
}
>
<i className="bx bx-plus"></i> Add Contact
</button>
</div>
</div>
<div className="row my-2 text-start">
<div className="col-12">
<Label htmlFor="address" className="form-label" required>
Address
</Label>
<textarea
id="address"
className="form-control form-control-sm"
{...register("address")}
/>
{errors.address && (
<small className="danger-text">{errors.address.message}</small>
)}
</div>
</div>
<div className="d-flex justify-content-end gap-3">
<button
type="reset"
onClick={handleClose}
className="btn btn-label-secondary btn-sm mt-3"
>
Cancel
</button>
<button type="submit" className="btn btn-primary btn-sm mt-3">
{isPending ? "Please wait..." : "Submit"}
</button>
</div>
</form>
</div>
);
};
export default ManageBranch;

View File

@ -0,0 +1,268 @@
import React, { useState } from "react";
import GlobalModel from "../../common/GlobalModel";
import ManageBranch from "./ManageBranch";
import { useBranches, useDeleteBranch } from "../../../hooks/useServiceProject";
import { ITEMS_PER_PAGE } from "../../../utils/constants";
import { useDebounce } from "../../../utils/appUtils";
import { useParams } from "react-router-dom";
import Pagination from "../../common/Pagination";
import ConfirmModal from "../../common/ConfirmModal";
import { SpinnerLoader } from "../../common/Loader";
const ServiceBranch = () => {
const { projectId } = useParams();
const [IsDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [manageState, setManageState] = useState({
IsOpen: false,
branchId: null,
});
const { mutate: DeleteBranch, isPending } = useDeleteBranch();
const [deletingId, setDeletingId] = useState(null);
const [search, setSearch] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const debouncedSearch = useDebounce(search, 500);
const { data, isLoading, isError, error } = useBranches(
projectId,
// true,
!showInactive,
ITEMS_PER_PAGE - 10,
currentPage,
debouncedSearch
);
const paginate = (page) => {
if (page >= 1 && page <= (data?.totalPages ?? 1)) {
setCurrentPage(page);
}
};
const columns = [
{
key: "branchName",
label: "Name",
align: "text-start",
getValue: (e) => e?.branchName || "N/A",
},
];
const handleDelete = (id) => {
setDeletingId(id);
DeleteBranch(
{ id, isActive: false },
{
onSettled: () => {
setDeletingId(null);
setIsDeleteModalOpen(false);
},
}
);
};
return (
<>
{IsDeleteModalOpen && (
<ConfirmModal
isOpen={IsDeleteModalOpen}
type="delete"
header="Delete Expense"
message="Are you sure you want delete?"
onSubmit={handleDelete}
onClose={() => setIsDeleteModalOpen(false)}
loading={isPending}
paramData={deletingId}
/>
)}
<div className="card h-100 table-responsive px-sm-4">
<div className="card-datatable" id="payment-request-table">
{/* Header Section */}
<div className="row align-items-center justify-content-between mt-3 mx-1">
<div className="col-md-6 col-sm-12 ms-n3 text-start ">
<h5 className="mb-0">
<i className="bx bx-buildings text-primary"></i>
<span className="ms-2 fw-bold">Branch</span>
</h5>
</div>
{/* Flex container for toggle + button */}
<div className="col-md-6 col-sm-12 text-end">
<div className="d-flex flex-column flex-md-row align-items-md-center justify-content-md-end gap-2">
{/* Toggle Switch */}
<div className="form-check form-switch d-inline-flex align-items-center">
<input
type="checkbox"
className="form-check-input mt-1"
id="inactiveEmployeesCheckbox"
checked={showInactive}
onChange={() => setShowInactive(!showInactive)}
/>
<label htmlFor="inactiveEmployeesCheckbox" className="ms-2 mt-1">
Show Deleted Branches
</label>
</div>
{/* Add Branch Button */}
<button
className="btn btn-sm btn-primary"
type="button"
onClick={() =>
setManageState({
IsOpen: true,
branchId: null,
})
}
>
<i className="bx bx-sm bx-plus-circle me-2"></i>
<span className="d-none d-md-inline-block">Add Branch</span>
</button>
</div>
</div>
</div>
<div className="mx-2 mt-3">
<table className="table border-top text-nowrap align-middle table-borderless">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} className={col.align}>
{col.label}
</th>
))}
<th className="text-center">Action</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td
colSpan={columns.length + 1}
className="text-center py-5"
style={{
height: "200px",
verticalAlign: "middle",
}}
>
<div className="d-flex justify-content-center align-items-center w-100 h-100">
<SpinnerLoader />
</div>
</td>
</tr>
)}
{isError && (
<tr>
<td
colSpan={columns.length + 1}
className="text-center text-danger py-5"
>
{error?.message || "Error loading branches"}
</td>
</tr>
)}
{!isLoading &&
!isError &&
data?.data?.length > 0 &&
data.data.map((branch) => (
<tr key={branch.id} style={{ height: "35px" }}>
{columns.map((col) => (
<td key={col.key} className={`${col.align} py-3`}>
{col.getValue(branch)}
</td>
))}
<td className="text-center">
<div className="dropdown z-2">
<button
type="button"
className="btn btn-xs btn-icon btn-text-secondary rounded-pill dropdown-toggle hide-arrow p-0 m-0"
data-bs-toggle="dropdown"
>
<i className="bx bx-dots-vertical-rounded text-muted p-0"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end w-auto">
{/* Modify */}
<li
onClick={() =>
setManageState({
IsOpen: true,
branchId: branch.id,
})
}
>
<a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-edit text-primary bx-xs me-2"></i>
Modify
</a>
</li>
{/* Delete */}
<li
onClick={() => {
setIsDeleteModalOpen(true);
setDeletingId(branch.id);
}}
>
<a className="dropdown-item px-2 cursor-pointer py-1">
<i className="bx bx-trash text-danger bx-xs me-2"></i>
Delete
</a>
</li>
</ul>
</div>
</td>
</tr>
))}
{!isLoading &&
!isError &&
(!data?.data || data.data.length === 0) && (
<tr>
<td
colSpan={columns.length + 1}
className="text-center py-12"
>
No Branch Found
</td>
</tr>
)}
</tbody>
</table>
{data?.data?.length > 0 && (
<Pagination
currentPage={currentPage}
totalPages={data.totalPages}
onPageChange={paginate}
/>
)}
</div>
{manageState.IsOpen && (
<GlobalModel
isOpen
size="md"
closeModal={() => setManageState({ IsOpen: false, branchId: null })}
>
<ManageBranch
key={manageState.branchId ?? "new"}
BranchToEdit={manageState.branchId}
closeModal={() =>
setManageState({ IsOpen: false, branchId: null })
}
/>
</GlobalModel>
)}
</div>
</div>
</>
);
};
export default ServiceBranch;

View File

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

View File

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

View File

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

View File

@ -1,30 +1,32 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Breadcrumb from "../common/Breadcrumb"; import Breadcrumb from "../../common/Breadcrumb";
import Label from "../common/Label"; import Label from "../../common/Label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { defaultJobValue, jobSchema } from "./ServiceProjectSchema"; import { defaultJobValue, jobSchema } from "../ServiceProjectSchema";
import { import {
useBranches,
useCreateServiceProjectJob, useCreateServiceProjectJob,
useJobTags, useJobTags,
useServiceProjectJobDetails, useServiceProjectJobDetails,
useServiceProjects, useServiceProjects,
useUpdateServiceProjectJob, useUpdateServiceProjectJob,
} from "../../hooks/useServiceProject"; } from "../../../hooks/useServiceProject";
import { ITEMS_PER_PAGE } from "../../utils/constants"; import { ITEMS_PER_PAGE } from "../../../utils/constants";
import DatePicker from "../common/DatePicker"; import DatePicker from "../../common/DatePicker";
import PmsEmployeeInputTag from "../common/PmsEmployeeInputTag"; import PmsEmployeeInputTag from "../../common/PmsEmployeeInputTag";
import TagInput from "../common/TagInput"; import TagInput from "../../common/TagInput";
import { localToUtc } from "../../utils/appUtils"; import { localToUtc } from "../../../utils/appUtils";
import SelectField from "../common/Forms/SelectField"; import SelectField from "../../common/Forms/SelectField";
import { import {
AppFormController, AppFormController,
AppFormProvider, AppFormProvider,
useAppForm, useAppForm,
} from "../../hooks/appHooks/useAppForm"; } from "../../../hooks/appHooks/useAppForm";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { useJobStatus } from "../../hooks/masterHook/useMaster"; import { useJobStatus } from "../../../hooks/masterHook/useMaster";
import { useServiceProjectJobContext } from "./Jobs"; import { useServiceProjectJobContext } from "../Jobs";
import { SelectFieldSearch } from "../../common/Forms/SelectFieldServerSide";
const ManageJob = ({ Job }) => { const ManageJob = ({ Job }) => {
const { setManageJob, setSelectedJob } = useServiceProjectJobContext(); const { setManageJob, setSelectedJob } = useServiceProjectJobContext();
@ -40,6 +42,7 @@ const ManageJob = ({ Job }) => {
watch, watch,
handleSubmit, handleSubmit,
reset, reset,
setValue,
formState: { errors }, formState: { errors },
} = methods; } = methods;
@ -162,6 +165,7 @@ const ManageJob = ({ Job }) => {
dueDate: JobData.dueDate ?? null, dueDate: JobData.dueDate ?? null,
tags: JobData.tags ?? [], tags: JobData.tags ?? [],
statusId: JobData.status.id, statusId: JobData.status.id,
projectBranchId : JobData?.projectBranch?.id
}); });
}, [JobData, Job, projectId]); }, [JobData, Job, projectId]);
return ( return (
@ -199,7 +203,7 @@ const ManageJob = ({ Job }) => {
/> />
</div> </div>
<div className="col-12 col-md-6 mb-2 mb-md-4"> <div className="col-12 col-md-6 mb-2 mb-md-4">
<Label required>Select Employee</Label> <Label>Select Employee</Label>
<PmsEmployeeInputTag <PmsEmployeeInputTag
control={control} control={control}
name="assignees" name="assignees"
@ -238,7 +242,20 @@ const ManageJob = ({ Job }) => {
name="tags" name="tags"
label="Tag" label="Tag"
placeholder="Enter Tag" placeholder="Enter Tag"
required />
</div>
<div className="col-12 col-md-6 mb-2 mb-md-4">
<SelectFieldSearch
label="Select Branch"
placeholder="Select Branch"
value={watch("projectBranchId")}
onChange={(val) => setValue("projectBranchId", val)}
valueKey="id"
labelKey="branchName"
hookParams={[projectId, true, 10, 1]}
useFetchHook={useBranches}
isMultiple={false}
disabled={Job}
/> />
</div> </div>
<div className="col-12"> <div className="col-12">

View File

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

@ -4,18 +4,27 @@ import { useServiceProject } from "../../hooks/useServiceProject";
import { formatUTCToLocalTime } from "../../utils/dateUtils"; import { formatUTCToLocalTime } from "../../utils/dateUtils";
import ManageServiceProject from "./ManageServiceProject"; import ManageServiceProject from "./ManageServiceProject";
import GlobalModel from "../common/GlobalModel"; import GlobalModel from "../common/GlobalModel";
import { SpinnerLoader } from "../common/Loader";
import ServiceBranch from "./ServiceProjectBranch/ServiceBranch";
import ServiceProfile from "./ServiceProfile";
const ServiceProjectProfile = () => { const ServiceProjectProfile = () => {
const { projectId } = useParams(); const { projectId } = useParams();
const [IsOpenModal, setIsOpenModal] = useState(false); const [IsOpenModal, setIsOpenModal] = useState(false);
const { data, isLoading, isError, error } = useServiceProject(projectId); const { data, isLoading, isError, error } = useServiceProject(projectId);
if (isLoading) { if (isLoading)
return <div className="">Loadng.</div>; return (
} <div className="py-8">
<SpinnerLoader />
</div>
);
return ( return (
<> <>
{IsOpenModal && ( {IsOpenModal && (
<GlobalModel isOpen={IsOpenModal} closeModal={() => setIsOpenModal(false)}> <GlobalModel
isOpen={IsOpenModal}
closeModal={() => setIsOpenModal(false)}
>
<ManageServiceProject <ManageServiceProject
serviceProjectId={projectId} serviceProjectId={projectId}
onClose={() => setIsOpenModal(false)} onClose={() => setIsOpenModal(false)}
@ -24,98 +33,13 @@ const ServiceProjectProfile = () => {
)} )}
<div className="row py-2"> <div className="row py-2">
<div className="col-md-6 col-lg-4 order-2 mb-6"> <div className="col-md-6 col-lg-5 order-2 mb-6">
<div className="card mb-4"> <ServiceProfile data={data} setIsOpenModal={setIsOpenModal}/>
<div className="card-header text-start">
<h5 className="card-action-title mb-0 ps-1">
{" "}
<i className="fa fa-building rounded-circle text-primary"></i>
<span className="ms-2 fw-bold">Project Profile</span>
</h5>
</div>
<div className="card-body">
<ul className="list-unstyled my-3 ps-0 text-start">
<li className="d-flex mb-3">
<div className="d-flex align-items-start" style={{ minWidth: "150px" }}>
<i className="bx bx-cog"></i>
<span className="fw-medium mx-2 text-nowrap">Name:</span>
</div>
{/* Content section that wraps nicely */}
<div className="flex-grow-1 text-start text-wrap">
{data.name}
</div>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-fingerprint"></i>
<span className="fw-medium mx-2">Nick Name:</span>
</div>
<span>{data.shortName}</span>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-check"></i>
<span className="fw-medium mx-2">Assign Date:</span>
</div>
<span>
{data.assignedDate ? formatUTCToLocalTime(data.assignedDate) : "NA"}
</span>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-trophy"></i>
<span className="fw-medium mx-2">Status:</span>
</div>
<span>{data?.status.status}</span>
</li>
<li className="d-flex mb-3">
<div className="d-flex align-items-center" style={{ width: '150px' }}>
<i className="bx bx-user"></i>
<span className="fw-medium mx-2">Contact:</span>
</div>
<span>{data.contactName}</span>
</li>
<li className="d-flex mb-3">
{/* Label section with icon */}
<div className="d-flex align-items-start" style={{ minWidth: "150px" }}>
<i className="bx bx-flag mt-1"></i>
<span className="fw-medium mx-2 text-nowrap">Address:</span>
</div>
{/* Content section that wraps nicely */}
<div className="flex-grow-1 text-start text-wrap">
{data.address}
</div>
</li>
<li className="d-flex justify-content-center mt-4"> {/* Added mt-4 for some top margin */}
<a className="d-flex justify-content-center mt-4"> {/* Added mt-4 for some top margin */}
<button
type="button"
className="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#edit-project-modal"
onClick={() => setIsOpenModal(true)}
>
Modify Details
</button>
</a>
</li>
</ul>
</div>
</div>
</div> </div>
<div className="col-md-6 col-lg-7 order-2 mb-6">
<ServiceBranch />
</div>
</div> </div>
</> </>
); );

View File

@ -50,6 +50,10 @@ export const defaultProjectValues = {
//#endregion //#endregion
//#region JobSchema //#region JobSchema
export const TagSchema = z.object({ export const TagSchema = z.object({
@ -70,6 +74,8 @@ export const jobSchema = z.object({
tags: z.array(TagSchema).optional().default([]), tags: z.array(TagSchema).optional().default([]),
statusId: z.string().optional().nullable(), statusId: z.string().optional().nullable(),
projectBranchId: z.string().optional().nullable(),
}); });
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
@ -109,6 +115,52 @@ export const defaultJobValue = {
startDate: null, startDate: null,
dueDate: null, dueDate: null,
tags: [], tags: [],
branchId: null,
}; };
//#endregion //#endregion
//#region Branch
export const BranchSchema = () =>
z.object({
projectId: z
.string()
.trim()
.min(1, { message: "Project is required" }),
branchName: z
.string()
.trim()
.min(1, { message: "Branch Name is required" }),
contactInformation: z.string().optional(),
address: z
.string()
.trim()
.min(1, { message: "Address is required" }),
branchType: z
.string()
.trim()
.min(1, { message: "Branch Type is required" }),
googleMapUrl: z
.string()
});
export const defaultBranches = {
branchName: "",
projectId: "",
contactInformation: "",
address: "",
branchType: "",
googleMapUrl: "",
};
//#endregion

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

@ -104,8 +104,7 @@ const SubScriptionHistory = ({ tenantId }) => {
</button> </button>
<button <button
className="dropdown-item py-1" className="dropdown-item py-1"
onClick={() => onClick={() =>{}
console.log("Download clicked for", item.id)
} }
> >
<i className="bx bx-cloud-download bx-sm"></i> Download <i className="bx bx-cloud-download bx-sm"></i> Download

View File

@ -93,11 +93,9 @@ const TenantForm = () => {
}; };
const onSubmitTenant = (data) => { const onSubmitTenant = (data) => {
console.log("Tenant Data:", data);
}; };
const onSubmitSubScription = (data) => { const onSubmitSubScription = (data) => {
console.log("Subscription Data:", data);
}; };
const newTenantConfig = [ const newTenantConfig = [

View File

@ -361,3 +361,212 @@ export const SelectProjectField = ({
</div> </div>
); );
}; };
export const SelectFieldSearch = ({
label = "Select",
placeholder = "Select ",
required = false,
value = null,
onChange,
valueKey = "id",
labelKey = "name",
disabled = false,
isFullObject = false,
isMultiple = false,
hookParams,
useFetchHook,
}) => {
const [searchText, setSearchText] = useState("");
const debounce = useDebounce(searchText, 300);
const { data, isLoading } = useFetchHook(...hookParams, debounce);
const options = data?.data ?? [];
const [open, setOpen] = useState(false);
const dropdownRef = useRef(null);
const getDisplayName = (entity) => {
if (!entity) return "";
return `${entity[labelKey] || ""}`.trim();
};
/** -----------------------------
* SELECTED OPTION (SINGLE)
* ----------------------------- */
let selectedSingle = null;
if (!isMultiple) {
if (isFullObject && value) selectedSingle = value;
else if (!isFullObject && value)
selectedSingle = options.find((o) => o[valueKey] === value);
}
/** -----------------------------
* SELECTED OPTION (MULTIPLE)
* ----------------------------- */
let selectedList = [];
if (isMultiple && Array.isArray(value)) {
if (isFullObject) selectedList = value;
else {
selectedList = options.filter((opt) => value.includes(opt[valueKey]));
}
}
/** Main button label */
const displayText = !isMultiple
? getDisplayName(selectedSingle) || placeholder
: selectedList.length > 0
? selectedList.map((e) => getDisplayName(e)).join(", ")
: placeholder;
/** -----------------------------
* HANDLE OUTSIDE CLICK
* ----------------------------- */
useEffect(() => {
const handleClickOutside = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// MERGED OPTIONS TO ENSURE SELECTED VALUE APPEARS EVEN IF NOT IN SEARCH RESULT
const [mergedOptions, setMergedOptions] = useState([]);
useEffect(() => {
let finalList = [...options];
if (!isMultiple && value && !isFullObject) {
// already selected option inside options?
const exists = options.some((o) => o[valueKey] === value);
// if selected item not found, try to get from props (value) as fallback
if (!exists && typeof value === "object") {
finalList.unshift(value);
}
}
if (isMultiple && Array.isArray(value)) {
value.forEach((val) => {
const id = isFullObject ? val[valueKey] : val;
const exists = options.some((o) => o[valueKey] === id);
if (!exists && typeof val === "object") {
finalList.unshift(val);
}
});
}
setMergedOptions(finalList);
}, [options, value]);
/** -----------------------------
* HANDLE SELECT
* ----------------------------- */
const handleSelect = (option) => {
if (!isMultiple) {
// SINGLE SELECT
if (isFullObject) onChange(option);
else onChange(option[valueKey]);
} else {
// MULTIPLE SELECT
let updated = [];
const exists = selectedList.some((e) => e[valueKey] === option[valueKey]);
if (exists) {
// remove
updated = selectedList.filter((e) => e[valueKey] !== option[valueKey]);
} else {
// add
updated = [...selectedList, option];
}
if (isFullObject) onChange(updated);
else onChange(updated.map((x) => x[valueKey]));
}
};
return (
<div className="mb-3 position-relative" ref={dropdownRef}>
{label && (
<Label className="form-label" required={required}>
{label}
</Label>
)}
{/* MAIN BUTTON */}
<button
type="button"
className={`select2-icons form-select d-flex align-items-center justify-content-between ${
open ? "show" : ""
}`}
disabled={disabled}
onClick={() => setOpen((prev) => !prev)}
>
<span className={`text-truncate ${!displayText ? "text-muted" : ""}`}>
{displayText}
</span>
</button>
{/* DROPDOWN */}
{open && (
<ul
className="dropdown-menu w-100 shadow-sm show animate__fadeIn h-64 overflow-auto rounded"
style={{
position: "absolute",
top: "100%",
left: 0,
zIndex: 1050,
marginTop: "2px",
borderRadius: "0.375rem",
overflow: "hidden",
}}
>
<div className="p-1">
<input
type="search"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="form-control form-control-sm"
placeholder="Search..."
disabled={disabled}
/>
</div>
{isLoading && (
<li className="dropdown-item text-muted text-center">Loading...</li>
)}
{!isLoading && options.length === 0 && (
<li className="dropdown-item text-muted text-center">
No results found
</li>
)}
{!isLoading &&
options.map((option) => {
const isActive = isMultiple
? selectedList.some((x) => x[valueKey] === option[valueKey])
: selectedSingle &&
selectedSingle[valueKey] === option[valueKey];
return (
<li key={option[valueKey]}>
<button
type="button"
className={`dropdown-item ${isActive ? "active" : ""}`}
onClick={() => handleSelect(option)}
>
{getDisplayName(option)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
};

View File

@ -28,7 +28,6 @@ const CommentEditor = () => {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const handleSubmit = () => { const handleSubmit = () => {
console.log("Comment:", value);
// Submit or handle content // Submit or handle content
}; };

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 { useDispatch, useSelector } from "react-redux";
import { import { closePopup, openPopup, togglePopup } from "../../slices/localVariablesSlice";
closePopup,
openPopup,
togglePopup,
} from "../../slices/localVariablesSlice";
/**
* align: "auto" | "left" | "right"
* boundaryRef: optional ref to the drawer/container element to use as boundary
*/
const HoverPopup = ({ const HoverPopup = ({
id, id,
title, title,
@ -13,6 +13,8 @@ const HoverPopup = ({
children, children,
className = "", className = "",
Mode = "hover", Mode = "hover",
align = "auto",
boundaryRef = null,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const visible = useSelector((s) => s.localVariables.popups[id] || false); const visible = useSelector((s) => s.localVariables.popups[id] || false);
@ -23,11 +25,9 @@ const HoverPopup = ({
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (Mode === "hover") dispatch(openPopup(id)); if (Mode === "hover") dispatch(openPopup(id));
}; };
const handleMouseLeave = () => { const handleMouseLeave = () => {
if (Mode === "hover") dispatch(closePopup(id)); if (Mode === "hover") dispatch(closePopup(id));
}; };
const handleClick = (e) => { const handleClick = (e) => {
if (Mode === "click") { if (Mode === "click") {
e.stopPropagation(); e.stopPropagation();
@ -35,43 +35,111 @@ const HoverPopup = ({
} }
}; };
// Close on outside click when using click mode
useEffect(() => { useEffect(() => {
if (Mode !== "click" || !visible) return; if (Mode !== "click" || !visible) return;
const handleOutside = (e) => { const handler = (e) => {
if ( if (
!popupRef.current?.contains(e.target) && popupRef.current &&
!triggerRef.current?.contains(e.target) !popupRef.current.contains(e.target) &&
triggerRef.current &&
!triggerRef.current.contains(e.target)
) { ) {
dispatch(closePopup(id)); 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(() => { useEffect(() => {
if (!visible || !popupRef.current) return; if (!visible || !popupRef.current || !triggerRef.current) return;
const popup = popupRef.current; // run in next frame so DOM/layout settles
const rect = popup.getBoundingClientRect(); requestAnimationFrame(() => {
const popup = popupRef.current;
popup.style.left = "50%"; // choose boundary: provided boundaryRef or nearest positioned parent (popup.parentElement)
popup.style.right = "auto"; const boundaryEl = (boundaryRef && boundaryRef.current) || popup.parentElement;
popup.style.transform = "translateX(-50%)"; if (!boundaryEl) return;
if (rect.right > window.innerWidth) { const boundaryRect = boundaryEl.getBoundingClientRect();
popup.style.left = "auto"; const triggerRect = triggerRef.current.getBoundingClientRect();
popup.style.right = "0";
popup.style.transform = "none";
}
if (rect.left < 0) { // reset styles first
popup.style.left = "0"; popup.style.left = "";
popup.style.right = "auto"; popup.style.right = "";
popup.style.transform = "none"; popup.style.transform = "";
} popup.style.top = "";
}, [visible]);
// 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 ( return (
<div className="d-inline-block position-relative"> <div className="d-inline-block position-relative">
@ -88,16 +156,18 @@ const HoverPopup = ({
{visible && ( {visible && (
<div <div
ref={popupRef} 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={{ style={{
zIndex: 2000, zIndex: 2000,
left: "50%", top: "100%", // open below trigger
transform: "translateX(-50%)", // left/right will be set by effect in parent coordinates
width: "max-content",
minWidth: "120px",
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{title && <h6 className="fw-semibold mb-2">{title}</h6>} {title && <h6 className="fw-semibold mb-2">{title}</h6>}
<div>{content}</div> <div>{content}</div>
</div> </div>
)} )}

View File

@ -411,7 +411,6 @@ export const useUpdateProject = (onSuccessCallback) => {
}, },
onError: (error) => { onError: (error) => {
console.log(error);
showToast(error?.message || "Error while updating project", "error"); showToast(error?.message || "Error while updating project", "error");
}, },
}); });

View File

@ -59,8 +59,8 @@ export const useCreateServiceProject = (onSuccessCallback) => {
onError: (error) => { onError: (error) => {
showToast( showToast(
error?.response?.data?.message || error?.response?.data?.message ||
error.message || error.message ||
"Failed to delete task", "Failed to delete task",
"error" "error"
); );
}, },
@ -84,8 +84,8 @@ export const useUpdateServiceProject = (onSuccessCallback) => {
onError: (error) => { onError: (error) => {
showToast( showToast(
error?.response?.data?.message || error?.response?.data?.message ||
error.message || error.message ||
"Failed to update project", "Failed to update project",
"error" "error"
); );
}, },
@ -110,8 +110,8 @@ export const useActiveInActiveServiceProject = (onSuccessCallback) => {
onError: (error) => { onError: (error) => {
showToast( showToast(
error?.response?.data?.message || error?.response?.data?.message ||
error.message || error.message ||
"Failed to update project", "Failed to update project",
"error" "error"
); );
}, },
@ -138,8 +138,8 @@ export const useAllocationServiceProjectTeam = (onSuccessCallback) => {
onError: (error) => { onError: (error) => {
showToast( showToast(
error?.response?.data?.message || error?.response?.data?.message ||
error.message || error.message ||
"Failed to update project", "Failed to update project",
"error" "error"
); );
}, },
@ -181,7 +181,7 @@ export const useJobComments = (jobId, pageSize, pageNumber) => {
); );
return resp.data; return resp.data;
}, },
enabled:!!jobId, enabled: !!jobId,
initialPageParam: pageNumber, initialPageParam: pageNumber,
@ -223,8 +223,8 @@ export const useAddCommentJob = (onSuccessCallback) => {
onError: (error) => { onError: (error) => {
showToast( showToast(
error?.response?.data?.message || error?.response?.data?.message ||
error.message || error.message ||
"Failed to update project", "Failed to update project",
"error" "error"
); );
}, },
@ -247,8 +247,8 @@ export const useCreateServiceProjectJob = (onSuccessCallback) => {
onError: (error) => { onError: (error) => {
showToast( showToast(
error?.response?.data?.message || error?.response?.data?.message ||
error.message || error.message ||
"Failed to update project", "Failed to update project",
"error" "error"
); );
}, },
@ -273,8 +273,8 @@ export const useUpdateServiceProjectJob = (onSuccessCallback) => {
onError: (error) => { onError: (error) => {
showToast( showToast(
error?.response?.data?.message || error?.response?.data?.message ||
error.message || error.message ||
"Failed to update project", "Failed to update project",
"error" "error"
); );
}, },
@ -282,3 +282,113 @@ export const useUpdateServiceProjectJob = (onSuccessCallback) => {
}; };
//#endregion //#endregion
//#region Branch
export const useBranches = (
projectId,
isActive,
pageSize,
pageNumber,
searchString
) => {
return useQuery({
queryKey: [
"branches",
projectId,
isActive,
pageSize,
pageNumber,
searchString,
],
queryFn: async () => {
const resp = await ServiceProjectRepository.GetBranchList(
projectId,
isActive,
pageSize,
pageNumber,
searchString
);
return resp.data;
},
enabled: !!projectId,
});
};
export const useBranchDetails = (id) => {
return useQuery({
queryKey: ["branch", id],
queryFn: async () => {
const resp = await ServiceProjectRepository.GetBranchDetail(id);
return resp.data;
},
enabled: !!id
})
}
export const useCreateBranch = (onSuccessCallBack) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload) => {
await ServiceProjectRepository.CreateBranch(payload);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["branches"] });
showToast("Branches Created Successfully", "success");
if (onSuccessCallBack) onSuccessCallBack();
},
onError: (error) => {
showToast(
error.message || "Something went wrong please try again !",
"error"
);
},
});
};
export const useUpdateBranch = (onSuccessCallBack) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, payload }) =>
await ServiceProjectRepository.UpdateBranch(id, payload),
onSuccess: (_, variables) => {
// remove old single-branch cache
queryClient.removeQueries({ queryKey: ["branch", variables.id] });
// refresh list
queryClient.invalidateQueries({ queryKey: ["branches"] });
showToast("Branch updated successfully", "success");
onSuccessCallBack?.();
},
onError: () => {
showToast("Something went wrong. Please try again later.", "error");
},
});
};
export const useDeleteBranch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, isActive}) =>
await ServiceProjectRepository.DeleteBranch(id, isActive),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["branches"] });
showToast("Branch deleted successfully", "success");
},
onError: (error) => {
showToast(
error?.response?.data?.message ||
error.message ||
"Failed to delete branch",
"error"
);
},
});
};

View File

@ -1,6 +1,8 @@
import { isAction } from "@reduxjs/toolkit";
import { api } from "../utils/axiosClient"; import { api } from "../utils/axiosClient";
export const ServiceProjectRepository = { export const ServiceProjectRepository = {
//#region Service Project
CreateServiceProject: (data) => api.post("/api/ServiceProject/create", data), CreateServiceProject: (data) => api.post("/api/ServiceProject/create", data),
GetServiceProjects: (pageSize, pageNumber) => GetServiceProjects: (pageSize, pageNumber) =>
api.get( api.get(
@ -17,6 +19,8 @@ export const ServiceProjectRepository = {
api.get( api.get(
`/api/ServiceProject/get/allocation/list?projectId=${projectId}&isActive=${isActive} ` `/api/ServiceProject/get/allocation/list?projectId=${projectId}&isActive=${isActive} `
), ),
//#endregion
//#region Job //#region Job
CreateJob: (data) => api.post(`/api/ServiceProject/job/create`, data), CreateJob: (data) => api.post(`/api/ServiceProject/job/create`, data),
@ -35,4 +39,22 @@ export const ServiceProjectRepository = {
api.patch(`/api/ServiceProject/job/edit/${id}`, patchData, { api.patch(`/api/ServiceProject/job/edit/${id}`, patchData, {
"Content-Type": "application/json-patch+json", "Content-Type": "application/json-patch+json",
}), }),
//#endregion
//#region Project Branch
CreateBranch: (data) => api.post(`/api/ServiceProject/branch/create`, data),
UpdateBranch: (id, data) =>
api.put(`/api/ServiceProject/branch/edit/${id}`, data),
GetBranchList: (projectId, isActive, pageSize, pageNumber, searchString) => {
return api.get(
`/api/ServiceProject/branch/list/${projectId}?isActive=${isActive}&pageSize=${pageSize}&pageNumber=${pageNumber}&searchString=${searchString}`
);
},
GetBranchDetail: (id) => api.get(`/api/ServiceProject/branch/details/${id}`),
DeleteBranch: (id, isActive = false) =>
api.delete(`/api/ServiceProject/branch/delete/${id}?isActive=${isActive}`),
}; };

View File

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