feat: enhance error handling across UI components and services to display server-provided error messages and update SMTP configuration deletion logic
This commit is contained in:
parent
87db482697
commit
1b97371f73
@ -117,34 +117,6 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||||
{
|
|
||||||
icon: Bot,
|
|
||||||
label: "AI Services",
|
|
||||||
isGroup: true,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: "Completion History",
|
|
||||||
path: "/tenant/ai/completions",
|
|
||||||
requiredPermission: { resource: "ai" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Prompt Management",
|
|
||||||
path: "/tenant/ai/prompts",
|
|
||||||
requiredPermission: { resource: "ai" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Tenant Config",
|
|
||||||
path: "/tenant/ai/config",
|
|
||||||
requiredPermission: { resource: "ai" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Knowledge (RAG)",
|
|
||||||
path: "/tenant/ai/knowledge",
|
|
||||||
requiredPermission: { resource: "ai" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
requiredPermission: { resource: "ai" },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: Paperclip,
|
icon: Paperclip,
|
||||||
label: "File Attachments",
|
label: "File Attachments",
|
||||||
@ -209,6 +181,34 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
|||||||
],
|
],
|
||||||
requiredPermission: { resource: "document" },
|
requiredPermission: { resource: "document" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Bot,
|
||||||
|
label: "AI Services",
|
||||||
|
isGroup: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Completion History",
|
||||||
|
path: "/tenant/ai/completions",
|
||||||
|
requiredPermission: { resource: "ai" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Prompt Management",
|
||||||
|
path: "/tenant/ai/prompts",
|
||||||
|
requiredPermission: { resource: "ai" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tenant Config",
|
||||||
|
path: "/tenant/ai/config",
|
||||||
|
requiredPermission: { resource: "ai" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Knowledge (RAG)",
|
||||||
|
path: "/tenant/ai/knowledge",
|
||||||
|
requiredPermission: { resource: "ai" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requiredPermission: { resource: "ai" },
|
||||||
|
},
|
||||||
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -248,7 +248,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
{
|
{
|
||||||
label: "Failed Emails",
|
label: "Failed Emails",
|
||||||
path: "/tenant/settings/failed-emails",
|
path: "/tenant/settings/failed-emails",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
requiredPermission: { resource: "tenants" },
|
requiredPermission: { resource: "tenants" },
|
||||||
},
|
},
|
||||||
@ -624,7 +624,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
<div
|
<div
|
||||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||||
style={{
|
style={{
|
||||||
color: accentColor
|
color: accentColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{roleName}
|
{roleName}
|
||||||
@ -711,7 +711,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
<div
|
<div
|
||||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||||
style={{
|
style={{
|
||||||
color: accentColor
|
color: accentColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{roleName}
|
{roleName}
|
||||||
@ -728,7 +728,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||||||
<MenuSection title="Platform" items={platformMenu} />
|
<MenuSection title="Platform" items={platformMenu} />
|
||||||
)}
|
)}
|
||||||
{platformServiceMenu.length > 0 && (
|
{platformServiceMenu.length > 0 && (
|
||||||
<MenuSection title="Platform Services" items={platformServiceMenu} />
|
<MenuSection
|
||||||
|
title="Platform Services"
|
||||||
|
items={platformServiceMenu}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System Menu */}
|
{/* System Menu */}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const FailedEmailsTable: React.FC = () => {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [limit, setLimit] = useState(50);
|
const [limit, setLimit] = useState(50);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [selectedEmail, setSelectedEmail] = useState<FailedEmail | null>(null);
|
const [selectedEmail, setSelectedEmail] = useState<FailedEmail | null>(null);
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
@ -30,9 +31,9 @@ export const FailedEmailsTable: React.FC = () => {
|
|||||||
setTotal(res.total || 0);
|
setTotal(res.total || 0);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error('Error fetching failed emails', {
|
setError(
|
||||||
description: error.message
|
error?.response?.data?.error?.message || "Failed to load failed emails",
|
||||||
});
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -181,6 +182,7 @@ export const FailedEmailsTable: React.FC = () => {
|
|||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
emptyMessage="No failed emails found"
|
emptyMessage="No failed emails found"
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{total > limit && (
|
{total > limit && (
|
||||||
|
|||||||
@ -249,7 +249,7 @@ export const MultiselectPaginatedSelect = ({
|
|||||||
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{required && <span className="text-[#e02424] text-[8px]">*</span>}
|
{required && <span className="text-[#e02424]">*</span>}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -257,8 +257,8 @@ export const SupplierModal = ({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showToast.error(
|
showToast.error(
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? "Failed to create supplier"
|
? error?.response?.data?.error?.message || "Failed to create supplier"
|
||||||
: "Failed to update supplier",
|
: error?.response?.data?.error?.message || "Failed to update supplier",
|
||||||
error?.message,
|
error?.message,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
import { Plus, Building2 } from "lucide-react";
|
import { Plus, Building2 } from "lucide-react";
|
||||||
import { supplierService } from "@/services/supplier-service";
|
import { supplierService } from "@/services/supplier-service";
|
||||||
import type { Supplier } from "@/types/supplier";
|
import type { Supplier } from "@/types/supplier";
|
||||||
import { formatDate } from "@/utils/format-date";
|
// import { formatDate } from "@/utils/format-date";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
|
||||||
interface SuppliersTableProps {
|
interface SuppliersTableProps {
|
||||||
@ -194,26 +194,26 @@ export const SuppliersTable = ({
|
|||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: "location",
|
// key: "location",
|
||||||
label: "Location",
|
// label: "Location",
|
||||||
render: (supplier) => (
|
// render: (supplier) => (
|
||||||
<span className="text-sm text-[#6b7280]">
|
// <span className="text-sm text-[#6b7280]">
|
||||||
{supplier.address?.city
|
// {supplier.address?.city
|
||||||
? `${supplier.address.city}, ${supplier.address.country}`
|
// ? `${supplier.address.city}, ${supplier.address.country}`
|
||||||
: supplier.address?.country || "-"}
|
// : supplier.address?.country || "-"}
|
||||||
</span>
|
// </span>
|
||||||
),
|
// ),
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
key: "created_at",
|
// key: "created_at",
|
||||||
label: "Dated",
|
// label: "Dated",
|
||||||
render: (supplier) => (
|
// render: (supplier) => (
|
||||||
<span className="text-sm text-[#6b7280]">
|
// <span className="text-sm text-[#6b7280]">
|
||||||
{formatDate(supplier.created_at)}
|
// {formatDate(supplier.created_at)}
|
||||||
</span>
|
// </span>
|
||||||
),
|
// ),
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
label: "Actions",
|
label: "Actions",
|
||||||
|
|||||||
@ -150,7 +150,7 @@ export const ViewSupplierModal = ({
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Supplier Profile"
|
title="Supplier Profile"
|
||||||
maxWidth="2xl"
|
maxWidth="xl"
|
||||||
footer={
|
footer={
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -65,7 +65,7 @@ const SmtpConfigPage = () => {
|
|||||||
if (!selectedConfig?.id) return;
|
if (!selectedConfig?.id) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
await smtpConfigService.deleteConfig(selectedConfig.id);
|
await smtpConfigService.deleteConfig(selectedConfig.id, selectedConfig.tenant_id);
|
||||||
showToast.success('Configuration deleted');
|
showToast.success('Configuration deleted');
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
fetchConfigs();
|
fetchConfigs();
|
||||||
|
|||||||
@ -85,8 +85,10 @@ const CreateDocument = (): ReactElement => {
|
|||||||
setTypes(typesRes.data || []);
|
setTypes(typesRes.data || []);
|
||||||
setCategories(categoriesRes.data || []);
|
setCategories(categoriesRes.data || []);
|
||||||
setModules(modulesRes.data || []);
|
setModules(modulesRes.data || []);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
showToast.error("Failed to load document metadata");
|
showToast.error(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load document metadata"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadLookups();
|
void loadLookups();
|
||||||
@ -98,8 +100,10 @@ const CreateDocument = (): ReactElement => {
|
|||||||
try {
|
try {
|
||||||
const res = await documentService.listFiles();
|
const res = await documentService.listFiles();
|
||||||
setFiles(res.data || []);
|
setFiles(res.data || []);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
showToast.error("Failed to load files");
|
showToast.error(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load files"
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFiles(false);
|
setIsLoadingFiles(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
DeleteConfirmationModal,
|
DeleteConfirmationModal,
|
||||||
type Column
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { documentService } from "@/services/document-service";
|
import { documentService } from "@/services/document-service";
|
||||||
import type { DocumentCategory } from "@/types/document";
|
import type { DocumentCategory } from "@/types/document";
|
||||||
@ -23,7 +23,10 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
const categorySchema = z.object({
|
const categorySchema = z.object({
|
||||||
name: z.string().min(1, "Category name is required"),
|
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"),
|
code: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Code is required")
|
||||||
|
.max(10, "Code must be 10 characters or less"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
reviewFrequency: z.string().min(1, "Review frequency is required"),
|
reviewFrequency: z.string().min(1, "Review frequency is required"),
|
||||||
retentionYears: z.string().min(1, "Retention years is required"),
|
retentionYears: z.string().min(1, "Retention years is required"),
|
||||||
@ -40,11 +43,15 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<DocumentCategory | null>(null);
|
const [editingCategory, setEditingCategory] =
|
||||||
const [viewingCategory, setViewingCategory] = useState<DocumentCategory | null>(null);
|
useState<DocumentCategory | null>(null);
|
||||||
|
const [viewingCategory, setViewingCategory] =
|
||||||
|
useState<DocumentCategory | null>(null);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [categoryToDelete, setCategoryToDelete] = useState<DocumentCategory | null>(null);
|
const [categoryToDelete, setCategoryToDelete] =
|
||||||
|
useState<DocumentCategory | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -72,7 +79,7 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
const response = await documentService.getCategories();
|
const response = await documentService.getCategories();
|
||||||
setCategories(response.data || []);
|
setCategories(response.data || []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(
|
setError(
|
||||||
err?.response?.data?.error?.message || "Failed to load categories",
|
err?.response?.data?.error?.message || "Failed to load categories",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -89,7 +96,10 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
setValue("name", category.name);
|
setValue("name", category.name);
|
||||||
setValue("code", category.code);
|
setValue("code", category.code);
|
||||||
setValue("description", category.description || "");
|
setValue("description", category.description || "");
|
||||||
setValue("reviewFrequency", category.review_frequency_months?.toString() || "12");
|
setValue(
|
||||||
|
"reviewFrequency",
|
||||||
|
category.review_frequency_months?.toString() || "12",
|
||||||
|
);
|
||||||
setValue("retentionYears", category.retention_years?.toString() || "7");
|
setValue("retentionYears", category.retention_years?.toString() || "7");
|
||||||
setValue("requiresTraining", !!category.requires_training);
|
setValue("requiresTraining", !!category.requires_training);
|
||||||
setValue("parentId", category.parent_id || "");
|
setValue("parentId", category.parent_id || "");
|
||||||
@ -129,53 +139,72 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
key: "name",
|
key: "name",
|
||||||
label: "Name",
|
label: "Name",
|
||||||
render: (cat) => <span className="text-[#0f1724] font-medium">{cat.name}</span>
|
render: (cat) => (
|
||||||
|
<span className="text-[#0f1724] font-medium">{cat.name}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "code",
|
key: "code",
|
||||||
label: "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>
|
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",
|
key: "review_frequency_months",
|
||||||
label: "Review Frequency",
|
label: "Review Frequency",
|
||||||
render: (category) =>
|
render: (category) =>
|
||||||
category.review_frequency_months ? `${category.review_frequency_months} months` : "-",
|
category.review_frequency_months
|
||||||
|
? `${category.review_frequency_months} months`
|
||||||
|
: "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "parent_id",
|
key: "parent_id",
|
||||||
label: "Parent Category",
|
label: "Parent Category",
|
||||||
render: (category) => {
|
render: (category) => {
|
||||||
const parent = categories.find(c => c.id === category.parent_id);
|
const parent = categories.find((c) => c.id === category.parent_id);
|
||||||
return <span className="text-[#6b7280]">{parent ? parent.name : "-"}</span>;
|
return (
|
||||||
}
|
<span className="text-[#6b7280]">{parent ? parent.name : "-"}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "retention_years",
|
key: "retention_years",
|
||||||
label: "Retention",
|
label: "Retention",
|
||||||
render: (category) => category.retention_years ? `${category.retention_years} years` : "-",
|
render: (category) =>
|
||||||
|
category.retention_years ? `${category.retention_years} years` : "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "requires_training",
|
key: "requires_training",
|
||||||
label: "Requires Training",
|
label: "Requires Training",
|
||||||
render: (category) => (
|
render: (category) => (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className={cn(
|
<div
|
||||||
"w-10 h-5 rounded-full relative transition-colors duration-200 pointer-events-none",
|
className={cn(
|
||||||
category.requires_training ? "bg-[#084cc8]" : "bg-gray-200"
|
"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
|
||||||
)} />
|
className={cn(
|
||||||
</div>
|
"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>
|
||||||
)
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "description",
|
key: "description",
|
||||||
label: "Description",
|
label: "Description",
|
||||||
render: (category) => <span className="text-gray-500 line-clamp-1 max-w-[300px]">{category.description || "-"}</span>,
|
render: (category) => (
|
||||||
|
<span className="text-gray-500 line-clamp-1 max-w-[300px]">
|
||||||
|
{category.description || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "actions",
|
key: "actions",
|
||||||
@ -184,9 +213,22 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
render: (category) => (
|
render: (category) => (
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
actions={[
|
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: "View Details",
|
||||||
{ label: "Delete", onClick: () => handleDeleteClick(category), icon: <Trash2 className="w-4 h-4" />, variant: "danger" },
|
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",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -234,9 +276,16 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
currentPage="Document Service"
|
currentPage="Document Service"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: "Document Categories",
|
title: "Document Categories",
|
||||||
description: "View and manage the document categories and their retention policies.",
|
description:
|
||||||
|
"View and manage the document categories and their retention policies.",
|
||||||
action: (
|
action: (
|
||||||
<PrimaryButton onClick={() => { setEditingCategory(null); reset(); setIsModalOpen(true); }}>
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCategory(null);
|
||||||
|
reset();
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Plus className="w-4 h-4 mr-1.5" />
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
Create Category
|
Create Category
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
@ -244,7 +293,6 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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">
|
<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
|
<DataTable
|
||||||
data={categories}
|
data={categories}
|
||||||
@ -252,213 +300,279 @@ const DocumentCategories = (): ReactElement => {
|
|||||||
keyExtractor={(category) => category.id}
|
keyExtractor={(category) => category.id}
|
||||||
emptyMessage="No categories found"
|
emptyMessage="No categories found"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => { setIsModalOpen(false); setEditingCategory(null); }}
|
onClose={() => {
|
||||||
title={editingCategory ? "Update Document Category" : "Create Document Category"}
|
setIsModalOpen(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
editingCategory
|
||||||
|
? "Update Document Category"
|
||||||
|
: "Create Document Category"
|
||||||
|
}
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)} className="p-6 space-y-5">
|
<form onSubmit={handleSubmit(onFormSubmit)} className="p-6 space-y-5">
|
||||||
<p className="text-sm text-gray-500 -mt-2">
|
<p className="text-sm text-gray-500 -mt-2">
|
||||||
Add a document category with review, retention, and training requirements.
|
Add a document category with review, retention, and training
|
||||||
</p>
|
requirements.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
label="Category Name"
|
label="Category Name"
|
||||||
required
|
required
|
||||||
placeholder="e.g. Standard Operating Procedures"
|
placeholder="e.g. Standard Operating Procedures"
|
||||||
error={errors.name?.message}
|
error={errors.name?.message}
|
||||||
{...register("name")}
|
{...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"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormTextArea
|
||||||
label="Code"
|
label="Description"
|
||||||
required
|
placeholder="Description of this user category."
|
||||||
placeholder="e.g. SOP"
|
error={errors.description?.message}
|
||||||
// description="Short code (e.g. INT, EXT, AUD). Max 100 characters."
|
rows={3}
|
||||||
error={errors.code?.message}
|
{...register("description")}
|
||||||
{...register("code")}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<div className="flex items-center justify-between py-4 border-t border-gray-100 mt-2">
|
||||||
<Controller
|
<div>
|
||||||
name="reviewFrequency"
|
<label className="text-sm font-bold text-[#0f1724]">
|
||||||
control={control}
|
Requires Training
|
||||||
render={({ field }) => (
|
</label>
|
||||||
<FormSelect
|
<p className="text-[11px] text-gray-500">
|
||||||
label="Review Frequency (months)"
|
Users must acknowledge documents in this category
|
||||||
value={field.value}
|
</p>
|
||||||
onValueChange={field.onChange}
|
</div>
|
||||||
options={[
|
<Controller
|
||||||
{ value: "1", label: "1" },
|
name="requiresTraining"
|
||||||
{ value: "3", label: "3" },
|
control={control}
|
||||||
{ value: "6", label: "6" },
|
render={({ field }) => (
|
||||||
{ value: "12", label: "12" },
|
<div
|
||||||
{ value: "24", label: "24" },
|
className={cn(
|
||||||
{ value: "36", label: "36" },
|
"w-10 h-5 rounded-full relative transition-colors duration-200 cursor-pointer",
|
||||||
{ value: "60", label: "60" },
|
field.value ? "bg-[#084cc8]" : "bg-gray-200",
|
||||||
]}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
onClick={() => field.onChange(!field.value)}
|
||||||
|
>
|
||||||
<FormTextArea
|
<div
|
||||||
label="Description"
|
className={cn(
|
||||||
placeholder="Description of this user category."
|
"absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200 shadow-sm",
|
||||||
error={errors.description?.message}
|
field.value && "translate-x-5",
|
||||||
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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setIsModalOpen(false); setEditingCategory(null); }}
|
onClick={() => {
|
||||||
className="px-6 py-2 border border-gray-200 rounded-md text-sm font-bold text-[#0f1724] hover:bg-gray-50 transition-colors"
|
setIsModalOpen(false);
|
||||||
>
|
setEditingCategory(null);
|
||||||
Cancel
|
}}
|
||||||
</button>
|
className="px-6 py-2 border border-gray-200 rounded-md text-sm font-bold text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||||
<PrimaryButton type="submit" disabled={isSubmitting} className="px-6">
|
>
|
||||||
{isSubmitting ? "Processing..." : editingCategory ? "Update Category" : "Create Category"}
|
Cancel
|
||||||
</PrimaryButton>
|
</button>
|
||||||
</div>
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? "Processing..."
|
||||||
|
: editingCategory
|
||||||
|
? "Update Category"
|
||||||
|
: "Create Category"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* View Modal */}
|
{/* View Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isViewModalOpen}
|
isOpen={isViewModalOpen}
|
||||||
onClose={() => { setIsViewModalOpen(false); setViewingCategory(null); }}
|
onClose={() => {
|
||||||
|
setIsViewModalOpen(false);
|
||||||
|
setViewingCategory(null);
|
||||||
|
}}
|
||||||
title="Document Category Details"
|
title="Document Category Details"
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
>
|
>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Name</label>
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
||||||
<p className="text-sm font-semibold text-[#0f1724] mt-1">{viewingCategory?.name}</p>
|
Name
|
||||||
</div>
|
</label>
|
||||||
<div>
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
||||||
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Code</label>
|
{viewingCategory?.name}
|
||||||
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
</p>
|
||||||
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">Description</label>
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
||||||
<p className="text-sm text-gray-600 mt-1 leading-relaxed">{viewingCategory?.description || "No description provided."}</p>
|
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>
|
||||||
<div className="bg-gray-50 p-4 rounded-lg flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
||||||
<p className="text-sm font-bold text-[#0f1724]">Requires Training</p>
|
Review Frequency
|
||||||
<p className="text-xs text-gray-500">Training acknowledgement is {viewingCategory?.requires_training ? "enabled" : "disabled"} for this category.</p>
|
</label>
|
||||||
</div>
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
||||||
<div className={cn(
|
{viewingCategory?.review_frequency_months} months
|
||||||
"w-10 h-5 rounded-full relative",
|
</p>
|
||||||
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>
|
||||||
<div className="flex justify-end pt-4 border-t border-gray-100">
|
<div>
|
||||||
<button
|
<label className="text-xs text-gray-500 uppercase font-bold tracking-wider">
|
||||||
onClick={() => setIsViewModalOpen(false)}
|
Retention
|
||||||
className="px-6 py-2 bg-[#0f1724] rounded-md text-sm font-bold text-white hover:bg-black transition-colors"
|
</label>
|
||||||
>
|
<p className="text-sm font-semibold text-[#0f1724] mt-1">
|
||||||
Close
|
{viewingCategory?.retention_years} years
|
||||||
</button>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,12 @@ import { useForm, Controller } from "react-hook-form";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import { FormField, FormSelect, FormTextArea, PrimaryButton } from "@/components/shared";
|
import {
|
||||||
|
FormField,
|
||||||
|
FormSelect,
|
||||||
|
FormTextArea,
|
||||||
|
PrimaryButton,
|
||||||
|
} from "@/components/shared";
|
||||||
import { documentService } from "@/services/document-service";
|
import { documentService } from "@/services/document-service";
|
||||||
import type { DocumentCategory } from "@/types/document";
|
import type { DocumentCategory } from "@/types/document";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
@ -66,21 +71,24 @@ const EditDocument = (): ReactElement => {
|
|||||||
|
|
||||||
const doc = docRes.data;
|
const doc = docRes.data;
|
||||||
// Find matching module by id (UUID) or module_id (code)
|
// Find matching module by id (UUID) or module_id (code)
|
||||||
const matchedModule = myModules.find(m =>
|
const matchedModule = myModules.find(
|
||||||
(doc.source_module_id && m.id === doc.source_module_id) ||
|
(m) =>
|
||||||
(doc.source_module && m.module_id === doc.source_module)
|
(doc.source_module_id && m.id === doc.source_module_id) ||
|
||||||
|
(doc.source_module && m.module_id === doc.source_module),
|
||||||
);
|
);
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
description: doc.description || "",
|
description: doc.description || "",
|
||||||
category_id: doc.category_id || "",
|
category_id: doc.category_id || doc.category?.id || "",
|
||||||
department: doc.department || "",
|
department: doc.department || "",
|
||||||
tags: (doc.tags || []).join(", "),
|
tags: (doc.tags || []).join(", "),
|
||||||
selectedModuleId: matchedModule?.id || "",
|
selectedModuleId: matchedModule?.id || "",
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error(err?.response?.data?.error?.message || "Failed to load document data");
|
showToast.error(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load document data",
|
||||||
|
);
|
||||||
navigate("/tenant/documents");
|
navigate("/tenant/documents");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -104,7 +112,8 @@ const EditDocument = (): ReactElement => {
|
|||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
source_module: modules.find((m) => m.id === data.selectedModuleId)!.module_id,
|
source_module: modules.find((m) => m.id === data.selectedModuleId)!
|
||||||
|
.module_id,
|
||||||
source_module_id: data.selectedModuleId,
|
source_module_id: data.selectedModuleId,
|
||||||
});
|
});
|
||||||
showToast.success("Document updated successfully");
|
showToast.success("Document updated successfully");
|
||||||
@ -239,7 +248,6 @@ const EditDocument = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<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)] p-4 md:p-5">
|
<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)] p-4 md:p-5">
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import {
|
import {
|
||||||
@ -10,9 +10,15 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Package,
|
Package,
|
||||||
ArrowUpRight
|
ArrowUpRight,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
ChevronDown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||||
|
import { logoutAsync } from '@/store/authSlice';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
import { moduleService } from '@/services/module-service';
|
import { moduleService } from '@/services/module-service';
|
||||||
import type { MyModule } from '@/types/module';
|
import type { MyModule } from '@/types/module';
|
||||||
import { AuthenticatedImage } from '@/components/shared';
|
import { AuthenticatedImage } from '@/components/shared';
|
||||||
@ -64,10 +70,62 @@ const WorkspaceCard = ({
|
|||||||
|
|
||||||
const LandingPage = (): ReactElement => {
|
const LandingPage = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, roles, tenantId } = useAppSelector((state) => state.auth);
|
const dispatch = useAppDispatch();
|
||||||
|
const { user, roles, tenantId, isLoading: isAuthLoading } = useAppSelector((state) => state.auth);
|
||||||
const { logoUrl } = useAppSelector((state) => state.theme);
|
const { logoUrl } = useAppSelector((state) => state.theme);
|
||||||
const [modules, setModules] = useState<MyModule[]>([]);
|
const [modules, setModules] = useState<MyModule[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Handle click outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
|
const handleLogout = async (e: React.MouseEvent): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
|
||||||
|
const isTenantRoute = window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant';
|
||||||
|
const isSuperAdmin = roles.includes('super_admin');
|
||||||
|
const redirectPath = isSuperAdmin ? '/' : (isTenantRoute ? '/tenant/login' : '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await dispatch(logoutAsync()).unwrap();
|
||||||
|
showToast.success(result.message || 'Logged out successfully');
|
||||||
|
navigate(redirectPath, { replace: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
dispatch({ type: 'auth/logout' });
|
||||||
|
showToast.success(error?.message || 'Logged out successfully');
|
||||||
|
navigate(redirectPath, { replace: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserInitials = (): string => {
|
||||||
|
if (user?.first_name && user?.last_name) {
|
||||||
|
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
if (user?.email) {
|
||||||
|
return user.email[0].toUpperCase();
|
||||||
|
}
|
||||||
|
return 'U';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchModules = async () => {
|
const fetchModules = async () => {
|
||||||
@ -122,6 +180,20 @@ const LandingPage = (): ReactElement => {
|
|||||||
return role.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
|
return role.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserDisplayName = (): string => {
|
||||||
|
let rolesArray: string[] = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles;
|
||||||
|
} else if (typeof roles === 'string') {
|
||||||
|
try { rolesArray = JSON.parse(roles); } catch { rolesArray = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.first_name) {
|
||||||
|
return `${user.first_name} - ${rolesArray[0] || 'User'}`;
|
||||||
|
}
|
||||||
|
return user?.email?.split('@')[0] || 'User';
|
||||||
|
};
|
||||||
|
|
||||||
// Icon mapping for modules based on code or name
|
// Icon mapping for modules based on code or name
|
||||||
const getModuleIcon = (module: MyModule) => {
|
const getModuleIcon = (module: MyModule) => {
|
||||||
const code = module.name.toUpperCase();
|
const code = module.name.toUpperCase();
|
||||||
@ -174,14 +246,62 @@ const LandingPage = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-right hidden sm:block">
|
<div className="relative" ref={dropdownRef}>
|
||||||
<p className="text-sm font-semibold text-[#0f1724]">{user?.first_name ? `${user.first_name} ${user.last_name || ''}` : getUserName()}</p>
|
<button
|
||||||
<p className="text-[11px] text-[#6b7280]">{user?.email}</p>
|
type="button"
|
||||||
</div>
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
<div className="w-9 h-9 bg-[#f1f5f9] rounded-full flex items-center justify-center border border-[rgba(0,0,0,0.08)]">
|
className="flex items-center gap-2.5 px-1.5 py-1.5 pr-1.5 bg-white border border-[rgba(0,0,0,0.08)] rounded-full hover:bg-gray-50 transition-colors cursor-pointer min-h-[44px]"
|
||||||
<span className="text-xs font-medium text-[#0f1724]">
|
aria-label="User menu"
|
||||||
{user?.first_name ? user.first_name[0].toUpperCase() : 'U'}
|
aria-expanded={isDropdownOpen}
|
||||||
</span>
|
>
|
||||||
|
<div className="w-7 h-7 bg-[#f1f5f9] rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-xs font-medium text-[#0f1724]">
|
||||||
|
{getUserInitials()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-medium text-[#0f1724] pr-1">
|
||||||
|
{getUserDisplayName()}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn('w-3.5 h-3.5 text-[#0f1724] transition-transform', isDropdownOpen && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-[52px] bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.08)] w-64 z-[100]"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* User Info Section */}
|
||||||
|
<div className="p-4 border-b border-[rgba(0,0,0,0.08)]">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 bg-[#f1f5f9] rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-[#0f1724]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[#0f1724] truncate">
|
||||||
|
{getUserDisplayName()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#6b7280] truncate">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<div className="p-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isAuthLoading}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm font-medium text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px]"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
<span>{isAuthLoading ? 'Logging out...' : 'Logout'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -90,9 +90,17 @@ export const NotificationSettings = () => {
|
|||||||
|
|
||||||
if (isLoading || !preferences) {
|
if (isLoading || !preferences) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-gray-500">
|
<Layout
|
||||||
Loading preferences...
|
currentPage="Notification Settings"
|
||||||
</div>
|
pageHeader={{
|
||||||
|
title: "Notification Settings",
|
||||||
|
description: "Control how and when you want to be notified",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
Loading preferences...
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -85,6 +85,7 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
const [modules, setModules] = useState<any[]>([]);
|
const [modules, setModules] = useState<any[]>([]);
|
||||||
const [selectedModule, setSelectedModule] = useState<string>("all");
|
const [selectedModule, setSelectedModule] = useState<string>("all");
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@ -138,7 +139,9 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
setTotalPages(res.pagination?.pages || 1);
|
setTotalPages(res.pagination?.pages || 1);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast.error("Failed to load templates");
|
setError(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load templates",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -329,6 +332,8 @@ const NotificationTemplates = (): ReactElement => {
|
|||||||
data={templates}
|
data={templates}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
keyExtractor={(t) => t.code}
|
keyExtractor={(t) => t.code}
|
||||||
|
error={error}
|
||||||
|
emptyMessage="No templates found"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Building2,
|
Building2,
|
||||||
Package,
|
Package,
|
||||||
AlertCircle,
|
// AlertCircle,
|
||||||
Pencil,
|
Pencil,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Files,
|
Files,
|
||||||
@ -51,12 +51,23 @@ interface QuotaEditModalProps {
|
|||||||
onUpdated: () => void;
|
onUpdated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalProps) => {
|
const QuotaEditModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
quota,
|
||||||
|
onUpdated,
|
||||||
|
}: QuotaEditModalProps) => {
|
||||||
const [maxStorageMB, setMaxStorageMB] = useState(
|
const [maxStorageMB, setMaxStorageMB] = useState(
|
||||||
Math.floor((typeof quota.max_storage_bytes === 'string' ? parseInt(quota.max_storage_bytes) : quota.max_storage_bytes) / 1024 / 1024)
|
Math.floor(
|
||||||
|
(typeof quota.max_storage_bytes === "string"
|
||||||
|
? parseInt(quota.max_storage_bytes)
|
||||||
|
: quota.max_storage_bytes) /
|
||||||
|
1024 /
|
||||||
|
1024,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const [maxFileMB, setMaxFileMB] = useState(
|
const [maxFileMB, setMaxFileMB] = useState(
|
||||||
Math.floor(quota.max_file_size_bytes / 1024 / 1024)
|
Math.floor(quota.max_file_size_bytes / 1024 / 1024),
|
||||||
);
|
);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
@ -85,8 +96,16 @@ const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalPro
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
{/* <SecondaryButton onClick={onClose} disabled={isUpdating}>Cancel</SecondaryButton> */}
|
{/* <SecondaryButton onClick={onClose} disabled={isUpdating}>Cancel</SecondaryButton> */}
|
||||||
<PrimaryButton onClick={handleSubmit} disabled={isUpdating} className="flex items-center gap-2">
|
<PrimaryButton
|
||||||
{isUpdating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
onClick={handleSubmit}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
Save Changes
|
Save Changes
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
@ -115,7 +134,7 @@ const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalPro
|
|||||||
const StorageDashboard = (): ReactElement => {
|
const StorageDashboard = (): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
const { primaryColor } = useAppTheme();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'stats' | 'quota'>('stats');
|
const [activeTab, setActiveTab] = useState<"stats" | "quota">("stats");
|
||||||
const [stats, setStats] = useState<StorageStats | null>(null);
|
const [stats, setStats] = useState<StorageStats | null>(null);
|
||||||
const [quota, setQuota] = useState<StorageQuota | null>(null);
|
const [quota, setQuota] = useState<StorageQuota | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -131,8 +150,11 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
]);
|
]);
|
||||||
setStats(statsRes.data);
|
setStats(statsRes.data);
|
||||||
setQuota(quotaRes.data);
|
setQuota(quotaRes.data);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError("Failed to load dashboard data");
|
setError(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load dashboard data",
|
||||||
|
);
|
||||||
|
console.log("Failed to load dashboard data", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -144,11 +166,15 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Layout currentPage="Storage Dashboard"
|
<Layout
|
||||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
currentPage="Storage Dashboard"
|
||||||
|
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: primaryColor }} />
|
<Loader2
|
||||||
|
className="w-8 h-8 animate-spin"
|
||||||
|
style={{ color: primaryColor }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
@ -156,14 +182,15 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
|
|
||||||
if (error || !stats || !quota) {
|
if (error || !stats || !quota) {
|
||||||
return (
|
return (
|
||||||
<Layout currentPage="Storage Dashboard"
|
<Layout
|
||||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
currentPage="Storage Dashboard"
|
||||||
|
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||||
>
|
>
|
||||||
<div className="max-w-md mx-auto mt-20 text-center">
|
<div className="max-w-md mx-auto mt-20 text-center">
|
||||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
{/* <AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> */}
|
||||||
<h2 className="text-lg font-bold">Error</h2>
|
{/* <h2 className="text-lg font-bold">Error</h2> */}
|
||||||
<p className="text-sm text-[#9aa6b2] mt-1">{error}</p>
|
<p className="text-sm text-red-500 mt-1">{error}</p>
|
||||||
<PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton>
|
{/* <PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton> */}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
@ -175,74 +202,120 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: "Storage Dashboard",
|
title: "Storage Dashboard",
|
||||||
description: "Overview of storage consumption, file counts, and quota limits.",
|
description:
|
||||||
|
"Overview of storage consumption, file counts, and quota limits.",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-[rgba(0,0,0,0.08)]">
|
<div className="flex border-b border-[rgba(0,0,0,0.08)]">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('stats')}
|
onClick={() => setActiveTab("stats")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
||||||
activeTab === 'stats'
|
activeTab === "stats"
|
||||||
? "text-[#0e1b2a]"
|
? "text-[#0e1b2a]"
|
||||||
: "border-transparent text-[#9aa6b2] hover:text-[#475569]"
|
: "border-transparent text-[#9aa6b2] hover:text-[#475569]",
|
||||||
)}
|
)}
|
||||||
style={activeTab === 'stats' ? { borderBottomColor: primaryColor, color: primaryColor } : {}}
|
style={
|
||||||
|
activeTab === "stats"
|
||||||
|
? { borderBottomColor: primaryColor, color: primaryColor }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Usage Statistics
|
Usage Statistics
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('quota')}
|
onClick={() => setActiveTab("quota")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
||||||
activeTab === 'quota'
|
activeTab === "quota"
|
||||||
? "text-[#0e1b2a]"
|
? "text-[#0e1b2a]"
|
||||||
: "border-transparent text-[#9aa6b2] hover:text-[#475569]"
|
: "border-transparent text-[#9aa6b2] hover:text-[#475569]",
|
||||||
)}
|
)}
|
||||||
style={activeTab === 'quota' ? { borderBottomColor: primaryColor, color: primaryColor } : {}}
|
style={
|
||||||
|
activeTab === "quota"
|
||||||
|
? { borderBottomColor: primaryColor, color: primaryColor }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Quota Details
|
Quota Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'stats' && (
|
{activeTab === "stats" && (
|
||||||
<div className="space-y-8 animate-in fade-in duration-300">
|
<div className="space-y-8 animate-in fade-in duration-300">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 rounded-lg" style={{ backgroundColor: `${primaryColor}10` }}>
|
<div
|
||||||
<HardDrive className="w-4 h-4" style={{ color: primaryColor }} />
|
className="p-2 rounded-lg"
|
||||||
|
style={{ backgroundColor: `${primaryColor}10` }}
|
||||||
|
>
|
||||||
|
<HardDrive
|
||||||
|
className="w-4 h-4"
|
||||||
|
style={{ color: primaryColor }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Usage</span>
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||||
|
Usage
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-black text-[#0e1b2a]">{stats.quota.usage_percent}% <span className="text-[10px] text-[#9aa6b2] font-medium uppercase">capacity</span></p>
|
<p className="text-xl font-black text-[#0e1b2a]">
|
||||||
|
{stats.quota.usage_percent}%{" "}
|
||||||
|
<span className="text-[10px] text-[#9aa6b2] font-medium uppercase">
|
||||||
|
capacity
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<div className="mt-2 w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
<div className="mt-2 w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||||
<div className="h-full" style={{ width: `${stats.quota.usage_percent}%`, backgroundColor: primaryColor }} />
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
width: `${stats.quota.usage_percent}%`,
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-emerald-50 rounded-lg"><Files className="w-4 h-4 text-emerald-600" /></div>
|
<div className="p-2 bg-emerald-50 rounded-lg">
|
||||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Total Files</span>
|
<Files className="w-4 h-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||||
|
Total Files
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.total}</p>
|
<p className="text-xl font-black text-[#0e1b2a]">
|
||||||
|
{stats.files.total}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-orange-50 rounded-lg"><ImageIcon className="w-4 h-4 text-orange-500" /></div>
|
<div className="p-2 bg-orange-50 rounded-lg">
|
||||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Images</span>
|
<ImageIcon className="w-4 h-4 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||||
|
Images
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.images}</p>
|
<p className="text-xl font-black text-[#0e1b2a]">
|
||||||
|
{stats.files.images}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="p-2 bg-red-50 rounded-lg"><FileText className="w-4 h-4 text-red-500" /></div>
|
<div className="p-2 bg-red-50 rounded-lg">
|
||||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">DOCs / PDFs</span>
|
<FileText className="w-4 h-4 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||||
|
DOCs / PDFs
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.pdfs + stats.files.documents}</p>
|
<p className="text-xl font-black text-[#0e1b2a]">
|
||||||
|
{stats.files.pdfs + stats.files.documents}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -251,8 +324,13 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
{/* Entity Table */}
|
{/* Entity Table */}
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
|
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
|
||||||
<Building2 className="w-4 h-4" style={{ color: primaryColor }} />
|
<Building2
|
||||||
<h3 className="text-sm font-bold text-[#0e1b2a]">By Entity Type</h3>
|
className="w-4 h-4"
|
||||||
|
style={{ color: primaryColor }}
|
||||||
|
/>
|
||||||
|
<h3 className="text-sm font-bold text-[#0e1b2a]">
|
||||||
|
By Entity Type
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
@ -264,10 +342,19 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
||||||
{Object.entries(stats.by_entity).map(([name, data]) => (
|
{Object.entries(stats.by_entity).map(([name, data]) => (
|
||||||
<tr key={name} className="hover:bg-gray-50 transition-colors">
|
<tr
|
||||||
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td>
|
key={name}
|
||||||
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td>
|
className="hover:bg-gray-50 transition-colors"
|
||||||
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td>
|
>
|
||||||
|
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-[#475569]">
|
||||||
|
{data.count}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">
|
||||||
|
{formatBytes(data.size)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -278,7 +365,9 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
|
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
|
||||||
<Package className="w-4 h-4 text-[#10b981]" />
|
<Package className="w-4 h-4 text-[#10b981]" />
|
||||||
<h3 className="text-sm font-bold text-[#0e1b2a]">By Source Module</h3>
|
<h3 className="text-sm font-bold text-[#0e1b2a]">
|
||||||
|
By Source Module
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
@ -290,10 +379,19 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
||||||
{Object.entries(stats.by_module).map(([name, data]) => (
|
{Object.entries(stats.by_module).map(([name, data]) => (
|
||||||
<tr key={name} className="hover:bg-gray-50 transition-colors">
|
<tr
|
||||||
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td>
|
key={name}
|
||||||
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td>
|
className="hover:bg-gray-50 transition-colors"
|
||||||
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td>
|
>
|
||||||
|
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-[#475569]">
|
||||||
|
{data.count}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">
|
||||||
|
{formatBytes(data.size)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -303,13 +401,18 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'quota' && (
|
{activeTab === "quota" && (
|
||||||
<div className="space-y-6 animate-in slide-in-from-bottom-2 duration-300">
|
<div className="space-y-6 animate-in slide-in-from-bottom-2 duration-300">
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="px-6 py-5 border-b border-[rgba(0,0,0,0.08)] flex items-center justify-between">
|
<div className="px-6 py-5 border-b border-[rgba(0,0,0,0.08)] flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="w-5 h-5" style={{ color: primaryColor }} />
|
<Database
|
||||||
<h3 className="text-base font-black text-[#0e1b2a]">Quota Profile</h3>
|
className="w-5 h-5"
|
||||||
|
style={{ color: primaryColor }}
|
||||||
|
/>
|
||||||
|
<h3 className="text-base font-black text-[#0e1b2a]">
|
||||||
|
Quota Profile
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => setIsEditModalOpen(true)}
|
onClick={() => setIsEditModalOpen(true)}
|
||||||
@ -324,18 +427,48 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
||||||
{[
|
{[
|
||||||
{ label: "Max Total Storage", value: quota.max_storage_formatted || formatBytes(quota.max_storage_bytes), icon: HardDrive },
|
{
|
||||||
{ label: "Max Per-File Size", value: quota.max_file_size_formatted || formatBytes(quota.max_file_size_bytes), icon: FileText },
|
label: "Max Total Storage",
|
||||||
{ label: "Currently Used", value: quota.used_storage_formatted || formatBytes(quota.used_storage_bytes), icon: Save },
|
value:
|
||||||
{ label: "File Count", value: `${quota.file_count} items`, icon: Files },
|
quota.max_storage_formatted ||
|
||||||
{ label: "Last Updated", value: new Date(quota.updated_at).toLocaleString(), icon: CheckCircle2 },
|
formatBytes(quota.max_storage_bytes),
|
||||||
|
icon: HardDrive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Max Per-File Size",
|
||||||
|
value:
|
||||||
|
quota.max_file_size_formatted ||
|
||||||
|
formatBytes(quota.max_file_size_bytes),
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Currently Used",
|
||||||
|
value:
|
||||||
|
quota.used_storage_formatted ||
|
||||||
|
formatBytes(quota.used_storage_bytes),
|
||||||
|
icon: Save,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "File Count",
|
||||||
|
value: `${quota.file_count} items`,
|
||||||
|
icon: Files,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last Updated",
|
||||||
|
value: new Date(quota.updated_at).toLocaleString(),
|
||||||
|
icon: CheckCircle2,
|
||||||
|
},
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<tr key={row.label}>
|
<tr key={row.label}>
|
||||||
<td className="px-6 py-4 flex items-center gap-3 w-1/3">
|
<td className="px-6 py-4 flex items-center gap-3 w-1/3">
|
||||||
<row.icon className="w-4 h-4 text-[#9aa6b2]" />
|
<row.icon className="w-4 h-4 text-[#9aa6b2]" />
|
||||||
<span className="text-sm font-medium text-[#475569]">{row.label}</span>
|
<span className="text-sm font-medium text-[#475569]">
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-bold text-[#0e1b2a]">
|
||||||
|
{row.value}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm font-bold text-[#0e1b2a]">{row.value}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -343,21 +476,36 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 border rounded-2xl flex items-start gap-4" style={{ backgroundColor: `${primaryColor}05`, borderColor: `${primaryColor}10` }}>
|
<div
|
||||||
<ShieldCheck className="w-6 h-6 shrink-0" style={{ color: primaryColor }} />
|
className="p-6 border rounded-2xl flex items-start gap-4"
|
||||||
<div>
|
style={{
|
||||||
<h4 className="text-sm font-bold text-[#0e1b2a]">System Security Policy</h4>
|
backgroundColor: `${primaryColor}05`,
|
||||||
<p className="text-sm text-[#475569] mt-1 leading-relaxed">
|
borderColor: `${primaryColor}10`,
|
||||||
The following extensions are strictly blocked to prevent malicious execution:
|
}}
|
||||||
</p>
|
>
|
||||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
<ShieldCheck
|
||||||
{quota.blocked_extensions?.map(ext => (
|
className="w-6 h-6 shrink-0"
|
||||||
<span key={ext} className="px-2 py-0.5 bg-red-100/50 text-red-700 text-[10px] font-black rounded border border-red-200 uppercase">
|
style={{ color: primaryColor }}
|
||||||
{ext}
|
/>
|
||||||
</span>
|
<div>
|
||||||
))}
|
<h4 className="text-sm font-bold text-[#0e1b2a]">
|
||||||
</div>
|
System Security Policy
|
||||||
</div>
|
</h4>
|
||||||
|
<p className="text-sm text-[#475569] mt-1 leading-relaxed">
|
||||||
|
The following extensions are strictly blocked to prevent
|
||||||
|
malicious execution:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||||
|
{quota.blocked_extensions?.map((ext) => (
|
||||||
|
<span
|
||||||
|
key={ext}
|
||||||
|
className="px-2 py-0.5 bg-red-100/50 text-red-700 text-[10px] font-black rounded border border-red-200 uppercase"
|
||||||
|
>
|
||||||
|
{ext}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -466,6 +466,11 @@ const ViewDocument = (): ReactElement => {
|
|||||||
label: "Change Summary",
|
label: "Change Summary",
|
||||||
render: (version) => version.change_summary || "-",
|
render: (version) => version.change_summary || "-",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "change_reason",
|
||||||
|
label: "Change Reason",
|
||||||
|
render: (version) => version.change_reason || "-",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "module_name",
|
key: "module_name",
|
||||||
label: "Module",
|
label: "Module",
|
||||||
@ -912,6 +917,7 @@ const ViewDocument = (): ReactElement => {
|
|||||||
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-lg text-sm focus:ring-1 focus:ring-[#112868]/20 focus:outline-none transition-all"
|
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-lg text-sm focus:ring-1 focus:ring-[#112868]/20 focus:outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 py-4 border-t border-[rgba(0,0,0,0.08)] bg-gray-50/50 flex justify-end gap-3">
|
<div className="p-6 py-4 border-t border-[rgba(0,0,0,0.08)] bg-gray-50/50 flex justify-end gap-3">
|
||||||
|
|||||||
@ -48,8 +48,9 @@ class SmtpConfigService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteConfig(id: string) {
|
async deleteConfig(id: string, tenantId?: string | null) {
|
||||||
const response = await apiClient.delete(`/smtp-config/${id}`);
|
const params = tenantId ? { tenantId } : {};
|
||||||
|
const response = await apiClient.delete(`/smtp-config/${id}`, { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,9 +16,13 @@ export const showToast = {
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: (message: string, description?: string, action?: ToastAction) => {
|
error: (
|
||||||
|
message: string,
|
||||||
|
// description?: string,
|
||||||
|
action?: ToastAction
|
||||||
|
) => {
|
||||||
toast.error(message, {
|
toast.error(message, {
|
||||||
description,
|
// description,
|
||||||
action,
|
action,
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user