From daf7f11310a2e8ba77c90352e60da8f5f4566b29 Mon Sep 17 00:00:00 2001 From: pramod mahajan Date: Fri, 29 Aug 2025 16:05:54 +0530 Subject: [PATCH] Upload new document api integrated and use can upload document at employee --- src/components/Documents/DocumentSchema.js | 88 +++-- src/components/Documents/Documents.jsx | 2 +- src/components/Documents/NewDocument.jsx | 421 ++++++++++++--------- src/components/common/TagInput.jsx | 136 +++---- src/hooks/useDocument.js | 22 ++ src/repositories/DocumentRepository.jsx | 10 +- src/utils/appUtils.js | 8 + 7 files changed, 388 insertions(+), 299 deletions(-) create mode 100644 src/hooks/useDocument.js diff --git a/src/components/Documents/DocumentSchema.js b/src/components/Documents/DocumentSchema.js index 5842e488..bb20e8b0 100644 --- a/src/components/Documents/DocumentSchema.js +++ b/src/components/Documents/DocumentSchema.js @@ -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: [], }; diff --git a/src/components/Documents/Documents.jsx b/src/components/Documents/Documents.jsx index 343310d8..5c8042d5 100644 --- a/src/components/Documents/Documents.jsx +++ b/src/components/Documents/Documents.jsx @@ -41,7 +41,7 @@ const Documents = () => { {isUpload && ( setUpload(false)}> - + setUpload(false)}/> )} diff --git a/src/components/Documents/NewDocument.jsx b/src/components/Documents/NewDocument.jsx index 175f46f4..7bdfa352 100644 --- a/src/components/Documents/NewDocument.jsx +++ b/src/components/Documents/NewDocument.jsx @@ -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 (

Upload New Document

- -
- {/* Document Name */} -
- - - {errors.name && ( -
{errors.name.message}
- )} -
- - {/* Category */} -
- - + {errors.name && ( +
{errors.name.message}
)} - {DocumentCategories?.map((type) => ( - - ))} - - {errors.documentCategoryId && ( -
- {errors.documentCategoryId.message} -
- )} -
+
- {/* Type */} -
- - - {errors.documentTypeId && ( -
{errors.documentTypeId.message}
- )} -
- - {/* Document ID */} -
- - - {errors.documentId && ( -
{errors.documentId.message}
- )} -
- - {/* Upload */} -
-
- - -
document.getElementById("attachment").click()} + {/* Category */} +
+ + { - onFileChange(e); - e.target.value = ""; // reset input - }} - /> -
- - {errors.attachment && ( - - {errors.attachment.fileName?.message || - errors.attachment.base64Data?.message || - errors.attachment.contentType?.message || - errors.attachment.fileSize?.message} - - )} - - {file && ( -
-
- - {file.fileName} - - - {(file.fileSize / 1024).toFixed(1)} KB - -
- + {isLoading && ( + + )} + {!isLoading && } + {DocumentCategories?.map((type) => ( + + ))} + + {errors.documentCategoryId && ( +
+ {errors.documentCategoryId.message}
)}
-
- {/* Description */} -
- - - {errors.description && ( -
{errors.description.message}
- )} -
+ {/* Type */} +
+ + + {errors.documentTypeId && ( +
{errors.documentTypeId.message}
+ )} +
- {/* Buttons */} -
- - -
- + {/* Document ID */} +
+ + + {errors.documentId && ( +
{errors.documentId.message}
+ )} +
+ + {/* Upload */} +
+
+ + +
document.getElementById("attachment").click()} + > + + + Click to select or click here to browse + + + ({selectedType?.allowedContentType || "PDF/JPG/PNG"}, max{" "} + {selectedType?.maxSizeAllowedInMB ?? 25}MB) + + + { + onFileChange(e); + e.target.value = ""; // reset input + }} + /> +
+ + {errors.attachment && ( + + {errors.attachment.message + ? errors.attachment.message + : errors.attachment.fileName?.message || + errors.attachment.base64Data?.message || + errors.attachment.contentType?.message || + errors.attachment.fileSize?.message} + + )} + + {file?.base64Data && ( +
+
+ + {file.fileName} + + + {(file.fileSize / 1024).toFixed(1)} KB + +
+ +
+ )} +
+
+
+ + {errors.tags && ( + {errors.tags.message} + )} +
+ + {/* Description */} +
+ + + {errors.description && ( +
{errors.description.message}
+ )} +
+ + {/* Buttons */} +
+ + +
+ +
); }; diff --git a/src/components/common/TagInput.jsx b/src/components/common/TagInput.jsx index d0517223..bbcfda78 100644 --- a/src/components/common/TagInput.jsx +++ b/src/components/common/TagInput.jsx @@ -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 ( <>