added comment, attchment

This commit is contained in:
pramod.mahajan 2025-11-14 13:04:15 +05:30
parent 297e0712bc
commit 86819dde03
10 changed files with 468 additions and 73 deletions

View File

@ -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 {

View File

@ -20,7 +20,7 @@ const Filelist = ({ files, removeFile, expenseToEdit,sm=6,md=4 }) => {
<i
className={`bx ${getIconByFileType(
file?.contentType
)} fs-3 text-primary`}
)} fs-3 `}
style={{ minWidth: "30px" }}
></i>

View File

@ -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 (
<div className="row">
<div className="sticky-section bg-white p-3">
<h6 className="m-0 fw-semibold mb-3">Add Comment</h6>
<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="d-flex justify-content-end gap-3 align-items-center text-end mt-3">
<div
onClick={() => document.getElementById("attachments").click()}
className="cursor-pointer"
>
<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-paperclip"></i>
Add Attachment
</div>
<button
className="btn btn-primary btn-sm px-3"
type="submit"
disabled={!watch("comment")?.trim() || isPending}
>
<i className="bx bx-send me-1"></i>
Submit
</button>
</div>
{files?.length > 0 && (
<Filelist files={files} removeFile={removeFile} />
)}
</form>
</div>
<div className="card-body p-0">
<div className="list-group p-0 m-0">
{jobComments?.map((item) => {
const user = item?.createdBy;
return (
<div
key={item.id}
className="list-group-item border-0 border-bottom p-0"
>
<div className="d-flex align-items-start mt-2 mx-0 px-0">
<Avatar
firstName={user?.firstName}
lastName={user?.lastName}
/>
<div className="">
<div className="d-flex flex-row gap-3">
<span className="fw-semibold">
{user?.firstName} {user?.lastName}
</span>
<span className="text-secondary">
<em>{formatUTCToLocalTime(item?.createdAt, true)}</em>
</span>
</div>
<div className="text-muted text-secondary">
{user?.jobRoleName}
</div>
<div className="text-wrap">
<p className="mb-1 mt-2 text-muted">{item.comment}</p>
<div className="d-flex flex-wrap jusify-content-end gap-2 gap-sm-6 ">
{item.attachments?.map((file) => (
<div className="d-flex align-items-center">
<i
className={`bx bx-xxl ${getIconByFileType(
file?.contentType
)} fs-3`}
></i>
<div className="d-flex flex-column">
<p className="m-0">{file.fileName}</p>
<small className="text-secondary">
{formatFileSize(file.fileSize)}
</small>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
export default JobComments;

View File

@ -0,0 +1,52 @@
import React from "react";
const JobStatusLog = ({ data }) => {
return (
<div className="card shadow-none p-0">
<div className="card-body p-0">
<div className="list-group">
{data?.map((item) => (
<div key={item.id} className="list-group-item border-0 border-bottom pb-3">
<div className="d-flex justify-content-between">
<div>
<span className="fw-semibold">
{item.nextStatus?.displayName ?? item.status?.displayName ?? "Status"}
</span>
</div>
<span className="badge bg-primary">
Level {item.nextStatus?.level ?? item.status?.level}
</span>
</div>
<p className="mb-1 mt-2 text-muted" style={{ fontSize: "0.9rem" }}>
{item.comment}
</p>
<div className="d-flex align-items-center mt-2">
<div className="rounded-circle bg-light d-flex justify-content-center align-items-center"
style={{ width: 32, height: 32 }}>
<i className="bx bx-user"></i>
</div>
<div className="ms-2">
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>
{item.updatedBy?.firstName} {item.updatedBy?.lastName}
</div>
<div className="text-muted" style={{ fontSize: "0.75rem" }}>
{item.updatedBy?.jobRoleName}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default JobStatusLog;

View File

@ -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 = () => {
<JobList filterByProject={selectedProject} />
</div>
</div>
<GlobalModel>
<PreviewDocument />
</GlobalModel>
</JonContext.Provider>
</>
);

View File

@ -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: <JobComments data={data} />,
},
{
id: "history",
title: "History",
active: false,
content: <JobStatusLog data={data?.updateLogs} />,
},
];
if (isLoading) return <SpinnerLoader />;
if (isError)
return (
@ -45,7 +64,8 @@ const ManageJobTicket = ({ Job }) => {
<small className="fs-6 text-secondary fs-italic me-3 mt-2">
<em>Created By</em>
</small>{" "}
<Avatar size="xs"
<Avatar
size="xs"
firstName={data?.createdBy?.firstName}
lastName={data?.createdBy?.lastName}
/>{" "}
@ -53,15 +73,41 @@ const ManageJobTicket = ({ Job }) => {
</div>
<div className="d-flex align-items-center">
<small className="fs-6 text-secondary fs-italic me-3 mt-2">
<em>Created By</em>
</small><EmployeeAvatarGroup employees={data?.assignees}/>
<em>Assigned</em>
</small>
<EmployeeAvatarGroup employees={data?.assignees} />
</div>
</div>
</div>
<hr className="divider"/>
<div className="col-12">
<div className="">
<div className="nav-align-top nav-tabs-shadow p-0 shadow-none">
<ul className="nav nav-tabs" role="tablist">
{tabsData.map((tab) => (
<li className="nav-item" key={tab.id}>
<button
type="button"
className={`nav-link ${tab.active ? "active" : ""}`}
data-bs-toggle="tab"
data-bs-target={`#tab-${tab.id}`}
role="tab"
>
{tab.title}
</button>
</li>
))}
</ul>
<div className="tab-content p-1 px-sm-3">
{tabsData.map((tab) => (
<div
key={tab.id}
className={`tab-pane fade ${tab.active ? "show active" : ""}`}
id={`tab-${tab.id}`}
role="tabpanel"
>
{tab.content}
</div>
))}
</div>
</div>
</div>

View File

@ -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,11 +40,9 @@ 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(),
@ -51,15 +50,39 @@ export const jobSchema = z.object({
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

View File

@ -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);
queryKey: ["serviceProjectJobs", pageSize, pageNumber, isActive, project],
queryFn: async () => {
const resp = await ServiceProjectRepository.GetJobList(
pageSize,
pageNumber,
isActive,
project
);
return resp.data;
}
})
}
export const useServiceProjectJobDetails = (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() =>{
queryKey: ["service-job", job],
queryFn: async () => {
const resp = await ServiceProjectRepository.GetJobDetails(job);
return resp.data;
},
enabled:!!job
})
}
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();

View File

@ -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}`
),
};

View File

@ -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;
return `rounded-pill text-bg-${color}`;
}