marco.pms.web/src/components/Documents/ManageDocument.jsx

429 lines
13 KiB
JavaScript

import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect, useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { defaultDocumentValues, DocumentPayloadSchema } from "./DocumentSchema";
import Label from "../common/Label";
import {
useDocumentCategories,
useDocumentTypes,
} from "../../hooks/masterHook/useMaster";
import TagInput from "../common/TagInput";
import {
useDocumentDetails,
useDocumentTags,
useUpdateDocument,
useUploadDocument,
} from "../../hooks/useDocument";
import showToast from "../../services/toastService";
import { useDocumentContext } from "./Documents";
import { isPending } from "@reduxjs/toolkit";
const toBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (err) => reject(err);
});
const MergedTagsWithExistenStatus = (formTags = [], originalTags = []) => {
const tagMap = new Map();
const safeFormTags = Array.isArray(formTags) ? formTags : [];
const safeOriginalTags = Array.isArray(originalTags) ? originalTags : [];
safeOriginalTags.forEach(tag => {
if (tag?.name) {
tagMap.set(tag.name, { ...tag, isActive: tag.isActive ?? true });
}
});
safeFormTags.forEach(tag => {
if (tag?.name) {
tagMap.set(tag.name, { ...tag, isActive: true });
}
});
safeOriginalTags.forEach(tag => {
if (tag?.name && !safeFormTags.some(t => t.name === tag.name)) {
tagMap.set(tag.name, { ...tag, isActive: false });
}
});
return Array.from(tagMap.values());
};
const ManageDocument = ({ closeModal, Document_Entity, Entity }) => {
const { ManageDoc } = useDocumentContext();
const isUpdateForm = Boolean(ManageDoc?.document);
const [selectedType, setSelectedType] = useState(null);
const [selectedCategory, setSelectedCategory] = useState(null);
const [schema, setSchema] = useState(() =>
DocumentPayloadSchema({ isUpdateForm })
);
const methods = useForm({
resolver: zodResolver(schema),
defaultValues: defaultDocumentValues,
});
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = methods;
const { mutate: UploadDocument, isPending: isUploading } = useUploadDocument(
() => {
showToast("Document Uploaded Successfully", "success");
closeModal();
}
);
const { mutate: UpdateDocument, isPending: isUpdating } = useUpdateDocument(
() => {
showToast("Document Updated Successfully", "success");
closeModal();
}
);
const onSubmit = (data) => {
const normalizeAttachment = (attachment) => {
if (!attachment) return null;
return {
...attachment,
fileSize: Math.ceil(attachment.fileSize / 1024),
};
};
const payload = {
...data,
attachment: normalizeAttachment(data.attachment),
};
if (ManageDoc?.document) {
const DocumentPayload = {
...payload,
id: DocData.id,
tags: MergedTagsWithExistenStatus(data?.tags, DocData?.tags),
};
UpdateDocument({ documentId: DocData?.id, DocumentPayload });
} else {
const DocumentPayload = { ...payload, entityId: Entity };
UploadDocument(DocumentPayload);
}
};
const {
data: DocData,
isLoading: isDocLoading,
isError: isDocError,
DocError,
} = useDocumentDetails(ManageDoc?.document);
const file = watch("attachment");
const documentTypeId = watch("documentTypeId");
// This hooks calling api base Entity(Employee) and Category
const { DocumentCategories, isLoading } =
useDocumentCategories(Document_Entity);
const categoryId = watch("documentCategoryId");
const { DocumentTypes, isLoading: isTypeLoading } = useDocumentTypes(
categoryId || null
);
const {data:DocumentTags} = useDocumentTags()
// Update schema whenever document type changes
useEffect(() => {
if (!documentTypeId) return;
const type = DocumentTypes?.find(
(t) => String(t.id) === String(documentTypeId)
);
if (!type) return;
setSelectedType(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,
isUpdateForm,
});
setSchema(() => newSchema);
methods.reset(methods.getValues(), { keepValues: true });
methods.formState.errors; // triggers revalidation
}, [documentTypeId, DocumentTypes, isUpdateForm]);
// File Upload
const onFileChange = async (e) => {
const uploaded = e.target.files[0];
if (!uploaded) return;
const base64Data = await toBase64(uploaded);
const parsedFile = {
fileName: uploaded.name,
base64Data,
contentType: uploaded.type,
fileSize: uploaded.size,
description: "",
isActive: true,
};
setValue("attachment", parsedFile, {
shouldDirty: true,
shouldValidate: true,
});
};
const removeFile = () => {
setValue("attachment", null, {
shouldDirty: true,
shouldValidate: true,
});
};
// 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(",") || "";
useEffect(() => {
if (DocData) {
reset({
...defaultDocumentValues,
name: DocData?.name ?? "",
documentCategoryId: DocData?.documentType?.documentCategory?.id
? String(DocData.documentType.documentCategory.id)
: "",
documentTypeId: DocData?.documentType?.id
? String(DocData.documentType.id)
: "",
documentId: DocData?.documentId ?? "",
description: DocData?.description ?? "",
attachment: DocData?.attachment ?? null,
tags: DocData?.tags ?? [],
});
}
}, [DocData, reset]);
if (isDocLoading) return <div>Loading...</div>;
if (isDocError) return <div>{DocError.message}</div>;
const isPending = isUploading || isUpdating;
return (
<div className="p-2">
<p className="fw-bold fs-6">Upload New Document</p>
<FormProvider key={documentTypeId} {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="text-start">
{/* 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>
)}
{!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>
{/* Type */}
{categoryId && (
<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 htmlFor="attachment" required>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.." options={DocumentTags} />
{errors.tags && (
<small className="danger-text">{errors.tags.message}</small>
)}
</div>
{/* Description */}
<div className="mb-2">
<Label htmlFor="description" required>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-end gap-3 mt-4">
<button
type="reset"
className="btn btn-label-secondary btn-sm"
disabled={isPending}
onClick={closeModal}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={isPending}
>
{isPending ? "Please Wait..." : " Submit"}
</button>
</div>
</form>
</FormProvider>
</div>
);
};
export default ManageDocument;