596 lines
19 KiB
TypeScript
596 lines
19 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
|
// import { useNavigate } from "react-router-dom";
|
|
import { useForm, Controller } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import * as z from "zod";
|
|
import { Layout } from "@/components/layout/Layout";
|
|
import {
|
|
DataTable,
|
|
FormField,
|
|
FormSelect,
|
|
FormTextArea,
|
|
PrimaryButton,
|
|
Modal,
|
|
ActionDropdown,
|
|
DeleteConfirmationModal,
|
|
type Column,
|
|
} from "@/components/shared";
|
|
import { documentService } from "@/services/document-service";
|
|
import type { DocumentCategory } from "@/types/document";
|
|
import { showToast } from "@/utils/toast";
|
|
import { Plus, Eye, Edit, Trash2 } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const categorySchema = z.object({
|
|
name: z.string().min(1, "Category name is required"),
|
|
code: z
|
|
.string()
|
|
.min(1, "Code is required")
|
|
.max(10, "Code must be 10 characters or less"),
|
|
description: z.string().optional(),
|
|
reviewFrequency: z.string().min(1, "Review frequency is required"),
|
|
retentionYears: z.string().min(1, "Retention years is required"),
|
|
requiresTraining: z.boolean().optional(),
|
|
parentId: z.string().optional(),
|
|
});
|
|
|
|
type CategoryFormData = z.infer<typeof categorySchema>;
|
|
|
|
const DocumentCategories = (): ReactElement => {
|
|
// const navigate = useNavigate();
|
|
const [categories, setCategories] = useState<DocumentCategory[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
|
const [editingCategory, setEditingCategory] =
|
|
useState<DocumentCategory | null>(null);
|
|
const [viewingCategory, setViewingCategory] =
|
|
useState<DocumentCategory | null>(null);
|
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
const [categoryToDelete, setCategoryToDelete] =
|
|
useState<DocumentCategory | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
control,
|
|
reset,
|
|
setValue,
|
|
formState: { errors },
|
|
} = useForm<CategoryFormData>({
|
|
resolver: zodResolver(categorySchema),
|
|
defaultValues: {
|
|
name: "",
|
|
code: "",
|
|
description: "",
|
|
reviewFrequency: "12",
|
|
retentionYears: "7",
|
|
requiresTraining: false,
|
|
parentId: "",
|
|
},
|
|
});
|
|
|
|
const loadCategories = async (): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await documentService.getCategories();
|
|
setCategories(response.data || []);
|
|
} catch (err: any) {
|
|
setError(
|
|
err?.response?.data?.error?.message || "Failed to load categories",
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadCategories();
|
|
}, []);
|
|
|
|
const handleEdit = (category: DocumentCategory) => {
|
|
setEditingCategory(category);
|
|
setValue("name", category.name);
|
|
setValue("code", category.code);
|
|
setValue("description", category.description || "");
|
|
setValue(
|
|
"reviewFrequency",
|
|
category.review_frequency_months?.toString() || "12",
|
|
);
|
|
setValue("retentionYears", category.retention_years?.toString() || "7");
|
|
setValue("requiresTraining", !!category.requires_training);
|
|
setValue("parentId", category.parent_id || "");
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleView = (category: DocumentCategory) => {
|
|
setViewingCategory(category);
|
|
setIsViewModalOpen(true);
|
|
};
|
|
|
|
const handleDeleteClick = (category: DocumentCategory) => {
|
|
setCategoryToDelete(category);
|
|
setIsDeleteModalOpen(true);
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (!categoryToDelete) return;
|
|
try {
|
|
setIsDeleting(true);
|
|
await documentService.deleteCategory(categoryToDelete.id);
|
|
showToast.success("Category deleted successfully");
|
|
setIsDeleteModalOpen(false);
|
|
setCategoryToDelete(null);
|
|
await loadCategories();
|
|
} catch (err: any) {
|
|
showToast.error(
|
|
err?.response?.data?.error?.message || "Failed to delete category",
|
|
);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const columns: Column<DocumentCategory>[] = useMemo(
|
|
() => [
|
|
{
|
|
key: "name",
|
|
label: "Name",
|
|
render: (cat) => (
|
|
<span className="text-[#0f1724] font-medium">{cat.name}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "code",
|
|
label: "Code",
|
|
render: (cat) => (
|
|
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-0.5 text-xs font-bold text-blue-600 border border-blue-100">
|
|
{cat.code}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "review_frequency_months",
|
|
label: "Review Frequency",
|
|
render: (category) =>
|
|
category.review_frequency_months
|
|
? `${category.review_frequency_months} months`
|
|
: "-",
|
|
},
|
|
{
|
|
key: "parent_id",
|
|
label: "Parent Category",
|
|
render: (category) => {
|
|
const parent = categories.find((c) => c.id === category.parent_id);
|
|
return (
|
|
<span className="text-[#6b7280]">{parent ? parent.name : "-"}</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: "retention_years",
|
|
label: "Retention",
|
|
render: (category) =>
|
|
category.retention_years ? `${category.retention_years} years` : "-",
|
|
},
|
|
{
|
|
key: "requires_training",
|
|
label: "Requires Training",
|
|
render: (category) => (
|
|
<div className="flex items-center">
|
|
<div
|
|
className={cn(
|
|
"w-10 h-5 rounded-full relative transition-colors duration-200 pointer-events-none",
|
|
category.requires_training ? "bg-[#084cc8]" : "bg-gray-200",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
|
|
category.requires_training && "translate-x-5",
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "description",
|
|
label: "Description",
|
|
render: (category) => (
|
|
<span className="text-gray-500 line-clamp-1 max-w-[300px]">
|
|
{category.description || "-"}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
label: "Actions",
|
|
align: "right",
|
|
render: (category) => (
|
|
<ActionDropdown
|
|
actions={[
|
|
{
|
|
label: "View Details",
|
|
onClick: () => handleView(category),
|
|
icon: <Eye className="w-4 h-4" />,
|
|
},
|
|
{
|
|
label: "Edit Category",
|
|
onClick: () => handleEdit(category),
|
|
icon: <Edit className="w-4 h-4" />,
|
|
},
|
|
{
|
|
label: "Delete",
|
|
onClick: () => handleDeleteClick(category),
|
|
icon: <Trash2 className="w-4 h-4" />,
|
|
variant: "danger",
|
|
},
|
|
]}
|
|
/>
|
|
),
|
|
},
|
|
],
|
|
[categories],
|
|
);
|
|
|
|
const onFormSubmit = async (data: CategoryFormData): Promise<void> => {
|
|
try {
|
|
setIsSubmitting(true);
|
|
const payload = {
|
|
name: data.name.trim(),
|
|
code: data.code.trim().toUpperCase(),
|
|
description: data.description?.trim() || undefined,
|
|
review_frequency_months: parseInt(data.reviewFrequency),
|
|
retention_years: parseInt(data.retentionYears),
|
|
requires_training: data.requiresTraining,
|
|
parent_id: data.parentId || null,
|
|
};
|
|
|
|
if (editingCategory) {
|
|
await documentService.updateCategory(editingCategory.id, payload);
|
|
showToast.success("Category updated");
|
|
} else {
|
|
await documentService.createCategory(payload);
|
|
showToast.success("Category created");
|
|
}
|
|
|
|
setIsModalOpen(false);
|
|
reset();
|
|
setEditingCategory(null);
|
|
await loadCategories();
|
|
} catch (err: any) {
|
|
showToast.error(
|
|
err?.response?.data?.error?.message || "Failed to process category",
|
|
);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Document Service"
|
|
pageHeader={{
|
|
title: "Document Categories",
|
|
description:
|
|
"View and manage the document categories and their retention policies.",
|
|
action: (
|
|
<PrimaryButton
|
|
onClick={() => {
|
|
setEditingCategory(null);
|
|
reset();
|
|
setIsModalOpen(true);
|
|
}}
|
|
>
|
|
<Plus className="w-4 h-4 mr-1.5" />
|
|
Create Category
|
|
</PrimaryButton>
|
|
),
|
|
}}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
|
<DataTable
|
|
data={categories}
|
|
columns={columns}
|
|
keyExtractor={(category) => category.id}
|
|
emptyMessage="No categories found"
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={isModalOpen}
|
|
onClose={() => {
|
|
setIsModalOpen(false);
|
|
setEditingCategory(null);
|
|
}}
|
|
title={
|
|
editingCategory
|
|
? "Update Document Category"
|
|
: "Create Document Category"
|
|
}
|
|
maxWidth="lg"
|
|
>
|
|
<form onSubmit={handleSubmit(onFormSubmit)} className="p-6 space-y-5">
|
|
<p className="text-sm text-gray-500 -mt-2">
|
|
Add a document category with review, retention, and training
|
|
requirements.
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<FormField
|
|
label="Category Name"
|
|
required
|
|
placeholder="e.g. Standard Operating Procedures"
|
|
error={errors.name?.message}
|
|
{...register("name")}
|
|
/>
|
|
|
|
<FormField
|
|
label="Code"
|
|
required
|
|
placeholder="e.g. SOP"
|
|
// description="Short code (e.g. INT, EXT, AUD). Max 100 characters."
|
|
error={errors.code?.message}
|
|
{...register("code")}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
<Controller
|
|
name="reviewFrequency"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<FormSelect
|
|
label="Review Frequency (months)"
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
options={[
|
|
{ value: "1", label: "1" },
|
|
{ value: "3", label: "3" },
|
|
{ value: "6", label: "6" },
|
|
{ value: "12", label: "12" },
|
|
{ value: "24", label: "24" },
|
|
{ value: "36", label: "36" },
|
|
{ value: "60", label: "60" },
|
|
]}
|
|
placeholder="Select months"
|
|
/>
|
|
)}
|
|
/>
|
|
<Controller
|
|
name="retentionYears"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<FormSelect
|
|
label="Retention (years)"
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
options={[
|
|
{ value: "1", label: "1" },
|
|
{ value: "3", label: "3" },
|
|
{ value: "5", label: "5" },
|
|
{ value: "7", label: "7" },
|
|
{ value: "10", label: "10" },
|
|
{ value: "25", label: "25" },
|
|
{ value: "99", label: "Permanent" },
|
|
]}
|
|
placeholder="Select years"
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<Controller
|
|
name="parentId"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<FormSelect
|
|
label="Parent Category (Optional)"
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
options={[
|
|
{ value: "", label: "No Parent (Root)" },
|
|
...categories
|
|
.filter(
|
|
(c) => !editingCategory || c.id !== editingCategory.id,
|
|
)
|
|
.map((c) => ({ value: c.id, label: c.name })),
|
|
]}
|
|
placeholder="Select parent category"
|
|
/>
|
|
)}
|
|
/>
|
|
|
|
<FormTextArea
|
|
label="Description"
|
|
placeholder="Description of this user category."
|
|
error={errors.description?.message}
|
|
rows={3}
|
|
{...register("description")}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between py-4 border-t border-gray-100 mt-2">
|
|
<div>
|
|
<label className="text-sm font-bold text-[#0f1724]">
|
|
Requires Training
|
|
</label>
|
|
<p className="text-[11px] text-gray-500">
|
|
Users must acknowledge documents in this category
|
|
</p>
|
|
</div>
|
|
<Controller
|
|
name="requiresTraining"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<div
|
|
className={cn(
|
|
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
|
|
field.value ? "bg-[#084cc8]" : "bg-gray-200",
|
|
)}
|
|
onClick={() => field.onChange(!field.value)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
|
|
field.value && "translate-x-5",
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsModalOpen(false);
|
|
setEditingCategory(null);
|
|
}}
|
|
className="px-6 py-2 border border-gray-200 rounded-md text-sm font-bold text-[#0f1724] hover:bg-gray-50 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<PrimaryButton
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="px-6"
|
|
>
|
|
{isSubmitting
|
|
? "Processing..."
|
|
: editingCategory
|
|
? "Update Category"
|
|
: "Create Category"}
|
|
</PrimaryButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* View Modal */}
|
|
<Modal
|
|
isOpen={isViewModalOpen}
|
|
onClose={() => {
|
|
setIsViewModalOpen(false);
|
|
setViewingCategory(null);
|
|
}}
|
|
title="Document Category Details"
|
|
maxWidth="lg"
|
|
>
|
|
<div className="p-6 space-y-6">
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
|
Name
|
|
</label>
|
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
|
{viewingCategory?.name}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
|
Code
|
|
</label>
|
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
|
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded border border-blue-100">
|
|
{viewingCategory?.code}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
|
Review Frequency
|
|
</label>
|
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
|
{viewingCategory?.review_frequency_months} months
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
|
Retention
|
|
</label>
|
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
|
{viewingCategory?.retention_years} years
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
|
Parent Category
|
|
</label>
|
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
|
{categories.find((c) => c.id === viewingCategory?.parent_id)
|
|
?.name || "None (Root Category)"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
|
Description
|
|
</label>
|
|
<p className="text-sm text-gray-600 mt-1 leading-relaxed">
|
|
{viewingCategory?.description || "No description provided."}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 p-4 rounded-lg flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-bold text-[#0f1724]">
|
|
Requires Training
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
Training acknowledgement is{" "}
|
|
{viewingCategory?.requires_training ? "enabled" : "disabled"}{" "}
|
|
for this category.
|
|
</p>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"w-10 h-5 rounded-full relative",
|
|
viewingCategory?.requires_training
|
|
? "bg-[#084cc8]"
|
|
: "bg-gray-200",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full",
|
|
viewingCategory?.requires_training && "translate-x-5",
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end pt-4 border-t border-gray-100">
|
|
<button
|
|
onClick={() => setIsViewModalOpen(false)}
|
|
className="px-6 py-2 bg-[#0f1724] rounded-md text-sm font-bold text-white hover:bg-black transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<DeleteConfirmationModal
|
|
isOpen={isDeleteModalOpen}
|
|
onClose={() => {
|
|
setIsDeleteModalOpen(false);
|
|
setCategoryToDelete(null);
|
|
}}
|
|
onConfirm={handleConfirmDelete}
|
|
title="Delete Document Category"
|
|
message="Are you sure you want to delete this category? This action cannot be undone and will fail if the category is currently associated with documents."
|
|
itemName={categoryToDelete?.name || ""}
|
|
isLoading={isDeleting}
|
|
/>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default DocumentCategories;
|