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[] = [
|
||||
{
|
||||
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,
|
||||
label: "File Attachments",
|
||||
@ -209,6 +181,34 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||
],
|
||||
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" },
|
||||
];
|
||||
|
||||
@ -248,7 +248,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
||||
{
|
||||
label: "Failed Emails",
|
||||
path: "/tenant/settings/failed-emails",
|
||||
}
|
||||
},
|
||||
],
|
||||
requiredPermission: { resource: "tenants" },
|
||||
},
|
||||
@ -624,7 +624,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<div
|
||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||
style={{
|
||||
color: accentColor
|
||||
color: accentColor,
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
@ -711,7 +711,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<div
|
||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||
style={{
|
||||
color: accentColor
|
||||
color: accentColor,
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
@ -728,7 +728,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<MenuSection title="Platform" items={platformMenu} />
|
||||
)}
|
||||
{platformServiceMenu.length > 0 && (
|
||||
<MenuSection title="Platform Services" items={platformServiceMenu} />
|
||||
<MenuSection
|
||||
title="Platform Services"
|
||||
items={platformServiceMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* System Menu */}
|
||||
|
||||
@ -15,6 +15,7 @@ export const FailedEmailsTable: React.FC = () => {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedEmail, setSelectedEmail] = useState<FailedEmail | null>(null);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
@ -30,9 +31,9 @@ export const FailedEmailsTable: React.FC = () => {
|
||||
setTotal(res.total || 0);
|
||||
setCurrentPage(page);
|
||||
} catch (error: any) {
|
||||
toast.error('Error fetching failed emails', {
|
||||
description: error.message
|
||||
});
|
||||
setError(
|
||||
error?.response?.data?.error?.message || "Failed to load failed emails",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -181,6 +182,7 @@ export const FailedEmailsTable: React.FC = () => {
|
||||
keyExtractor={(item) => item.id}
|
||||
isLoading={loading}
|
||||
emptyMessage="No failed emails found"
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{total > limit && (
|
||||
|
||||
@ -249,7 +249,7 @@ export const MultiselectPaginatedSelect = ({
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
|
||||
>
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424] text-[8px]">*</span>}
|
||||
{required && <span className="text-[#e02424]">*</span>}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
|
||||
@ -257,8 +257,8 @@ export const SupplierModal = ({
|
||||
} catch (error: any) {
|
||||
showToast.error(
|
||||
mode === "create"
|
||||
? "Failed to create supplier"
|
||||
: "Failed to update supplier",
|
||||
? error?.response?.data?.error?.message || "Failed to create supplier"
|
||||
: error?.response?.data?.error?.message || "Failed to update supplier",
|
||||
error?.message,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
import { Plus, Building2 } from "lucide-react";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
import type { Supplier } from "@/types/supplier";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
// import { formatDate } from "@/utils/format-date";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
|
||||
interface SuppliersTableProps {
|
||||
@ -194,26 +194,26 @@ export const SuppliersTable = ({
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "location",
|
||||
label: "Location",
|
||||
render: (supplier) => (
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{supplier.address?.city
|
||||
? `${supplier.address.city}, ${supplier.address.country}`
|
||||
: supplier.address?.country || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
label: "Dated",
|
||||
render: (supplier) => (
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{formatDate(supplier.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// key: "location",
|
||||
// label: "Location",
|
||||
// render: (supplier) => (
|
||||
// <span className="text-sm text-[#6b7280]">
|
||||
// {supplier.address?.city
|
||||
// ? `${supplier.address.city}, ${supplier.address.country}`
|
||||
// : supplier.address?.country || "-"}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// key: "created_at",
|
||||
// label: "Dated",
|
||||
// render: (supplier) => (
|
||||
// <span className="text-sm text-[#6b7280]">
|
||||
// {formatDate(supplier.created_at)}
|
||||
// </span>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
|
||||
@ -150,7 +150,7 @@ export const ViewSupplierModal = ({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Supplier Profile"
|
||||
maxWidth="2xl"
|
||||
maxWidth="xl"
|
||||
footer={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
|
||||
@ -65,7 +65,7 @@ const SmtpConfigPage = () => {
|
||||
if (!selectedConfig?.id) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await smtpConfigService.deleteConfig(selectedConfig.id);
|
||||
await smtpConfigService.deleteConfig(selectedConfig.id, selectedConfig.tenant_id);
|
||||
showToast.success('Configuration deleted');
|
||||
setDeleteModalOpen(false);
|
||||
fetchConfigs();
|
||||
|
||||
@ -85,8 +85,10 @@ const CreateDocument = (): ReactElement => {
|
||||
setTypes(typesRes.data || []);
|
||||
setCategories(categoriesRes.data || []);
|
||||
setModules(modulesRes.data || []);
|
||||
} catch {
|
||||
showToast.error("Failed to load document metadata");
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to load document metadata"
|
||||
);
|
||||
}
|
||||
};
|
||||
void loadLookups();
|
||||
@ -98,8 +100,10 @@ const CreateDocument = (): ReactElement => {
|
||||
try {
|
||||
const res = await documentService.listFiles();
|
||||
setFiles(res.data || []);
|
||||
} catch {
|
||||
showToast.error("Failed to load files");
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to load files"
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
Modal,
|
||||
ActionDropdown,
|
||||
DeleteConfirmationModal,
|
||||
type Column
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import { documentService } from "@/services/document-service";
|
||||
import type { DocumentCategory } from "@/types/document";
|
||||
@ -23,7 +23,10 @@ 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"),
|
||||
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"),
|
||||
@ -40,11 +43,15 @@ const DocumentCategories = (): ReactElement => {
|
||||
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 [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 [categoryToDelete, setCategoryToDelete] =
|
||||
useState<DocumentCategory | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -72,7 +79,7 @@ const DocumentCategories = (): ReactElement => {
|
||||
const response = await documentService.getCategories();
|
||||
setCategories(response.data || []);
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to load categories",
|
||||
);
|
||||
} finally {
|
||||
@ -89,7 +96,10 @@ const DocumentCategories = (): ReactElement => {
|
||||
setValue("name", category.name);
|
||||
setValue("code", category.code);
|
||||
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("requiresTraining", !!category.requires_training);
|
||||
setValue("parentId", category.parent_id || "");
|
||||
@ -129,53 +139,72 @@ const DocumentCategories = (): ReactElement => {
|
||||
{
|
||||
key: "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",
|
||||
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",
|
||||
label: "Review Frequency",
|
||||
render: (category) =>
|
||||
category.review_frequency_months ? `${category.review_frequency_months} months` : "-",
|
||||
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>;
|
||||
}
|
||||
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` : "-",
|
||||
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 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>,
|
||||
render: (category) => (
|
||||
<span className="text-gray-500 line-clamp-1 max-w-[300px]">
|
||||
{category.description || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
@ -184,9 +213,22 @@ const DocumentCategories = (): ReactElement => {
|
||||
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" },
|
||||
{
|
||||
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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
@ -234,9 +276,16 @@ const DocumentCategories = (): ReactElement => {
|
||||
currentPage="Document Service"
|
||||
pageHeader={{
|
||||
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: (
|
||||
<PrimaryButton onClick={() => { setEditingCategory(null); reset(); setIsModalOpen(true); }}>
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
setEditingCategory(null);
|
||||
reset();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Create Category
|
||||
</PrimaryButton>
|
||||
@ -244,7 +293,6 @@ const DocumentCategories = (): ReactElement => {
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
@ -252,213 +300,279 @@ const DocumentCategories = (): ReactElement => {
|
||||
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"}
|
||||
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>
|
||||
<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")}
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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")}
|
||||
/>
|
||||
<FormTextArea
|
||||
label="Description"
|
||||
placeholder="Description of this user category."
|
||||
error={errors.description?.message}
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<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",
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
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 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>
|
||||
<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); }}
|
||||
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 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">Description</label>
|
||||
<p className="text-sm text-gray-600 mt-1 leading-relaxed">{viewingCategory?.description || "No description provided."}</p>
|
||||
<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 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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
|
||||
@ -4,7 +4,12 @@ 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 { FormField, FormSelect, FormTextArea, PrimaryButton } from "@/components/shared";
|
||||
import {
|
||||
FormField,
|
||||
FormSelect,
|
||||
FormTextArea,
|
||||
PrimaryButton,
|
||||
} from "@/components/shared";
|
||||
import { documentService } from "@/services/document-service";
|
||||
import type { DocumentCategory } from "@/types/document";
|
||||
import { showToast } from "@/utils/toast";
|
||||
@ -66,21 +71,24 @@ const EditDocument = (): ReactElement => {
|
||||
|
||||
const doc = docRes.data;
|
||||
// Find matching module by id (UUID) or module_id (code)
|
||||
const matchedModule = myModules.find(m =>
|
||||
(doc.source_module_id && m.id === doc.source_module_id) ||
|
||||
(doc.source_module && m.module_id === doc.source_module)
|
||||
const matchedModule = myModules.find(
|
||||
(m) =>
|
||||
(doc.source_module_id && m.id === doc.source_module_id) ||
|
||||
(doc.source_module && m.module_id === doc.source_module),
|
||||
);
|
||||
|
||||
reset({
|
||||
title: doc.title,
|
||||
description: doc.description || "",
|
||||
category_id: doc.category_id || "",
|
||||
category_id: doc.category_id || doc.category?.id || "",
|
||||
department: doc.department || "",
|
||||
tags: (doc.tags || []).join(", "),
|
||||
selectedModuleId: matchedModule?.id || "",
|
||||
});
|
||||
} 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");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -104,7 +112,8 @@ const EditDocument = (): ReactElement => {
|
||||
.map((tag) => tag.trim())
|
||||
.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,
|
||||
});
|
||||
showToast.success("Document updated successfully");
|
||||
@ -239,7 +248,6 @@ const EditDocument = (): ReactElement => {
|
||||
</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="flex gap-2 mt-1">
|
||||
<button
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
@ -10,9 +10,15 @@ import {
|
||||
Database,
|
||||
ClipboardCheck,
|
||||
Package,
|
||||
ArrowUpRight
|
||||
ArrowUpRight,
|
||||
LogOut,
|
||||
User,
|
||||
ChevronDown
|
||||
} 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 type { MyModule } from '@/types/module';
|
||||
import { AuthenticatedImage } from '@/components/shared';
|
||||
@ -64,10 +70,62 @@ const WorkspaceCard = ({
|
||||
|
||||
const LandingPage = (): ReactElement => {
|
||||
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 [modules, setModules] = useState<MyModule[]>([]);
|
||||
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(() => {
|
||||
const fetchModules = async () => {
|
||||
@ -122,6 +180,20 @@ const LandingPage = (): ReactElement => {
|
||||
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
|
||||
const getModuleIcon = (module: MyModule) => {
|
||||
const code = module.name.toUpperCase();
|
||||
@ -174,14 +246,62 @@ const LandingPage = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-sm font-semibold text-[#0f1724]">{user?.first_name ? `${user.first_name} ${user.last_name || ''}` : getUserName()}</p>
|
||||
<p className="text-[11px] text-[#6b7280]">{user?.email}</p>
|
||||
</div>
|
||||
<div className="w-9 h-9 bg-[#f1f5f9] rounded-full flex items-center justify-center border border-[rgba(0,0,0,0.08)]">
|
||||
<span className="text-xs font-medium text-[#0f1724]">
|
||||
{user?.first_name ? user.first_name[0].toUpperCase() : 'U'}
|
||||
</span>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
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]"
|
||||
aria-label="User menu"
|
||||
aria-expanded={isDropdownOpen}
|
||||
>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@ -90,9 +90,17 @@ export const NotificationSettings = () => {
|
||||
|
||||
if (isLoading || !preferences) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Loading preferences...
|
||||
</div>
|
||||
<Layout
|
||||
currentPage="Notification Settings"
|
||||
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 [selectedModule, setSelectedModule] = useState<string>("all");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@ -138,7 +139,9 @@ const NotificationTemplates = (): ReactElement => {
|
||||
setTotalPages(res.pagination?.pages || 1);
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error("Failed to load templates");
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to load templates",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -329,6 +332,8 @@ const NotificationTemplates = (): ReactElement => {
|
||||
data={templates}
|
||||
isLoading={isLoading}
|
||||
keyExtractor={(t) => t.code}
|
||||
error={error}
|
||||
emptyMessage="No templates found"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
ShieldCheck,
|
||||
Building2,
|
||||
Package,
|
||||
AlertCircle,
|
||||
// AlertCircle,
|
||||
Pencil,
|
||||
HardDrive,
|
||||
Files,
|
||||
@ -51,12 +51,23 @@ interface QuotaEditModalProps {
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalProps) => {
|
||||
const QuotaEditModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
quota,
|
||||
onUpdated,
|
||||
}: QuotaEditModalProps) => {
|
||||
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(
|
||||
Math.floor(quota.max_file_size_bytes / 1024 / 1024)
|
||||
Math.floor(quota.max_file_size_bytes / 1024 / 1024),
|
||||
);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
@ -85,8 +96,16 @@ const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalPro
|
||||
footer={
|
||||
<>
|
||||
{/* <SecondaryButton onClick={onClose} disabled={isUpdating}>Cancel</SecondaryButton> */}
|
||||
<PrimaryButton 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" />}
|
||||
<PrimaryButton
|
||||
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
|
||||
</PrimaryButton>
|
||||
</>
|
||||
@ -115,7 +134,7 @@ const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalPro
|
||||
const StorageDashboard = (): ReactElement => {
|
||||
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 [quota, setQuota] = useState<StorageQuota | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -131,8 +150,11 @@ const StorageDashboard = (): ReactElement => {
|
||||
]);
|
||||
setStats(statsRes.data);
|
||||
setQuota(quotaRes.data);
|
||||
} catch (err) {
|
||||
setError("Failed to load dashboard data");
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to load dashboard data",
|
||||
);
|
||||
console.log("Failed to load dashboard data", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -144,11 +166,15 @@ const StorageDashboard = (): ReactElement => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout currentPage="Storage Dashboard"
|
||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||
<Layout
|
||||
currentPage="Storage Dashboard"
|
||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||
>
|
||||
<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>
|
||||
</Layout>
|
||||
);
|
||||
@ -156,14 +182,15 @@ const StorageDashboard = (): ReactElement => {
|
||||
|
||||
if (error || !stats || !quota) {
|
||||
return (
|
||||
<Layout currentPage="Storage Dashboard"
|
||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||
<Layout
|
||||
currentPage="Storage Dashboard"
|
||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||
>
|
||||
<div className="max-w-md mx-auto mt-20 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-bold">Error</h2>
|
||||
<p className="text-sm text-[#9aa6b2] mt-1">{error}</p>
|
||||
<PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton>
|
||||
{/* <AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> */}
|
||||
{/* <h2 className="text-lg font-bold">Error</h2> */}
|
||||
<p className="text-sm text-red-500 mt-1">{error}</p>
|
||||
{/* <PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton> */}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
@ -175,74 +202,120 @@ const StorageDashboard = (): ReactElement => {
|
||||
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
|
||||
pageHeader={{
|
||||
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">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-[rgba(0,0,0,0.08)]">
|
||||
<button
|
||||
onClick={() => setActiveTab('stats')}
|
||||
onClick={() => setActiveTab("stats")}
|
||||
className={cn(
|
||||
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
||||
activeTab === 'stats'
|
||||
activeTab === "stats"
|
||||
? "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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('quota')}
|
||||
onClick={() => setActiveTab("quota")}
|
||||
className={cn(
|
||||
"px-6 py-3 text-sm font-bold transition-all border-b-2",
|
||||
activeTab === 'quota'
|
||||
activeTab === "quota"
|
||||
? "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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'stats' && (
|
||||
{activeTab === "stats" && (
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
{/* Summary Cards */}
|
||||
<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="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg" style={{ backgroundColor: `${primaryColor}10` }}>
|
||||
<HardDrive className="w-4 h-4" style={{ color: primaryColor }} />
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${primaryColor}10` }}
|
||||
>
|
||||
<HardDrive
|
||||
className="w-4 h-4"
|
||||
style={{ color: primaryColor }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Usage</span>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
Usage
|
||||
</span>
|
||||
</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="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 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="p-2 bg-emerald-50 rounded-lg"><Files className="w-4 h-4 text-emerald-600" /></div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Total Files</span>
|
||||
<div className="p-2 bg-emerald-50 rounded-lg">
|
||||
<Files className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
Total Files
|
||||
</span>
|
||||
</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 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="p-2 bg-orange-50 rounded-lg"><ImageIcon className="w-4 h-4 text-orange-500" /></div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Images</span>
|
||||
<div className="p-2 bg-orange-50 rounded-lg">
|
||||
<ImageIcon className="w-4 h-4 text-orange-500" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
Images
|
||||
</span>
|
||||
</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 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="p-2 bg-red-50 rounded-lg"><FileText className="w-4 h-4 text-red-500" /></div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">DOCs / PDFs</span>
|
||||
<div className="p-2 bg-red-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
|
||||
DOCs / PDFs
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@ -251,8 +324,13 @@ const StorageDashboard = (): ReactElement => {
|
||||
{/* Entity Table */}
|
||||
<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">
|
||||
<Building2 className="w-4 h-4" style={{ color: primaryColor }} />
|
||||
<h3 className="text-sm font-bold text-[#0e1b2a]">By Entity Type</h3>
|
||||
<Building2
|
||||
className="w-4 h-4"
|
||||
style={{ color: primaryColor }}
|
||||
/>
|
||||
<h3 className="text-sm font-bold text-[#0e1b2a]">
|
||||
By Entity Type
|
||||
</h3>
|
||||
</div>
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
@ -264,10 +342,19 @@ const StorageDashboard = (): ReactElement => {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
||||
{Object.entries(stats.by_entity).map(([name, data]) => (
|
||||
<tr key={name} className="hover:bg-gray-50 transition-colors">
|
||||
<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
|
||||
key={name}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</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="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]" />
|
||||
<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>
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
@ -290,10 +379,19 @@ const StorageDashboard = (): ReactElement => {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
||||
{Object.entries(stats.by_module).map(([name, data]) => (
|
||||
<tr key={name} className="hover:bg-gray-50 transition-colors">
|
||||
<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
|
||||
key={name}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
@ -303,13 +401,18 @@ const StorageDashboard = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'quota' && (
|
||||
{activeTab === "quota" && (
|
||||
<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="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">
|
||||
<Database className="w-5 h-5" style={{ color: primaryColor }} />
|
||||
<h3 className="text-base font-black text-[#0e1b2a]">Quota Profile</h3>
|
||||
<Database
|
||||
className="w-5 h-5"
|
||||
style={{ color: primaryColor }}
|
||||
/>
|
||||
<h3 className="text-base font-black text-[#0e1b2a]">
|
||||
Quota Profile
|
||||
</h3>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
@ -324,18 +427,48 @@ const StorageDashboard = (): ReactElement => {
|
||||
<table className="w-full">
|
||||
<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: "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 },
|
||||
{
|
||||
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: "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) => (
|
||||
<tr key={row.label}>
|
||||
<td className="px-6 py-4 flex items-center gap-3 w-1/3">
|
||||
<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 className="px-6 py-4 text-sm font-bold text-[#0e1b2a]">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -343,21 +476,36 @@ const StorageDashboard = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border rounded-2xl flex items-start gap-4" style={{ backgroundColor: `${primaryColor}05`, borderColor: `${primaryColor}10` }}>
|
||||
<ShieldCheck className="w-6 h-6 shrink-0" style={{ color: primaryColor }} />
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-[#0e1b2a]">System Security Policy</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
|
||||
className="p-6 border rounded-2xl flex items-start gap-4"
|
||||
style={{
|
||||
backgroundColor: `${primaryColor}05`,
|
||||
borderColor: `${primaryColor}10`,
|
||||
}}
|
||||
>
|
||||
<ShieldCheck
|
||||
className="w-6 h-6 shrink-0"
|
||||
style={{ color: primaryColor }}
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-[#0e1b2a]">
|
||||
System Security Policy
|
||||
</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>
|
||||
)}
|
||||
|
||||
@ -466,6 +466,11 @@ const ViewDocument = (): ReactElement => {
|
||||
label: "Change Summary",
|
||||
render: (version) => version.change_summary || "-",
|
||||
},
|
||||
{
|
||||
key: "change_reason",
|
||||
label: "Change Reason",
|
||||
render: (version) => version.change_reason || "-",
|
||||
},
|
||||
{
|
||||
key: "module_name",
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
|
||||
@ -48,8 +48,9 @@ class SmtpConfigService {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteConfig(id: string) {
|
||||
const response = await apiClient.delete(`/smtp-config/${id}`);
|
||||
async deleteConfig(id: string, tenantId?: string | null) {
|
||||
const params = tenantId ? { tenantId } : {};
|
||||
const response = await apiClient.delete(`/smtp-config/${id}`, { params });
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,13 @@ export const showToast = {
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
error: (message: string, description?: string, action?: ToastAction) => {
|
||||
error: (
|
||||
message: string,
|
||||
// description?: string,
|
||||
action?: ToastAction
|
||||
) => {
|
||||
toast.error(message, {
|
||||
description,
|
||||
// description,
|
||||
action,
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user