Added Document Managment feature #388

Merged
pramod.mahajan merged 124 commits from Document_Manag into main 2025-09-10 14:34:35 +00:00
7 changed files with 388 additions and 299 deletions
Showing only changes of commit daf7f11310 - Show all commits

View File

@ -1,33 +1,59 @@
import { z } from "zod"; import { z } from "zod";
import { normalizeAllowedContentTypes } from "../../utils/appUtils";
export const AttachmentSchema = z.object({ export const AttachmentSchema = (allowedContentType, maxSizeAllowedInMB) => {
const allowedTypes = normalizeAllowedContentTypes(allowedContentType);
return z.object({
fileName: z.string().min(1, { message: "File name is required" }), fileName: z.string().min(1, { message: "File name is required" }),
base64Data: z.string().min(1, { message: "File data is required" }), base64Data: z.string().min(1, { message: "File data is required" }),
contentType: z.string().min(1, {message:"MIME type is required"}), contentType: z
.string()
.min(1, { message: "MIME type is required" })
.refine(
(val) => (allowedTypes.length ? allowedTypes.includes(val) : true),
{
message: `File type must be one of: ${allowedTypes.join(", ")}`,
}
),
fileSize: z fileSize: z
.number() .number()
.int() .int()
.nonnegative("fileSize must be ≥ 0") .nonnegative("fileSize must be ≥ 0")
.max(25 * 1024 * 1024, "fileSize must be ≤ 25MB"), .max(
(maxSizeAllowedInMB ?? 25) * 1024 * 1024,
`fileSize must be ≤ ${maxSizeAllowedInMB ?? 25}MB`
),
description: z.string().optional().default(""), description: z.string().optional().default(""),
isActive: z.boolean(), isActive: z.boolean(),
}); });
};
export const TagSchema = z.object({ export const TagSchema = z.object({
name: z.string().min(1, {message:"Tag name is required"}), name: z.string().min(1, "Tag name is required"),
isActive: z.boolean(), isActive: z.boolean().default(true),
}); });
export const DocumentPayloadSchema = (isMandatory, regularExp) => {
export const DocumentPayloadSchema = (docConfig = {}) => {
const {
isMandatory,
regexExpression,
allowedContentType,
maxSizeAllowedInMB,
} = docConfig;
let documentIdSchema = z.string(); let documentIdSchema = z.string();
if (isMandatory) { if (isMandatory) {
documentIdSchema = documentIdSchema.min(1, {message:"DocumentId is required"}); documentIdSchema = documentIdSchema.min(1, {
message: "DocumentId is required",
});
} }
if (regularExp) { if (regexExpression) {
documentIdSchema = documentIdSchema.regex( documentIdSchema = documentIdSchema.regex(
new RegExp(regularExp), new RegExp(regexExpression),
"Invalid DocumentId format" "Invalid DocumentId format"
); );
} }
@ -36,19 +62,27 @@ export const DocumentPayloadSchema = (isMandatory, regularExp) => {
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
documentId: documentIdSchema, documentId: documentIdSchema,
description: z.string().min(1, { message: "Description is required" }), description: z.string().min(1, { message: "Description is required" }),
entityId: z.string().min(1,{message:"Please Select Document Entity"}), // entityId: z.string().min(1, { message: "Please Select Document Entity" }),
documentTypeId: z.string().min(1, { message: "Please Select Document Type" }), documentTypeId: z.string().min(1, { message: "Please Select Document Type" }),
documentCategoryId:z.string().min(1,{message:"Please Select Document Category"}), documentCategoryId: z
attachment: AttachmentSchema, .string()
tags: z.array(TagSchema).default([]), .min(1, { message: "Please Select Document Category" }),
attachment: AttachmentSchema(allowedContentType, maxSizeAllowedInMB).nullable().refine(
(val) => val !== null,
{ message: "Attachment is required" }
),
tags: z.array(TagSchema).optional().default([]),
}); });
}; };
export const defaultDocumentValues = { export const defaultDocumentValues = {
name: "", name: "",
documentId: "", documentId: "",
description: "", description: "",
entityId: "", // entityId: "",
documentTypeId: "", documentTypeId: "",
documentCategoryId: "", documentCategoryId: "",
attachment: { attachment: {

View File

@ -41,7 +41,7 @@ const Documents = () => {
</div> </div>
{isUpload && ( {isUpload && (
<GlobalModel isOpen={isUpload} closeModal={()=>setUpload(false)}> <GlobalModel isOpen={isUpload} closeModal={()=>setUpload(false)}>
<NewDocument/> <NewDocument closeModal={()=>setUpload(false)}/>
</GlobalModel> </GlobalModel>
)} )}

View File

@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import { defaultDocumentValues, DocumentPayloadSchema } from "./DocumentSchema"; import { defaultDocumentValues, DocumentPayloadSchema } from "./DocumentSchema";
import Label from "../common/Label"; import Label from "../common/Label";
import { DOCUMENTS_ENTITIES } from "../../utils/constants"; import { DOCUMENTS_ENTITIES } from "../../utils/constants";
@ -8,8 +8,11 @@ import {
useDocumentCategories, useDocumentCategories,
useDocumentTypes, useDocumentTypes,
} from "../../hooks/masterHook/useMaster"; } from "../../hooks/masterHook/useMaster";
import TagInput from "../common/TagInput";
import { useUploadDocument } from "../../hooks/useDocument";
import showToast from "../../services/toastService";
import { useParams } from "react-router-dom";
// util fn: convert file base64
const toBase64 = (file) => const toBase64 = (file) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@ -18,40 +21,70 @@ const toBase64 = (file) =>
reader.onerror = (err) => reject(err); reader.onerror = (err) => reject(err);
}); });
const NewDocument = () => { const NewDocument = ({closeModal}) => {
const [selectedType, setType] = useState(""); const { employeeId } = useParams();
const [selectedType, setSelectedType] = useState(null);
const DocumentUpload = DocumentPayloadSchema( const [selectedCategory, setSelectedCategory] = useState(null);
selectedType?.isMandatory ?? null, const [schema, setSchema] = useState(() => DocumentPayloadSchema({}));
selectedType?.regexExpression ?? null const methods = useForm({
); resolver: zodResolver(schema),
defaultValues: defaultDocumentValues,
});
const { const {
register, register,
handleSubmit, handleSubmit,
watch, watch,
setValue, setValue,
reset,
formState: { errors }, formState: { errors },
} = useForm({ } = methods;
resolver: zodResolver(DocumentUpload), const { mutate: UploadDocument, isPending } = useUploadDocument(() => {
defaultValues: defaultDocumentValues, showToast("Document Uploaded Successfully", "success");
closeModal();
}); });
const onSubmit = (data) => { const onSubmit = (data) => {
console.log("Form submitted:", data); const DocumentPayload = { ...data, entityId: employeeId };
UploadDocument(DocumentPayload);
}; };
const documentTypeId = watch("documentTypeId");
const categoryId = watch("documentCategoryId");
const file = watch("attachment"); const file = watch("attachment");
// API Data const documentTypeId = watch("documentTypeId");
// This hooks calling api base Entity(Employee) and Category
const { DocumentCategories, isLoading } = useDocumentCategories( const { DocumentCategories, isLoading } = useDocumentCategories(
DOCUMENTS_ENTITIES.EmployeeEntity DOCUMENTS_ENTITIES.EmployeeEntity
); );
const { DocumentTypes, isLoading: isTypeLoading } = const categoryId = watch("documentCategoryId");
useDocumentTypes(categoryId); const { DocumentTypes, isLoading: isTypeLoading } = useDocumentTypes(
categoryId || null
);
// Update schema whenever document type changes
useEffect(() => {
if (!documentTypeId) return;
const type = DocumentTypes?.find((t) => t.id === documentTypeId);
setSelectedType(type || null);
if (type) {
const newSchema = DocumentPayloadSchema({
isMandatory: type.isMandatory ?? false,
regexExpression: type.regexExpression ?? null,
allowedContentType: type.allowedContentType ?? [
"application/pdf",
"image/jpeg",
"image/png",
],
maxSizeAllowedInMB: type.maxSizeAllowedInMB ?? 25,
});
setSchema(() => newSchema);
reset({ ...methods.getValues() }, { keepValues: true });
}
}, [documentTypeId, DocumentTypes, reset]);
// File Upload // File Upload
const onFileChange = async (e) => { const onFileChange = async (e) => {
@ -82,16 +115,24 @@ const NewDocument = () => {
}); });
}; };
useEffect(() => { // build dynamic file accept string
if (documentTypeId) { const fileAccept =
setType(DocumentTypes?.find((type) => type.id === documentTypeId)); selectedType?.allowedContentType
} ?.split(",")
}, [documentTypeId, DocumentTypes]); .map((t) =>
t === "application/pdf"
? ".pdf"
: t === "image/jpeg"
? ".jpg,.jpeg"
: t === "image/png"
? ".png"
: ""
)
.join(",") || "";
return ( return (
<div className="p-2"> <div className="p-2">
<p className="fw-bold fs-6">Upload New Document</p> <p className="fw-bold fs-6">Upload New Document</p>
<FormProvider key={documentTypeId} {...methods}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{/* Document Name */} {/* Document Name */}
<div className="mb-2"> <div className="mb-2">
@ -120,6 +161,7 @@ const NewDocument = () => {
Loading... Loading...
</option> </option>
)} )}
{!isLoading && <option value="">Select Category</option>}
{DocumentCategories?.map((type) => ( {DocumentCategories?.map((type) => (
<option key={type.id} value={type.id}> <option key={type.id} value={type.id}>
{type.name} {type.name}
@ -188,12 +230,15 @@ const NewDocument = () => {
<span className="text-muted d-block"> <span className="text-muted d-block">
Click to select or click here to browse Click to select or click here to browse
</span> </span>
<small className="text-muted">(PDF, JPG, PNG, max 25MB)</small> <small className="text-muted">
({selectedType?.allowedContentType || "PDF/JPG/PNG"}, max{" "}
{selectedType?.maxSizeAllowedInMB ?? 25}MB)
</small>
<input <input
type="file" type="file"
id="attachment" id="attachment"
accept=".pdf,.jpg,.jpeg,.png" accept={selectedType?.allowedContentType}
style={{ display: "none" }} style={{ display: "none" }}
onChange={(e) => { onChange={(e) => {
onFileChange(e); onFileChange(e);
@ -204,14 +249,16 @@ const NewDocument = () => {
{errors.attachment && ( {errors.attachment && (
<small className="danger-text"> <small className="danger-text">
{errors.attachment.fileName?.message || {errors.attachment.message
? errors.attachment.message
: errors.attachment.fileName?.message ||
errors.attachment.base64Data?.message || errors.attachment.base64Data?.message ||
errors.attachment.contentType?.message || errors.attachment.contentType?.message ||
errors.attachment.fileSize?.message} errors.attachment.fileSize?.message}
</small> </small>
)} )}
{file && ( {file?.base64Data && (
<div className="d-flex justify-content-between text-start p-1 mt-2"> <div className="d-flex justify-content-between text-start p-1 mt-2">
<div> <div>
<span className="mb-0 text-secondary small d-block"> <span className="mb-0 text-secondary small d-block">
@ -229,6 +276,12 @@ const NewDocument = () => {
)} )}
</div> </div>
</div> </div>
<div className="mb-2">
<TagInput name="tags" label="Tags" placeholder="Tags.." />
{errors.tags && (
<small className="danger-text">{errors.tags.message}</small>
)}
</div>
{/* Description */} {/* Description */}
<div className="mb-2"> <div className="mb-2">
@ -245,14 +298,24 @@ const NewDocument = () => {
{/* Buttons */} {/* Buttons */}
<div className="d-flex justify-content-center gap-3"> <div className="d-flex justify-content-center gap-3">
<button type="submit" className="btn btn-primary btn-sm"> <button
Submit type="submit"
className="btn btn-primary btn-sm"
disabled={isPending}
>
{isPending ? "Please Wait..." : " Submit"}
</button> </button>
<button type="reset" className="btn btn-secondary btn-sm"> <button
type="reset"
className="btn btn-secondary btn-sm"
disabled={isPending}
onClick={()=>closeModal()}
>
Cancel Cancel
</button> </button>
</div> </div>
</form> </form>
</FormProvider>
</div> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
import { useFormContext, useWatch } from "react-hook-form"; import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form";
const TagInput = ({ const TagInput = ({
label = "Tags", label = "Tags",
@ -8,104 +8,58 @@ const TagInput = ({
color = "#e9ecef", color = "#e9ecef",
options = [], options = [],
}) => { }) => {
const [tags, setTags] = useState([]); const [tags, setTags] = useState([]); // now array of objects
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const { setValue, trigger, control } = useFormContext();
const watchedTags = useWatch({ control, name });
const { setValue } = useFormContext();
// Keep form value synced
useEffect(() => { useEffect(() => {
if ( setValue(name, tags, { shouldValidate: true });
Array.isArray(watchedTags) && }, [tags, name, setValue]);
JSON.stringify(tags) !== JSON.stringify(watchedTags)
) {
setTags(watchedTags);
}
}, [JSON.stringify(watchedTags)]);
useEffect(() => { const handleKeyDown = (e) => {
if (input.trim() === "") { if (e.key === "Enter" && input.trim()) {
setSuggestions([]); e.preventDefault();
} else { if (!tags.some((t) => t.name === input.trim())) {
const filtered = options?.filter( setTags((prev) => [...prev, { name: input.trim(), isActive: true }]);
(opt) =>
opt?.name?.toLowerCase()?.includes(input.toLowerCase()) &&
!tags?.some((tag) => tag.name === opt.name)
);
setSuggestions(filtered);
} }
}, [input, options, tags]);
const addTag = async (tagObj) => {
if (!tags.some((tag) => tag.name === tagObj.name)) {
const cleanedTag = {
id: tagObj.id ?? null,
name: tagObj.name,
};
const newTags = [...tags, cleanedTag];
setTags(newTags);
setValue(name, newTags, { shouldValidate: true });
await trigger(name);
setInput(""); setInput("");
setSuggestions([]); setSuggestions([]);
} }
}; };
const removeTag = (indexToRemove) => { const handleRemove = (tagName) => {
const newTags = tags.filter((_, i) => i !== indexToRemove); setTags((prev) => prev.filter((t) => t.name !== tagName));
setTags(newTags);
setValue(name, newTags, { shouldValidate: true });
trigger(name);
}; };
const handleInputKeyDown = (e) => { const handleChange = (e) => {
if ((e.key === "Enter" || e.key === " ")&& input.trim() !== "") { const val = e.target.value;
e.preventDefault(); setInput(val);
const existing = options.find( if (val) {
(opt) => opt.name.toLowerCase() === input.trim().toLowerCase() setSuggestions(
options
.filter(
(opt) =>
opt.toLowerCase().includes(val.toLowerCase()) &&
!tags.some((t) => t.name === opt)
)
.map((s) => ({ name: s, isActive: true }))
); );
const newTag = existing } else {
? existing setSuggestions([]);
: {
id: null,
name: input.trim(),
description: input.trim(),
};
addTag(newTag);
} else if (e.key === "Backspace" && input === "") {
setTags((prev) => prev.slice(0, -1));
} }
}; };
const handleInputKey = (e) => {
const key = e.key?.toLowerCase();
if ((key === "enter" || key === " " || e.code === "Space") && input.trim() !== "") {
e.preventDefault();
const existing = options.find(
(opt) => opt.name.toLowerCase() === input.trim().toLowerCase()
);
const newTag = existing
? existing
: {
id: null,
name: input.trim(),
description: input.trim(),
};
addTag(newTag);
} else if ((key === "backspace" || e.code === "Backspace") && input === "") {
setTags((prev) => prev.slice(0, -1));
}
};
const handleSuggestionClick = (suggestion) => { const handleSuggestionClick = (suggestion) => {
addTag(suggestion); if (!tags.some((t) => t.name === suggestion.name)) {
setTags((prev) => [...prev, suggestion]);
}
setInput("");
setSuggestions([]);
}; };
const backgroundColor = color || "#f8f9fa";
const iconColor = `var(--bs-${color})`;
return ( return (
<> <>
<label htmlFor={name} className="form-label"> <label htmlFor={name} className="form-label">
@ -122,17 +76,16 @@ useEffect(() => {
key={index} key={index}
className="d-flex align-items-center" className="d-flex align-items-center"
style={{ style={{
color: iconColor, backgroundColor: color,
backgroundColor,
padding: "2px 6px", padding: "2px 6px",
borderRadius: "2px", borderRadius: "2px",
fontSize: "0.85rem", fontSize: "0.8rem",
}} }}
> >
{tag.name} {tag.name}
<i <i
className="bx bx-x bx-xs ms-1" className="bx bx-x bx-xs ms-1"
onClick={() => removeTag(index)} onClick={() => handleRemove(tag.name)}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
/> />
</span> </span>
@ -141,9 +94,8 @@ useEffect(() => {
<input <input
type="text" type="text"
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={handleChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleInputKey}
placeholder={placeholder} placeholder={placeholder}
style={{ style={{
border: "none", border: "none",
@ -161,7 +113,9 @@ useEffect(() => {
zIndex: 1000, zIndex: 1000,
maxHeight: "150px", maxHeight: "150px",
overflowY: "auto", overflowY: "auto",
boxShadow:"0px 4px 10px rgba(0, 0, 0, 0.2)",borderRadius:"3px",border:"1px solid #ddd" boxShadow: "0px 4px 10px rgba(0, 0, 0, 0.2)",
borderRadius: "3px",
border: "1px solid #ddd",
}} }}
> >
{suggestions.map((sugg, i) => ( {suggestions.map((sugg, i) => (
@ -170,7 +124,6 @@ useEffect(() => {
className="dropdown-item p-1 hoverBox" className="dropdown-item p-1 hoverBox"
onClick={() => handleSuggestionClick(sugg)} onClick={() => handleSuggestionClick(sugg)}
style={{ cursor: "pointer", fontSize: "0.875rem" }} style={{ cursor: "pointer", fontSize: "0.875rem" }}
> >
{sugg.name} {sugg.name}
</li> </li>
@ -181,4 +134,5 @@ useEffect(() => {
</> </>
); );
}; };
export default TagInput; export default TagInput;

22
src/hooks/useDocument.js Normal file
View File

@ -0,0 +1,22 @@
//----------------------- MUTATION -------------------------
import { useMutation } from "@tanstack/react-query"
import showToast from "../services/toastService";
import { DocumentRepository } from "../repositories/DocumentRepository";
export const useUploadDocument =(onSuccessCallBack)=>{
return useMutation(({
mutationFn:async(DocumentPayload)=>DocumentRepository.uploadDocument(DocumentPayload),
onSuccess:(data,variables)=>{
if(onSuccessCallBack) onSuccessCallBack()
},
onError: (error) => {
showToast(
error.message || "Something went wrong please try again !",
"error"
);
},
}))
}

View File

@ -1,5 +1,13 @@
import { api } from "../utils/axiosClient"; import { api } from "../utils/axiosClient";
export const DocumentRepository = { export const DocumentRepository = {
uploadDocument:(data)=> api.post(`/api/Document/upload`,data) uploadDocument:(data)=> api.post(`/api/Document/upload`,data),
getDocumentList:(entityTypeId,entityId,pageSize, pageNumber, filter,searchString)=>{
const payloadJsonString = JSON.stringify(filter);
return api.get(`/api/Document/list/${entityTypeId}/entity/${entityId}/?pageSize=${pageSize}&pageNumber=${pageNumber}&filter=${payloadJsonString}&searchString=${searchString}`)
},
getDocumentById:(id)=>api.get(`/api/Document/${id}`),
getFilterEntities:(entityId)=>api.get(`/api/Document/get/filter${entityTypeId}`),
} }

View File

@ -61,3 +61,11 @@ export const getIconByFileType = (type = "") => {
return "bx bx-file"; return "bx bx-file";
}; };
export const normalizeAllowedContentTypes = (allowedContentType) => {
if (!allowedContentType) return [];
if (Array.isArray(allowedContentType)) return allowedContentType;
if (typeof allowedContentType === "string") return allowedContentType.split(",");
return [];
};