Upload new document api integrated and use can upload document at employee

This commit is contained in:
pramod mahajan 2025-08-29 16:05:54 +05:30
parent b93eaf6b95
commit daf7f11310
7 changed files with 388 additions and 299 deletions

View File

@ -1,33 +1,59 @@
import { z } from "zod";
import { normalizeAllowedContentTypes } from "../../utils/appUtils";
export const AttachmentSchema = z.object({
fileName: z.string().min(1, {message:"File name is required"}),
base64Data: z.string().min(1, {message:"File data is required"}),
contentType: z.string().min(1, {message:"MIME type is required"}),
fileSize: z
.number()
.int()
.nonnegative("fileSize must be ≥ 0")
.max(25 * 1024 * 1024, "fileSize must be ≤ 25MB"),
description: z.string().optional().default(""),
isActive: z.boolean(),
});
export const AttachmentSchema = (allowedContentType, maxSizeAllowedInMB) => {
const allowedTypes = normalizeAllowedContentTypes(allowedContentType);
return z.object({
fileName: z.string().min(1, { message: "File name is required" }),
base64Data: z.string().min(1, { message: "File data 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
.number()
.int()
.nonnegative("fileSize must be ≥ 0")
.max(
(maxSizeAllowedInMB ?? 25) * 1024 * 1024,
`fileSize must be ≤ ${maxSizeAllowedInMB ?? 25}MB`
),
description: z.string().optional().default(""),
isActive: z.boolean(),
});
};
export const TagSchema = z.object({
name: z.string().min(1, {message:"Tag name is required"}),
isActive: z.boolean(),
name: z.string().min(1, "Tag name is required"),
isActive: z.boolean().default(true),
});
export const DocumentPayloadSchema = (isMandatory, regularExp) => {
export const DocumentPayloadSchema = (docConfig = {}) => {
const {
isMandatory,
regexExpression,
allowedContentType,
maxSizeAllowedInMB,
} = docConfig;
let documentIdSchema = z.string();
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(
new RegExp(regularExp),
new RegExp(regexExpression),
"Invalid DocumentId format"
);
}
@ -35,22 +61,30 @@ export const DocumentPayloadSchema = (isMandatory, regularExp) => {
return z.object({
name: z.string().min(1, "Name is required"),
documentId: documentIdSchema,
description: z.string().min(1,{message:"Description is required"}),
entityId: z.string().min(1,{message:"Please Select Document Entity"}),
documentTypeId: z.string().min(1,{message:"Please Select Document Type"}),
documentCategoryId:z.string().min(1,{message:"Please Select Document Category"}),
attachment: AttachmentSchema,
tags: z.array(TagSchema).default([]),
description: z.string().min(1, { message: "Description is required" }),
// entityId: z.string().min(1, { message: "Please Select Document Entity" }),
documentTypeId: z.string().min(1, { message: "Please Select Document Type" }),
documentCategoryId: z
.string()
.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 = {
name: "",
documentId: "",
description: "",
entityId: "",
// entityId: "",
documentTypeId: "",
documentCategoryId:"",
documentCategoryId: "",
attachment: {
fileName: "",
base64Data: "",
@ -59,5 +93,5 @@ export const defaultDocumentValues = {
description: "",
isActive: true,
},
tags: [],
tags: [],
};

View File

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

View File

@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
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 Label from "../common/Label";
import { DOCUMENTS_ENTITIES } from "../../utils/constants";
@ -8,8 +8,11 @@ import {
useDocumentCategories,
useDocumentTypes,
} 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) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
@ -18,40 +21,70 @@ const toBase64 = (file) =>
reader.onerror = (err) => reject(err);
});
const NewDocument = () => {
const [selectedType, setType] = useState("");
const DocumentUpload = DocumentPayloadSchema(
selectedType?.isMandatory ?? null,
selectedType?.regexExpression ?? null
);
const NewDocument = ({closeModal}) => {
const { employeeId } = useParams();
const [selectedType, setSelectedType] = useState(null);
const [selectedCategory, setSelectedCategory] = useState(null);
const [schema, setSchema] = useState(() => DocumentPayloadSchema({}));
const methods = useForm({
resolver: zodResolver(schema),
defaultValues: defaultDocumentValues,
});
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = useForm({
resolver: zodResolver(DocumentUpload),
defaultValues: defaultDocumentValues,
} = methods;
const { mutate: UploadDocument, isPending } = useUploadDocument(() => {
showToast("Document Uploaded Successfully", "success");
closeModal();
});
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");
// API Data
const documentTypeId = watch("documentTypeId");
// This hooks calling api base Entity(Employee) and Category
const { DocumentCategories, isLoading } = useDocumentCategories(
DOCUMENTS_ENTITIES.EmployeeEntity
);
const { DocumentTypes, isLoading: isTypeLoading } =
useDocumentTypes(categoryId);
const categoryId = watch("documentCategoryId");
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
const onFileChange = async (e) => {
@ -82,177 +115,207 @@ const NewDocument = () => {
});
};
useEffect(() => {
if (documentTypeId) {
setType(DocumentTypes?.find((type) => type.id === documentTypeId));
}
}, [documentTypeId, DocumentTypes]);
// build dynamic file accept string
const fileAccept =
selectedType?.allowedContentType
?.split(",")
.map((t) =>
t === "application/pdf"
? ".pdf"
: t === "image/jpeg"
? ".jpg,.jpeg"
: t === "image/png"
? ".png"
: ""
)
.join(",") || "";
return (
<div className="p-2">
<p className="fw-bold fs-6">Upload New Document</p>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Document Name */}
<div className="mb-2">
<Label htmlFor="name" required>
Document Name
</Label>
<input
type="text"
className="form-control form-control-sm"
{...register("name")}
/>
{errors.name && (
<div className="danger-text">{errors.name.message}</div>
)}
</div>
{/* Category */}
<div className="mb-2">
<Label htmlFor="documentCategoryId">Document Category</Label>
<select
{...register("documentCategoryId")}
className="form-select form-select-sm"
>
{isLoading && (
<option disabled value="">
Loading...
</option>
<FormProvider key={documentTypeId} {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Document Name */}
<div className="mb-2">
<Label htmlFor="name" required>
Document Name
</Label>
<input
type="text"
className="form-control form-control-sm"
{...register("name")}
/>
{errors.name && (
<div className="danger-text">{errors.name.message}</div>
)}
{DocumentCategories?.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{errors.documentCategoryId && (
<div className="danger-text">
{errors.documentCategoryId.message}
</div>
)}
</div>
</div>
{/* Type */}
<div className="mb-2">
<Label htmlFor="documentTypeId">Document Type</Label>
<select
{...register("documentTypeId")}
className="form-select form-select-sm"
>
{isTypeLoading && (
<option disabled value="">
Loading...
</option>
)}
{DocumentTypes?.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{errors.documentTypeId && (
<div className="danger-text">{errors.documentTypeId.message}</div>
)}
</div>
{/* Document ID */}
<div className="mb-2">
<Label
htmlFor="documentId"
required={selectedType?.isMandatory ?? false}
>
Document ID
</Label>
<input
type="text"
className="form-control form-control-sm"
{...register("documentId")}
/>
{errors.documentId && (
<div className="danger-text">{errors.documentId.message}</div>
)}
</div>
{/* Upload */}
<div className="row my-2">
<div className="col-md-12">
<label className="form-label">Upload Document</label>
<div
className="border border-secondary border-dashed rounded p-4 text-center bg-textMuted position-relative"
style={{ cursor: "pointer" }}
onClick={() => document.getElementById("attachment").click()}
{/* Category */}
<div className="mb-2">
<Label htmlFor="documentCategoryId">Document Category</Label>
<select
{...register("documentCategoryId")}
className="form-select form-select-sm"
>
<i className="bx bx-cloud-upload d-block bx-lg"></i>
<span className="text-muted d-block">
Click to select or click here to browse
</span>
<small className="text-muted">(PDF, JPG, PNG, max 25MB)</small>
<input
type="file"
id="attachment"
accept=".pdf,.jpg,.jpeg,.png"
style={{ display: "none" }}
onChange={(e) => {
onFileChange(e);
e.target.value = ""; // reset input
}}
/>
</div>
{errors.attachment && (
<small className="danger-text">
{errors.attachment.fileName?.message ||
errors.attachment.base64Data?.message ||
errors.attachment.contentType?.message ||
errors.attachment.fileSize?.message}
</small>
)}
{file && (
<div className="d-flex justify-content-between text-start p-1 mt-2">
<div>
<span className="mb-0 text-secondary small d-block">
{file.fileName}
</span>
<span className="text-body-secondary small d-block">
{(file.fileSize / 1024).toFixed(1)} KB
</span>
</div>
<i
className="bx bx-trash bx-sm cursor-pointer text-danger"
onClick={removeFile}
></i>
{isLoading && (
<option disabled value="">
Loading...
</option>
)}
{!isLoading && <option value="">Select Category</option>}
{DocumentCategories?.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{errors.documentCategoryId && (
<div className="danger-text">
{errors.documentCategoryId.message}
</div>
)}
</div>
</div>
{/* Description */}
<div className="mb-2">
<Label htmlFor="description">Description</Label>
<textarea
rows="2"
className="form-control"
{...register("description")}
></textarea>
{errors.description && (
<div className="danger-text">{errors.description.message}</div>
)}
</div>
{/* Type */}
<div className="mb-2">
<Label htmlFor="documentTypeId">Document Type</Label>
<select
{...register("documentTypeId")}
className="form-select form-select-sm"
>
{isTypeLoading && (
<option disabled value="">
Loading...
</option>
)}
{DocumentTypes?.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{errors.documentTypeId && (
<div className="danger-text">{errors.documentTypeId.message}</div>
)}
</div>
{/* Buttons */}
<div className="d-flex justify-content-center gap-3">
<button type="submit" className="btn btn-primary btn-sm">
Submit
</button>
<button type="reset" className="btn btn-secondary btn-sm">
Cancel
</button>
</div>
</form>
{/* Document ID */}
<div className="mb-2">
<Label
htmlFor="documentId"
required={selectedType?.isMandatory ?? false}
>
Document ID
</Label>
<input
type="text"
className="form-control form-control-sm"
{...register("documentId")}
/>
{errors.documentId && (
<div className="danger-text">{errors.documentId.message}</div>
)}
</div>
{/* Upload */}
<div className="row my-2">
<div className="col-md-12">
<label className="form-label">Upload Document</label>
<div
className="border border-secondary border-dashed rounded p-4 text-center bg-textMuted position-relative"
style={{ cursor: "pointer" }}
onClick={() => document.getElementById("attachment").click()}
>
<i className="bx bx-cloud-upload d-block bx-lg"></i>
<span className="text-muted d-block">
Click to select or click here to browse
</span>
<small className="text-muted">
({selectedType?.allowedContentType || "PDF/JPG/PNG"}, max{" "}
{selectedType?.maxSizeAllowedInMB ?? 25}MB)
</small>
<input
type="file"
id="attachment"
accept={selectedType?.allowedContentType}
style={{ display: "none" }}
onChange={(e) => {
onFileChange(e);
e.target.value = ""; // reset input
}}
/>
</div>
{errors.attachment && (
<small className="danger-text">
{errors.attachment.message
? errors.attachment.message
: errors.attachment.fileName?.message ||
errors.attachment.base64Data?.message ||
errors.attachment.contentType?.message ||
errors.attachment.fileSize?.message}
</small>
)}
{file?.base64Data && (
<div className="d-flex justify-content-between text-start p-1 mt-2">
<div>
<span className="mb-0 text-secondary small d-block">
{file.fileName}
</span>
<span className="text-body-secondary small d-block">
{(file.fileSize / 1024).toFixed(1)} KB
</span>
</div>
<i
className="bx bx-trash bx-sm cursor-pointer text-danger"
onClick={removeFile}
></i>
</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 */}
<div className="mb-2">
<Label htmlFor="description">Description</Label>
<textarea
rows="2"
className="form-control"
{...register("description")}
></textarea>
{errors.description && (
<div className="danger-text">{errors.description.message}</div>
)}
</div>
{/* Buttons */}
<div className="d-flex justify-content-center gap-3">
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={isPending}
>
{isPending ? "Please Wait..." : " Submit"}
</button>
<button
type="reset"
className="btn btn-secondary btn-sm"
disabled={isPending}
onClick={()=>closeModal()}
>
Cancel
</button>
</div>
</form>
</FormProvider>
</div>
);
};

View File

@ -1,5 +1,5 @@
import { useFormContext, useWatch } from "react-hook-form";
import React, { useEffect, useState } from "react";
import React, { useState, useEffect } from "react";
import { useFormContext } from "react-hook-form";
const TagInput = ({
label = "Tags",
@ -8,104 +8,58 @@ const TagInput = ({
color = "#e9ecef",
options = [],
}) => {
const [tags, setTags] = useState([]);
const [tags, setTags] = useState([]); // now array of objects
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState([]);
const { setValue, trigger, control } = useFormContext();
const watchedTags = useWatch({ control, name });
const { setValue } = useFormContext();
useEffect(() => {
if (
Array.isArray(watchedTags) &&
JSON.stringify(tags) !== JSON.stringify(watchedTags)
) {
setTags(watchedTags);
}
}, [JSON.stringify(watchedTags)]);
// Keep form value synced
useEffect(() => {
if (input.trim() === "") {
setSuggestions([]);
} else {
const filtered = options?.filter(
(opt) =>
opt?.name?.toLowerCase()?.includes(input.toLowerCase()) &&
!tags?.some((tag) => tag.name === opt.name)
);
setSuggestions(filtered);
}
}, [input, options, tags]);
setValue(name, tags, { shouldValidate: true });
}, [tags, name, setValue]);
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);
const handleKeyDown = (e) => {
if (e.key === "Enter" && input.trim()) {
e.preventDefault();
if (!tags.some((t) => t.name === input.trim())) {
setTags((prev) => [...prev, { name: input.trim(), isActive: true }]);
}
setInput("");
setSuggestions([]);
}
};
const removeTag = (indexToRemove) => {
const newTags = tags.filter((_, i) => i !== indexToRemove);
setTags(newTags);
setValue(name, newTags, { shouldValidate: true });
trigger(name);
const handleRemove = (tagName) => {
setTags((prev) => prev.filter((t) => t.name !== tagName));
};
const handleInputKeyDown = (e) => {
if ((e.key === "Enter" || e.key === " ")&& input.trim() !== "") {
e.preventDefault();
const existing = options.find(
(opt) => opt.name.toLowerCase() === input.trim().toLowerCase()
const handleChange = (e) => {
const val = e.target.value;
setInput(val);
if (val) {
setSuggestions(
options
.filter(
(opt) =>
opt.toLowerCase().includes(val.toLowerCase()) &&
!tags.some((t) => t.name === opt)
)
.map((s) => ({ name: s, isActive: true }))
);
const newTag = existing
? existing
: {
id: null,
name: input.trim(),
description: input.trim(),
};
addTag(newTag);
} else if (e.key === "Backspace" && input === "") {
setTags((prev) => prev.slice(0, -1));
} else {
setSuggestions([]);
}
};
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) => {
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 (
<>
<label htmlFor={name} className="form-label">
@ -122,17 +76,16 @@ useEffect(() => {
key={index}
className="d-flex align-items-center"
style={{
color: iconColor,
backgroundColor,
backgroundColor: color,
padding: "2px 6px",
borderRadius: "2px",
fontSize: "0.85rem",
fontSize: "0.8rem",
}}
>
{tag.name}
<i
className="bx bx-x bx-xs ms-1"
onClick={() => removeTag(index)}
onClick={() => handleRemove(tag.name)}
style={{ cursor: "pointer" }}
/>
</span>
@ -141,9 +94,8 @@ useEffect(() => {
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKey}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
style={{
border: "none",
@ -156,12 +108,14 @@ useEffect(() => {
{suggestions.length > 0 && (
<ul
className="list-group position-absolute mt-1 bg-white w-50 shadow-sm "
className="list-group position-absolute mt-1 bg-white w-50 shadow-sm"
style={{
zIndex: 1000,
maxHeight: "150px",
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) => (
@ -169,8 +123,7 @@ useEffect(() => {
key={i}
className="dropdown-item p-1 hoverBox"
onClick={() => handleSuggestionClick(sugg)}
style={{cursor: "pointer", fontSize: "0.875rem"}}
style={{ cursor: "pointer", fontSize: "0.875rem" }}
>
{sugg.name}
</li>
@ -181,4 +134,5 @@ useEffect(() => {
</>
);
};
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";
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";
};
export const normalizeAllowedContentTypes = (allowedContentType) => {
if (!allowedContentType) return [];
if (Array.isArray(allowedContentType)) return allowedContentType;
if (typeof allowedContentType === "string") return allowedContentType.split(",");
return [];
};