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 (
+
-
+
+
+ {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}`;
}
-