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:
Yashwin 2026-04-29 18:51:18 +05:30
parent 87db482697
commit 1b97371f73
17 changed files with 814 additions and 391 deletions

View File

@ -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}
@ -685,7 +685,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain"
fallback={
<div
<div
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
style={{ backgroundColor: primaryColor }}
>
@ -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 */}

View File

@ -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 && (

View File

@ -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

View File

@ -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 {

View File

@ -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",

View File

@ -150,7 +150,7 @@ export const ViewSupplierModal = ({
isOpen={isOpen}
onClose={onClose}
title="Supplier Profile"
maxWidth="2xl"
maxWidth="xl"
footer={
<SecondaryButton
type="button"

View File

@ -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();

View File

@ -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);
}

View File

@ -4,16 +4,16 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
import {
DataTable,
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
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 || "");
@ -126,56 +136,75 @@ const DocumentCategories = (): ReactElement => {
const columns: Column<DocumentCategory>[] = useMemo(
() => [
{
key: "name",
{
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",
{
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",
},
]}
/>
),
@ -215,7 +257,7 @@ const DocumentCategories = (): ReactElement => {
await documentService.createCategory(payload);
showToast.success("Category created");
}
setIsModalOpen(false);
reset();
setEditingCategory(null);
@ -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>

View File

@ -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";
@ -59,28 +64,31 @@ const EditDocument = (): ReactElement => {
moduleService.getMyModules(),
documentService.getById(id),
]);
setCategories(categoriesRes.data || []);
const myModules = modulesRes.data || [];
setModules(myModules);
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

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
)}

View File

@ -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">

View File

@ -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;
}
}

View File

@ -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,
});