diff --git a/public/assets/css/core-extend.css b/public/assets/css/core-extend.css index ae7c4205..b05f71c1 100644 --- a/public/assets/css/core-extend.css +++ b/public/assets/css/core-extend.css @@ -5,8 +5,13 @@ } .offcanvas.offcanvas-wide { width: 700px !important; /* adjust as needed */ - max-width: 90vw; /* responsive fallback */ } +.sticky-section { + position: sticky; + top: var(--sticky-top, 0px) !important; + z-index: 1025; +} + /* ===========================% Background_Colors %========================================================== */ .bg-light-primary { diff --git a/src/components/Expenses/Filelist.jsx b/src/components/Expenses/Filelist.jsx index bd9f7108..35a4a986 100644 --- a/src/components/Expenses/Filelist.jsx +++ b/src/components/Expenses/Filelist.jsx @@ -20,7 +20,7 @@ const Filelist = ({ files, removeFile, expenseToEdit,sm=6,md=4 }) => { diff --git a/src/components/ServiceProject/JobComments.jsx b/src/components/ServiceProject/JobComments.jsx new file mode 100644 index 00000000..ada85a11 --- /dev/null +++ b/src/components/ServiceProject/JobComments.jsx @@ -0,0 +1,214 @@ +import React, { useEffect, useState } from "react"; +import Avatar from "../common/Avatar"; +import { useAppForm } from "../../hooks/appHooks/useAppForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +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"; + +const JobComments = ({ data }) => { + const { + register, + handleSubmit, + watch, + reset, + setValue, + formState: { errors }, + } = useAppForm({ + resolver: zodResolver(JobCommentSchema), + default: { comment: "", attachments: [] }, + }); + + const { + data: comments, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useJobComments(data?.id, ITEMS_PER_PAGE, 1); + const jobComments = comments?.pages?.flatMap((p) => p?.data ?? []) ?? []; + + const { mutate: AddComment, isPending } = useAddCommentJob(() => reset()); + const onSubmit = (formData) => { + formData.jobTicketId = data?.id; + AddComment(formData); + }; + + useEffect(() => { + document.documentElement.style.setProperty("--sticky-top", `-25px`); + }, []); + + const files = watch("attachments"); + const onFileChange = async (e) => { + const newFiles = Array.from(e.target.files); + if (newFiles.length === 0) return; + + const existingFiles = Array.isArray(watch("attachments")) + ? watch("attachments") + : []; + + const parsedFiles = await Promise.all( + newFiles.map(async (file) => { + const base64Data = await toBase64(file); + return { + fileName: file.name, + base64Data, + contentType: file.type, + fileSize: file.size, + description: "", + isActive: true, + }; + }) + ); + + const combinedFiles = [ + ...existingFiles, + ...parsedFiles.filter( + (newFile) => + !existingFiles?.some( + (f) => + f.fileName === newFile.fileName && f.fileSize === newFile.fileSize + ) + ), + ]; + + setValue("attachments", combinedFiles, { + shouldDirty: true, + shouldValidate: true, + }); + }; + + const toBase64 = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result.split(",")[1]); + reader.onerror = (error) => reject(error); + }); + const removeFile = (index) => { + const newFiles = files.filter((_, i) => i !== index); + setValue("attachments", newFiles, { shouldValidate: true }); + }; + return ( +
+
+
Add Comment
+ +
+
+ + +
+ + + {errors?.comment && ( + + {errors?.comment?.message} + + )} +
+
+ +
+
document.getElementById("attachments").click()} + className="cursor-pointer" + > + { + onFileChange(e); + e.target.value = ""; + }} + /> + + Add Attachment +
+ +
+ {files?.length > 0 && ( + + )} + +
+
+
+ {jobComments?.map((item) => { + const user = item?.createdBy; + + return ( +
+
+ +
+
+ + {user?.firstName} {user?.lastName} + + + {formatUTCToLocalTime(item?.createdAt, true)} + +
+
+ {user?.jobRoleName} +
+
+

{item.comment}

+
+ {item.attachments?.map((file) => ( +
+ +
+

{file.fileName}

+ + {formatFileSize(file.fileSize)} + +
+
+ ))} +
+
+
+
+
+ ); + })} +
+
+
+ ); +}; + +export default JobComments; diff --git a/src/components/ServiceProject/JobStatusLog.jsx b/src/components/ServiceProject/JobStatusLog.jsx new file mode 100644 index 00000000..2f61c8ce --- /dev/null +++ b/src/components/ServiceProject/JobStatusLog.jsx @@ -0,0 +1,52 @@ +import React from "react"; + +const JobStatusLog = ({ data }) => { + return ( +
+
+
+ + {data?.map((item) => ( +
+ +
+
+ + {item.nextStatus?.displayName ?? item.status?.displayName ?? "Status"} + +
+ + + Level {item.nextStatus?.level ?? item.status?.level} + +
+ +

+ {item.comment} +

+ +
+
+ +
+
+
+ {item.updatedBy?.firstName} {item.updatedBy?.lastName} +
+
+ {item.updatedBy?.jobRoleName} +
+
+
+ +
+ ))} + +
+
+
+ ); +}; + +export default JobStatusLog; diff --git a/src/components/ServiceProject/Jobs.jsx b/src/components/ServiceProject/Jobs.jsx index 69e20b61..8387f977 100644 --- a/src/components/ServiceProject/Jobs.jsx +++ b/src/components/ServiceProject/Jobs.jsx @@ -7,6 +7,8 @@ import OffcanvasComponent from "../common/OffcanvasComponent"; import showToast from "../../services/toastService"; import ManageJob from "./ManageJob"; import ManageJobTicket from "./ManageJobTicket"; +import GlobalModel from "../common/GlobalModel"; +import PreviewDocument from "../Expenses/PreviewDocument"; export const JonContext = createContext(); export const useServiceProjectJobContext = () => { @@ -72,6 +74,10 @@ const Jobs = () => { + + + + ); diff --git a/src/components/ServiceProject/ManageJobTicket.jsx b/src/components/ServiceProject/ManageJobTicket.jsx index 3d422a83..92f1363b 100644 --- a/src/components/ServiceProject/ManageJobTicket.jsx +++ b/src/components/ServiceProject/ManageJobTicket.jsx @@ -1,15 +1,34 @@ -import React from "react"; +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"; const ManageJobTicket = ({ Job }) => { const { data, isLoading, isError, error } = useServiceProjectJobDetails( Job?.job ); + + const tabsData = [ + { + id: "comment", + title: "Coments", + active: true, + content: , + }, + { + id: "history", + title: "History", + active: false, + content: , + }, + ]; + + if (isLoading) return ; if (isError) return ( @@ -41,27 +60,54 @@ const ManageJobTicket = ({ Job }) => {
-
- - Created By - {" "} - {" "} - {`${data?.createdBy?.firstName} ${data?.createdBy?.lastName}`} -
-
- - Created By - -
+
+ + Created By + {" "} + {" "} + {`${data?.createdBy?.firstName} ${data?.createdBy?.lastName}`} +
+
+ + Assigned + + +
-
-
-
+
+
    + {tabsData.map((tab) => ( +
  • + +
  • + ))} +
+ +
+ {tabsData.map((tab) => ( +
+ {tab.content} +
+ ))}
diff --git a/src/components/ServiceProject/ServiceProjectSchema.jsx b/src/components/ServiceProject/ServiceProjectSchema.jsx index 349e8070..acaa779f 100644 --- a/src/components/ServiceProject/ServiceProjectSchema.jsx +++ b/src/components/ServiceProject/ServiceProjectSchema.jsx @@ -4,7 +4,9 @@ export const projectSchema = z.object({ name: z.string().min(1, "Name is required"), shortName: z.string().min(1, "Short name is required"), clientId: z.string().min(1, { message: "Client is required" }), - services:z.array(z.string()).min(1,{message:"At least one service required"}), + services: z + .array(z.string()) + .min(1, { message: "At least one service required" }), address: z.string().min(1, "Address is required"), assignedDate: z.string().min(1, { message: "Date is required" }), statusId: z.string().min(1, "Status is required"), @@ -31,7 +33,6 @@ export const defaultProjectValues = { //#region JobSchema - export const TagSchema = z.object({ name: z.string().min(1, "Tag name is required"), isActive: z.boolean().default(true), @@ -39,27 +40,49 @@ export const TagSchema = z.object({ export const jobSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().min(1, "Description is required"), - projectId: z.string().min(1,"Project is required"), + projectId: z.string().min(1, "Project is required"), - assignees: z - .array(z.string() ) - .nonempty("At least one assignee is required"), + assignees: z.array(z.string()).nonempty("At least one assignee is required"), startDate: z.string(), dueDate: z.string(), - tags: z.array(TagSchema).optional().default([]), + tags: z.array(TagSchema).optional().default([]), }); +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ALLOWED_TYPES = [ + "application/pdf", + "image/png", + "image/jpg", + "image/jpeg", +]; +export const JobCommentSchema = z.object({ + comment: z.string().min(1, { message: "Please leave comment" }), + attachments: z.array( + z.object({ + fileName: z.string().min(1, { message: "Filename is required" }), + base64Data: z.string().nullable(), + contentType: z.string().refine((val) => ALLOWED_TYPES.includes(val), { + message: "Only PDF, PNG, JPG, or JPEG files are allowed", + }), + documentId: z.string().optional(), + fileSize: z.number().max(MAX_FILE_SIZE, { + message: "File size must be less than or equal to 5MB", + }), + description: z.string().optional(), + isActive: z.boolean().default(true), + }) + ), +}); export const defaultJobValue = { - title:"", - description:"", - projectId:"", - assignees:[], - startDate:null, - dueDate:null, - tags:[] + title: "", + description: "", + projectId: "", + assignees: [], + startDate: null, + dueDate: null, + tags: [], +}; -} - -//#endregion \ No newline at end of file +//#endregion diff --git a/src/hooks/useServiceProject.jsx b/src/hooks/useServiceProject.jsx index 3e13b848..c0a809b3 100644 --- a/src/hooks/useServiceProject.jsx +++ b/src/hooks/useServiceProject.jsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ServiceProjectRepository } from "../repositories/ServiceProjectRepository"; import showToast from "../services/toastService"; @@ -102,26 +102,75 @@ export const useActiveInActiveServiceProject = (onSuccessCallback) => { //#region Service Jobs - -export const useServiceProjectJobs=(pageSize,pageNumber,isActive=true,project)=>{ +export const useServiceProjectJobs = ( + pageSize, + pageNumber, + isActive = true, + project +) => { return useQuery({ - queryKey:["serviceProjectJobs",pageSize,pageNumber,isActive,project], - queryFn:async() =>{ - const resp = await ServiceProjectRepository.GetJobList(pageSize,pageNumber,isActive,project); - return resp.data; - } - }) -} -export const useServiceProjectJobDetails = (job)=>{ - return useQuery({ - queryKey:['service-job',job], - queryFn:async() =>{ - const resp = await ServiceProjectRepository.GetJobDetails(job); + queryKey: ["serviceProjectJobs", pageSize, pageNumber, isActive, project], + queryFn: async () => { + const resp = await ServiceProjectRepository.GetJobList( + pageSize, + pageNumber, + isActive, + project + ); return resp.data; }, - enabled:!!job - }) -} + }); +}; +export const useJobComments = (jobId, pageSize, pageNumber ) => { + return useInfiniteQuery({ + queryKey: ["jobComments", jobId, pageSize], + + queryFn: async ({ pageParam = pageNumber }) => { + const resp = await ServiceProjectRepository.GetJobComment(jobId, pageSize, pageParam); + return resp.data; + }, + + initialPageParam: pageNumber, + + getNextPageParam: (lastPage, allPages) => { + if (!lastPage?.data?.length) return null; + + return allPages.length + pageNumber; + }, + }); +}; + +export const useServiceProjectJobDetails = (job) => { + return useQuery({ + queryKey: ["service-job", job], + queryFn: async () => { + const resp = await ServiceProjectRepository.GetJobDetails(job); + return resp.data; + }, + enabled: !!job, + }); +}; +export const useAddCommentJob = (onSuccessCallback) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => await ServiceProjectRepository.AddComment(data), + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["jobComments", variables?.jobTicketId], + }); + if (onSuccessCallback) onSuccessCallback(); + showToast("Job Created successfully", "success"); + }, + onError: (error) => { + showToast( + error?.response?.data?.message || + error.message || + "Failed to update project", + "error" + ); + }, + }); +}; export const useCreateServiceProjectJob = (onSuccessCallback) => { const queryClient = useQueryClient(); diff --git a/src/repositories/ServiceProjectRepository.jsx b/src/repositories/ServiceProjectRepository.jsx index 541d63de..c7b2812a 100644 --- a/src/repositories/ServiceProjectRepository.jsx +++ b/src/repositories/ServiceProjectRepository.jsx @@ -15,9 +15,14 @@ export const ServiceProjectRepository = { //#region Job CreateJob: (data) => api.post(`/api/ServiceProject/job/create`, data), - GetJobList: (pageSize, pageNumber, isActive,projectId,) => + GetJobList: (pageSize, pageNumber, isActive, projectId) => api.get( `/api/ServiceProject/job/list?pageSize=${pageSize}&pageNumber=${pageNumber}&isActive=${isActive}&projectId=${projectId}` ), - GetJobDetails:(id)=>api.get(`/api/ServiceProject/job/details/${id}`) + GetJobDetails: (id) => api.get(`/api/ServiceProject/job/details/${id}`), + AddComment: (data) => api.post("/api/ServiceProject/job/add/comment", data), + GetJobComment: (jobTicketId,pageSize,pageNumber) => + api.get( + `/api/ServiceProject/job/comment/list?jobTicketId=${jobTicketId}&pageSize=${pageSize}&pageNumber=${pageNumber}` + ), }; diff --git a/src/utils/appUtils.js b/src/utils/appUtils.js index 5cb6068c..1c681809 100644 --- a/src/utils/appUtils.js +++ b/src/utils/appUtils.js @@ -51,13 +51,15 @@ export const useDebounce = (value, delay = 500) => { export const getIconByFileType = (type = "") => { const lower = type.toLowerCase(); - if (lower === "application/pdf") return "bxs-file-pdf"; - if (lower.includes("word")) return "bxs-file-doc"; + if (lower === "application/pdf") return "bxs-file-pdf text-danger"; + if (lower.includes("word")) return "bxs-file-doc text-primary text-primry"; if (lower.includes("excel") || lower.includes("spreadsheet")) - return "bxs-file-xls"; + return "bxs-file-xls text-primry"; if (lower === "image/png") return "bxs-file-png"; - if (lower === "image/jpeg" || lower === "image/jpg") return "bxs-file-jpg"; - if (lower.includes("zip") || lower.includes("rar")) return "bxs-file-archive"; + if (lower === "image/jpeg" || lower === "image/jpg") + return "bxs-file-jpg text-primry"; + if (lower.includes("zip") || lower.includes("rar")) + return "bxs-file-archive text-secondary"; return "bx bx-file"; }; @@ -192,19 +194,12 @@ export const frequencyLabel = ( } }; -const badgeColors = [ - "primary", - "secondary", - "success", - "warning", - "info", -]; +const badgeColors = ["primary", "secondary", "success", "warning", "info"]; let colorIndex = 0; -export function getNextBadgeColor(type="label") { +export function getNextBadgeColor(type = "label") { const color = badgeColors[colorIndex]; - colorIndex = (colorIndex + 1) % badgeColors.length; + colorIndex = (colorIndex + 1) % badgeColors.length; return `rounded-pill text-bg-${color}`; } -