Added Document Managment feature #388
@ -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) => {
|
||||||
fileName: z.string().min(1, {message:"File name is required"}),
|
const allowedTypes = normalizeAllowedContentTypes(allowedContentType);
|
||||||
base64Data: z.string().min(1, {message:"File data is required"}),
|
|
||||||
contentType: z.string().min(1, {message:"MIME type is required"}),
|
return z.object({
|
||||||
fileSize: z
|
fileName: z.string().min(1, { message: "File name is required" }),
|
||||||
.number()
|
base64Data: z.string().min(1, { message: "File data is required" }),
|
||||||
.int()
|
contentType: z
|
||||||
.nonnegative("fileSize must be ≥ 0")
|
.string()
|
||||||
.max(25 * 1024 * 1024, "fileSize must be ≤ 25MB"),
|
.min(1, { message: "MIME type is required" })
|
||||||
description: z.string().optional().default(""),
|
.refine(
|
||||||
isActive: z.boolean(),
|
(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({
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -35,22 +61,30 @@ export const DocumentPayloadSchema = (isMandatory, regularExp) => {
|
|||||||
return z.object({
|
return z.object({
|
||||||
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: {
|
||||||
fileName: "",
|
fileName: "",
|
||||||
base64Data: "",
|
base64Data: "",
|
||||||
@ -59,5 +93,5 @@ export const defaultDocumentValues = {
|
|||||||
description: "",
|
description: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -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,177 +115,207 @@ 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">
|
||||||
<Label htmlFor="name" required>
|
<Label htmlFor="name" required>
|
||||||
Document Name
|
Document Name
|
||||||
</Label>
|
</Label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control form-control-sm"
|
className="form-control form-control-sm"
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<div className="danger-text">{errors.name.message}</div>
|
<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>
|
|
||||||
)}
|
)}
|
||||||
{DocumentCategories?.map((type) => (
|
</div>
|
||||||
<option key={type.id} value={type.id}>
|
|
||||||
{type.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.documentCategoryId && (
|
|
||||||
<div className="danger-text">
|
|
||||||
{errors.documentCategoryId.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type */}
|
{/* Category */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Label htmlFor="documentTypeId">Document Type</Label>
|
<Label htmlFor="documentCategoryId">Document Category</Label>
|
||||||
<select
|
<select
|
||||||
{...register("documentTypeId")}
|
{...register("documentCategoryId")}
|
||||||
className="form-select form-select-sm"
|
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()}
|
|
||||||
>
|
>
|
||||||
<i className="bx bx-cloud-upload d-block bx-lg"></i>
|
{isLoading && (
|
||||||
<span className="text-muted d-block">
|
<option disabled value="">
|
||||||
Click to select or click here to browse
|
Loading...
|
||||||
</span>
|
</option>
|
||||||
<small className="text-muted">(PDF, JPG, PNG, max 25MB)</small>
|
)}
|
||||||
|
{!isLoading && <option value="">Select Category</option>}
|
||||||
<input
|
{DocumentCategories?.map((type) => (
|
||||||
type="file"
|
<option key={type.id} value={type.id}>
|
||||||
id="attachment"
|
{type.name}
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
</option>
|
||||||
style={{ display: "none" }}
|
))}
|
||||||
onChange={(e) => {
|
</select>
|
||||||
onFileChange(e);
|
{errors.documentCategoryId && (
|
||||||
e.target.value = ""; // reset input
|
<div className="danger-text">
|
||||||
}}
|
{errors.documentCategoryId.message}
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Type */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="documentTypeId">Document Type</Label>
|
||||||
<textarea
|
<select
|
||||||
rows="2"
|
{...register("documentTypeId")}
|
||||||
className="form-control"
|
className="form-select form-select-sm"
|
||||||
{...register("description")}
|
>
|
||||||
></textarea>
|
{isTypeLoading && (
|
||||||
{errors.description && (
|
<option disabled value="">
|
||||||
<div className="danger-text">{errors.description.message}</div>
|
Loading...
|
||||||
)}
|
</option>
|
||||||
</div>
|
)}
|
||||||
|
{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 */}
|
{/* Document ID */}
|
||||||
<div className="d-flex justify-content-center gap-3">
|
<div className="mb-2">
|
||||||
<button type="submit" className="btn btn-primary btn-sm">
|
<Label
|
||||||
Submit
|
htmlFor="documentId"
|
||||||
</button>
|
required={selectedType?.isMandatory ?? false}
|
||||||
<button type="reset" className="btn btn-secondary btn-sm">
|
>
|
||||||
Cancel
|
Document ID
|
||||||
</button>
|
</Label>
|
||||||
</div>
|
<input
|
||||||
</form>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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();
|
||||||
|
|
||||||
useEffect(() => {
|
// Keep form value synced
|
||||||
if (
|
|
||||||
Array.isArray(watchedTags) &&
|
|
||||||
JSON.stringify(tags) !== JSON.stringify(watchedTags)
|
|
||||||
) {
|
|
||||||
setTags(watchedTags);
|
|
||||||
}
|
|
||||||
}, [JSON.stringify(watchedTags)]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (input.trim() === "") {
|
setValue(name, tags, { shouldValidate: true });
|
||||||
setSuggestions([]);
|
}, [tags, name, setValue]);
|
||||||
} else {
|
|
||||||
const filtered = options?.filter(
|
|
||||||
(opt) =>
|
|
||||||
opt?.name?.toLowerCase()?.includes(input.toLowerCase()) &&
|
|
||||||
!tags?.some((tag) => tag.name === opt.name)
|
|
||||||
);
|
|
||||||
setSuggestions(filtered);
|
|
||||||
}
|
|
||||||
}, [input, options, tags]);
|
|
||||||
|
|
||||||
const addTag = async (tagObj) => {
|
const handleKeyDown = (e) => {
|
||||||
if (!tags.some((tag) => tag.name === tagObj.name)) {
|
if (e.key === "Enter" && input.trim()) {
|
||||||
const cleanedTag = {
|
e.preventDefault();
|
||||||
id: tagObj.id ?? null,
|
if (!tags.some((t) => t.name === input.trim())) {
|
||||||
name: tagObj.name,
|
setTags((prev) => [...prev, { name: input.trim(), isActive: true }]);
|
||||||
};
|
}
|
||||||
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",
|
||||||
@ -156,12 +108,14 @@ useEffect(() => {
|
|||||||
|
|
||||||
{suggestions.length > 0 && (
|
{suggestions.length > 0 && (
|
||||||
<ul
|
<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={{
|
style={{
|
||||||
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) => (
|
||||||
@ -169,8 +123,7 @@ useEffect(() => {
|
|||||||
key={i}
|
key={i}
|
||||||
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
22
src/hooks/useDocument.js
Normal 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"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
@ -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}`),
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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 [];
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user