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[] = [ const tenantAdminPlatformServiceMenu: MenuItem[] = [
{
icon: Bot,
label: "AI Services",
isGroup: true,
children: [
{
label: "Completion History",
path: "/tenant/ai/completions",
requiredPermission: { resource: "ai" },
},
{
label: "Prompt Management",
path: "/tenant/ai/prompts",
requiredPermission: { resource: "ai" },
},
{
label: "Tenant Config",
path: "/tenant/ai/config",
requiredPermission: { resource: "ai" },
},
{
label: "Knowledge (RAG)",
path: "/tenant/ai/knowledge",
requiredPermission: { resource: "ai" },
},
],
requiredPermission: { resource: "ai" },
},
{ {
icon: Paperclip, icon: Paperclip,
label: "File Attachments", label: "File Attachments",
@ -209,6 +181,34 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
], ],
requiredPermission: { resource: "document" }, requiredPermission: { resource: "document" },
}, },
{
icon: Bot,
label: "AI Services",
isGroup: true,
children: [
{
label: "Completion History",
path: "/tenant/ai/completions",
requiredPermission: { resource: "ai" },
},
{
label: "Prompt Management",
path: "/tenant/ai/prompts",
requiredPermission: { resource: "ai" },
},
{
label: "Tenant Config",
path: "/tenant/ai/config",
requiredPermission: { resource: "ai" },
},
{
label: "Knowledge (RAG)",
path: "/tenant/ai/knowledge",
requiredPermission: { resource: "ai" },
},
],
requiredPermission: { resource: "ai" },
},
{ icon: Package, label: "Modules", path: "/tenant/modules" }, { icon: Package, label: "Modules", path: "/tenant/modules" },
]; ];
@ -248,7 +248,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
{ {
label: "Failed Emails", label: "Failed Emails",
path: "/tenant/settings/failed-emails", path: "/tenant/settings/failed-emails",
} },
], ],
requiredPermission: { resource: "tenants" }, requiredPermission: { resource: "tenants" },
}, },
@ -624,7 +624,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div <div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2" className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{ style={{
color: accentColor color: accentColor,
}} }}
> >
{roleName} {roleName}
@ -685,7 +685,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
alt="Logo" alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain" className="h-9 w-auto max-w-[180px] object-contain"
fallback={ 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" 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 }} style={{ backgroundColor: primaryColor }}
> >
@ -711,7 +711,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div <div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2" className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{ style={{
color: accentColor color: accentColor,
}} }}
> >
{roleName} {roleName}
@ -728,7 +728,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<MenuSection title="Platform" items={platformMenu} /> <MenuSection title="Platform" items={platformMenu} />
)} )}
{platformServiceMenu.length > 0 && ( {platformServiceMenu.length > 0 && (
<MenuSection title="Platform Services" items={platformServiceMenu} /> <MenuSection
title="Platform Services"
items={platformServiceMenu}
/>
)} )}
{/* System Menu */} {/* System Menu */}

View File

@ -15,6 +15,7 @@ export const FailedEmailsTable: React.FC = () => {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(50); const [limit, setLimit] = useState(50);
const [error, setError] = useState<string | null>(null);
const [selectedEmail, setSelectedEmail] = useState<FailedEmail | null>(null); const [selectedEmail, setSelectedEmail] = useState<FailedEmail | null>(null);
const [isModalVisible, setIsModalVisible] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false);
@ -30,9 +31,9 @@ export const FailedEmailsTable: React.FC = () => {
setTotal(res.total || 0); setTotal(res.total || 0);
setCurrentPage(page); setCurrentPage(page);
} catch (error: any) { } catch (error: any) {
toast.error('Error fetching failed emails', { setError(
description: error.message error?.response?.data?.error?.message || "Failed to load failed emails",
}); );
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -181,6 +182,7 @@ export const FailedEmailsTable: React.FC = () => {
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
isLoading={loading} isLoading={loading}
emptyMessage="No failed emails found" emptyMessage="No failed emails found"
error={error}
/> />
{total > limit && ( {total > limit && (

View File

@ -249,7 +249,7 @@ export const MultiselectPaginatedSelect = ({
className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]" className="flex items-center gap-1.5 text-[13px] font-medium text-[#0e1b2a]"
> >
<span>{label}</span> <span>{label}</span>
{required && <span className="text-[#e02424] text-[8px]">*</span>} {required && <span className="text-[#e02424]">*</span>}
</label> </label>
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button

View File

@ -257,8 +257,8 @@ export const SupplierModal = ({
} catch (error: any) { } catch (error: any) {
showToast.error( showToast.error(
mode === "create" mode === "create"
? "Failed to create supplier" ? error?.response?.data?.error?.message || "Failed to create supplier"
: "Failed to update supplier", : error?.response?.data?.error?.message || "Failed to update supplier",
error?.message, error?.message,
); );
} finally { } finally {

View File

@ -15,7 +15,7 @@ import {
import { Plus, Building2 } from "lucide-react"; import { Plus, Building2 } from "lucide-react";
import { supplierService } from "@/services/supplier-service"; import { supplierService } from "@/services/supplier-service";
import type { Supplier } from "@/types/supplier"; import type { Supplier } from "@/types/supplier";
import { formatDate } from "@/utils/format-date"; // import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppTheme } from "@/hooks/useAppTheme";
interface SuppliersTableProps { interface SuppliersTableProps {
@ -194,26 +194,26 @@ export const SuppliersTable = ({
</StatusBadge> </StatusBadge>
), ),
}, },
{ // {
key: "location", // key: "location",
label: "Location", // label: "Location",
render: (supplier) => ( // render: (supplier) => (
<span className="text-sm text-[#6b7280]"> // <span className="text-sm text-[#6b7280]">
{supplier.address?.city // {supplier.address?.city
? `${supplier.address.city}, ${supplier.address.country}` // ? `${supplier.address.city}, ${supplier.address.country}`
: supplier.address?.country || "-"} // : supplier.address?.country || "-"}
</span> // </span>
), // ),
}, // },
{ // {
key: "created_at", // key: "created_at",
label: "Dated", // label: "Dated",
render: (supplier) => ( // render: (supplier) => (
<span className="text-sm text-[#6b7280]"> // <span className="text-sm text-[#6b7280]">
{formatDate(supplier.created_at)} // {formatDate(supplier.created_at)}
</span> // </span>
), // ),
}, // },
{ {
key: "actions", key: "actions",
label: "Actions", label: "Actions",

View File

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

View File

@ -65,7 +65,7 @@ const SmtpConfigPage = () => {
if (!selectedConfig?.id) return; if (!selectedConfig?.id) return;
setIsDeleting(true); setIsDeleting(true);
try { try {
await smtpConfigService.deleteConfig(selectedConfig.id); await smtpConfigService.deleteConfig(selectedConfig.id, selectedConfig.tenant_id);
showToast.success('Configuration deleted'); showToast.success('Configuration deleted');
setDeleteModalOpen(false); setDeleteModalOpen(false);
fetchConfigs(); fetchConfigs();

View File

@ -85,8 +85,10 @@ const CreateDocument = (): ReactElement => {
setTypes(typesRes.data || []); setTypes(typesRes.data || []);
setCategories(categoriesRes.data || []); setCategories(categoriesRes.data || []);
setModules(modulesRes.data || []); setModules(modulesRes.data || []);
} catch { } catch (err: any) {
showToast.error("Failed to load document metadata"); showToast.error(
err?.response?.data?.error?.message || "Failed to load document metadata"
);
} }
}; };
void loadLookups(); void loadLookups();
@ -98,8 +100,10 @@ const CreateDocument = (): ReactElement => {
try { try {
const res = await documentService.listFiles(); const res = await documentService.listFiles();
setFiles(res.data || []); setFiles(res.data || []);
} catch { } catch (err: any) {
showToast.error("Failed to load files"); showToast.error(
err?.response?.data?.error?.message || "Failed to load files"
);
} finally { } finally {
setIsLoadingFiles(false); setIsLoadingFiles(false);
} }

View File

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

View File

@ -4,7 +4,12 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { FormField, FormSelect, FormTextArea, PrimaryButton } from "@/components/shared"; import {
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
} from "@/components/shared";
import { documentService } from "@/services/document-service"; import { documentService } from "@/services/document-service";
import type { DocumentCategory } from "@/types/document"; import type { DocumentCategory } from "@/types/document";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
@ -59,28 +64,31 @@ const EditDocument = (): ReactElement => {
moduleService.getMyModules(), moduleService.getMyModules(),
documentService.getById(id), documentService.getById(id),
]); ]);
setCategories(categoriesRes.data || []); setCategories(categoriesRes.data || []);
const myModules = modulesRes.data || []; const myModules = modulesRes.data || [];
setModules(myModules); setModules(myModules);
const doc = docRes.data; const doc = docRes.data;
// Find matching module by id (UUID) or module_id (code) // Find matching module by id (UUID) or module_id (code)
const matchedModule = myModules.find(m => const matchedModule = myModules.find(
(doc.source_module_id && m.id === doc.source_module_id) || (m) =>
(doc.source_module && m.module_id === doc.source_module) (doc.source_module_id && m.id === doc.source_module_id) ||
(doc.source_module && m.module_id === doc.source_module),
); );
reset({ reset({
title: doc.title, title: doc.title,
description: doc.description || "", description: doc.description || "",
category_id: doc.category_id || "", category_id: doc.category_id || doc.category?.id || "",
department: doc.department || "", department: doc.department || "",
tags: (doc.tags || []).join(", "), tags: (doc.tags || []).join(", "),
selectedModuleId: matchedModule?.id || "", selectedModuleId: matchedModule?.id || "",
}); });
} catch (err: any) { } catch (err: any) {
showToast.error(err?.response?.data?.error?.message || "Failed to load document data"); showToast.error(
err?.response?.data?.error?.message || "Failed to load document data",
);
navigate("/tenant/documents"); navigate("/tenant/documents");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -104,7 +112,8 @@ const EditDocument = (): ReactElement => {
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter(Boolean) .filter(Boolean)
: [], : [],
source_module: modules.find((m) => m.id === data.selectedModuleId)!.module_id, source_module: modules.find((m) => m.id === data.selectedModuleId)!
.module_id,
source_module_id: data.selectedModuleId, source_module_id: data.selectedModuleId,
}); });
showToast.success("Document updated successfully"); showToast.success("Document updated successfully");
@ -239,7 +248,6 @@ const EditDocument = (): ReactElement => {
</div> </div>
</div> </div>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] p-4 md:p-5">
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
<button <button

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { import {
@ -10,9 +10,15 @@ import {
Database, Database,
ClipboardCheck, ClipboardCheck,
Package, Package,
ArrowUpRight ArrowUpRight,
LogOut,
User,
ChevronDown
} from 'lucide-react'; } from 'lucide-react';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { logoutAsync } from '@/store/authSlice';
import { cn } from '@/lib/utils';
import { showToast } from '@/utils/toast';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import type { MyModule } from '@/types/module'; import type { MyModule } from '@/types/module';
import { AuthenticatedImage } from '@/components/shared'; import { AuthenticatedImage } from '@/components/shared';
@ -64,10 +70,62 @@ const WorkspaceCard = ({
const LandingPage = (): ReactElement => { const LandingPage = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, roles, tenantId } = useAppSelector((state) => state.auth); const dispatch = useAppDispatch();
const { user, roles, tenantId, isLoading: isAuthLoading } = useAppSelector((state) => state.auth);
const { logoUrl } = useAppSelector((state) => state.theme); const { logoUrl } = useAppSelector((state) => state.theme);
const [modules, setModules] = useState<MyModule[]>([]); const [modules, setModules] = useState<MyModule[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
setIsDropdownOpen(false);
}
};
if (isDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isDropdownOpen]);
const handleLogout = async (e: React.MouseEvent): Promise<void> => {
e.preventDefault();
e.stopPropagation();
setIsDropdownOpen(false);
const isTenantRoute = window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant';
const isSuperAdmin = roles.includes('super_admin');
const redirectPath = isSuperAdmin ? '/' : (isTenantRoute ? '/tenant/login' : '/');
try {
const result = await dispatch(logoutAsync()).unwrap();
showToast.success(result.message || 'Logged out successfully');
navigate(redirectPath, { replace: true });
} catch (error: any) {
console.error('Logout error:', error);
dispatch({ type: 'auth/logout' });
showToast.success(error?.message || 'Logged out successfully');
navigate(redirectPath, { replace: true });
}
};
const getUserInitials = (): string => {
if (user?.first_name && user?.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
if (user?.email) {
return user.email[0].toUpperCase();
}
return 'U';
};
useEffect(() => { useEffect(() => {
const fetchModules = async () => { const fetchModules = async () => {
@ -122,6 +180,20 @@ const LandingPage = (): ReactElement => {
return role.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' '); return role.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
}; };
const getUserDisplayName = (): string => {
let rolesArray: string[] = [];
if (Array.isArray(roles)) {
rolesArray = roles;
} else if (typeof roles === 'string') {
try { rolesArray = JSON.parse(roles); } catch { rolesArray = []; }
}
if (user?.first_name) {
return `${user.first_name} - ${rolesArray[0] || 'User'}`;
}
return user?.email?.split('@')[0] || 'User';
};
// Icon mapping for modules based on code or name // Icon mapping for modules based on code or name
const getModuleIcon = (module: MyModule) => { const getModuleIcon = (module: MyModule) => {
const code = module.name.toUpperCase(); const code = module.name.toUpperCase();
@ -174,14 +246,62 @@ const LandingPage = (): ReactElement => {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-right hidden sm:block"> <div className="relative" ref={dropdownRef}>
<p className="text-sm font-semibold text-[#0f1724]">{user?.first_name ? `${user.first_name} ${user.last_name || ''}` : getUserName()}</p> <button
<p className="text-[11px] text-[#6b7280]">{user?.email}</p> type="button"
</div> onClick={() => setIsDropdownOpen(!isDropdownOpen)}
<div className="w-9 h-9 bg-[#f1f5f9] rounded-full flex items-center justify-center border border-[rgba(0,0,0,0.08)]"> className="flex items-center gap-2.5 px-1.5 py-1.5 pr-1.5 bg-white border border-[rgba(0,0,0,0.08)] rounded-full hover:bg-gray-50 transition-colors cursor-pointer min-h-[44px]"
<span className="text-xs font-medium text-[#0f1724]"> aria-label="User menu"
{user?.first_name ? user.first_name[0].toUpperCase() : 'U'} aria-expanded={isDropdownOpen}
</span> >
<div className="w-7 h-7 bg-[#f1f5f9] rounded-full flex items-center justify-center">
<span className="text-xs font-medium text-[#0f1724]">
{getUserInitials()}
</span>
</div>
<span className="text-[13px] font-medium text-[#0f1724] pr-1">
{getUserDisplayName()}
</span>
<ChevronDown
className={cn('w-3.5 h-3.5 text-[#0f1724] transition-transform', isDropdownOpen && 'rotate-180')}
/>
</button>
{/* Dropdown Menu */}
{isDropdownOpen && (
<div
className="absolute right-0 top-[52px] bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.08)] w-64 z-[100]"
onMouseDown={(e) => e.stopPropagation()}
>
{/* User Info Section */}
<div className="p-4 border-b border-[rgba(0,0,0,0.08)]">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 bg-[#f1f5f9] rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-[#0f1724]" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#0f1724] truncate">
{getUserDisplayName()}
</p>
<p className="text-xs text-[#6b7280] truncate">{user?.email}</p>
</div>
</div>
</div>
{/* Logout Button */}
<div className="p-2">
<button
type="button"
onClick={handleLogout}
disabled={isAuthLoading}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm font-medium text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px]"
>
<LogOut className="w-4 h-4" />
<span>{isAuthLoading ? 'Logging out...' : 'Logout'}</span>
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
</header> </header>

View File

@ -90,9 +90,17 @@ export const NotificationSettings = () => {
if (isLoading || !preferences) { if (isLoading || !preferences) {
return ( return (
<div className="p-8 text-center text-gray-500"> <Layout
Loading preferences... currentPage="Notification Settings"
</div> pageHeader={{
title: "Notification Settings",
description: "Control how and when you want to be notified",
}}
>
<div className="p-8 text-center text-gray-500">
Loading preferences...
</div>
</Layout>
); );
} }

View File

@ -85,6 +85,7 @@ const NotificationTemplates = (): ReactElement => {
const [modules, setModules] = useState<any[]>([]); const [modules, setModules] = useState<any[]>([]);
const [selectedModule, setSelectedModule] = useState<string>("all"); const [selectedModule, setSelectedModule] = useState<string>("all");
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Pagination // Pagination
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@ -138,7 +139,9 @@ const NotificationTemplates = (): ReactElement => {
setTotalPages(res.pagination?.pages || 1); setTotalPages(res.pagination?.pages || 1);
} }
} catch (err: any) { } catch (err: any) {
showToast.error("Failed to load templates"); setError(
err?.response?.data?.error?.message || "Failed to load templates",
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -329,6 +332,8 @@ const NotificationTemplates = (): ReactElement => {
data={templates} data={templates}
isLoading={isLoading} isLoading={isLoading}
keyExtractor={(t) => t.code} keyExtractor={(t) => t.code}
error={error}
emptyMessage="No templates found"
/> />
</div> </div>

View File

@ -4,7 +4,7 @@ import {
ShieldCheck, ShieldCheck,
Building2, Building2,
Package, Package,
AlertCircle, // AlertCircle,
Pencil, Pencil,
HardDrive, HardDrive,
Files, Files,
@ -51,12 +51,23 @@ interface QuotaEditModalProps {
onUpdated: () => void; onUpdated: () => void;
} }
const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalProps) => { const QuotaEditModal = ({
isOpen,
onClose,
quota,
onUpdated,
}: QuotaEditModalProps) => {
const [maxStorageMB, setMaxStorageMB] = useState( const [maxStorageMB, setMaxStorageMB] = useState(
Math.floor((typeof quota.max_storage_bytes === 'string' ? parseInt(quota.max_storage_bytes) : quota.max_storage_bytes) / 1024 / 1024) Math.floor(
(typeof quota.max_storage_bytes === "string"
? parseInt(quota.max_storage_bytes)
: quota.max_storage_bytes) /
1024 /
1024,
),
); );
const [maxFileMB, setMaxFileMB] = useState( const [maxFileMB, setMaxFileMB] = useState(
Math.floor(quota.max_file_size_bytes / 1024 / 1024) Math.floor(quota.max_file_size_bytes / 1024 / 1024),
); );
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@ -85,8 +96,16 @@ const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalPro
footer={ footer={
<> <>
{/* <SecondaryButton onClick={onClose} disabled={isUpdating}>Cancel</SecondaryButton> */} {/* <SecondaryButton onClick={onClose} disabled={isUpdating}>Cancel</SecondaryButton> */}
<PrimaryButton onClick={handleSubmit} disabled={isUpdating} className="flex items-center gap-2"> <PrimaryButton
{isUpdating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} onClick={handleSubmit}
disabled={isUpdating}
className="flex items-center gap-2"
>
{isUpdating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save Changes Save Changes
</PrimaryButton> </PrimaryButton>
</> </>
@ -115,7 +134,7 @@ const QuotaEditModal = ({ isOpen, onClose, quota, onUpdated }: QuotaEditModalPro
const StorageDashboard = (): ReactElement => { const StorageDashboard = (): ReactElement => {
const { primaryColor } = useAppTheme(); const { primaryColor } = useAppTheme();
const [activeTab, setActiveTab] = useState<'stats' | 'quota'>('stats'); const [activeTab, setActiveTab] = useState<"stats" | "quota">("stats");
const [stats, setStats] = useState<StorageStats | null>(null); const [stats, setStats] = useState<StorageStats | null>(null);
const [quota, setQuota] = useState<StorageQuota | null>(null); const [quota, setQuota] = useState<StorageQuota | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -131,8 +150,11 @@ const StorageDashboard = (): ReactElement => {
]); ]);
setStats(statsRes.data); setStats(statsRes.data);
setQuota(quotaRes.data); setQuota(quotaRes.data);
} catch (err) { } catch (err: any) {
setError("Failed to load dashboard data"); setError(
err?.response?.data?.error?.message || "Failed to load dashboard data",
);
console.log("Failed to load dashboard data", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -144,11 +166,15 @@ const StorageDashboard = (): ReactElement => {
if (loading) { if (loading) {
return ( return (
<Layout currentPage="Storage Dashboard" <Layout
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]} currentPage="Storage Dashboard"
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
> >
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: primaryColor }} /> <Loader2
className="w-8 h-8 animate-spin"
style={{ color: primaryColor }}
/>
</div> </div>
</Layout> </Layout>
); );
@ -156,14 +182,15 @@ const StorageDashboard = (): ReactElement => {
if (error || !stats || !quota) { if (error || !stats || !quota) {
return ( return (
<Layout currentPage="Storage Dashboard" <Layout
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]} currentPage="Storage Dashboard"
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
> >
<div className="max-w-md mx-auto mt-20 text-center"> <div className="max-w-md mx-auto mt-20 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> {/* <AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> */}
<h2 className="text-lg font-bold">Error</h2> {/* <h2 className="text-lg font-bold">Error</h2> */}
<p className="text-sm text-[#9aa6b2] mt-1">{error}</p> <p className="text-sm text-red-500 mt-1">{error}</p>
<PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton> {/* <PrimaryButton onClick={loadData} className="mt-4">Retry</PrimaryButton> */}
</div> </div>
</Layout> </Layout>
); );
@ -175,74 +202,120 @@ const StorageDashboard = (): ReactElement => {
// breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]} // breadcrumbs={[{ label: "File Attachments" }, { label: "Storage Dashboard" }]}
pageHeader={{ pageHeader={{
title: "Storage Dashboard", title: "Storage Dashboard",
description: "Overview of storage consumption, file counts, and quota limits.", description:
"Overview of storage consumption, file counts, and quota limits.",
}} }}
> >
<div className="space-y-6"> <div className="space-y-6">
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-[rgba(0,0,0,0.08)]"> <div className="flex border-b border-[rgba(0,0,0,0.08)]">
<button <button
onClick={() => setActiveTab('stats')} onClick={() => setActiveTab("stats")}
className={cn( className={cn(
"px-6 py-3 text-sm font-bold transition-all border-b-2", "px-6 py-3 text-sm font-bold transition-all border-b-2",
activeTab === 'stats' activeTab === "stats"
? "text-[#0e1b2a]" ? "text-[#0e1b2a]"
: "border-transparent text-[#9aa6b2] hover:text-[#475569]" : "border-transparent text-[#9aa6b2] hover:text-[#475569]",
)} )}
style={activeTab === 'stats' ? { borderBottomColor: primaryColor, color: primaryColor } : {}} style={
activeTab === "stats"
? { borderBottomColor: primaryColor, color: primaryColor }
: {}
}
> >
Usage Statistics Usage Statistics
</button> </button>
<button <button
onClick={() => setActiveTab('quota')} onClick={() => setActiveTab("quota")}
className={cn( className={cn(
"px-6 py-3 text-sm font-bold transition-all border-b-2", "px-6 py-3 text-sm font-bold transition-all border-b-2",
activeTab === 'quota' activeTab === "quota"
? "text-[#0e1b2a]" ? "text-[#0e1b2a]"
: "border-transparent text-[#9aa6b2] hover:text-[#475569]" : "border-transparent text-[#9aa6b2] hover:text-[#475569]",
)} )}
style={activeTab === 'quota' ? { borderBottomColor: primaryColor, color: primaryColor } : {}} style={
activeTab === "quota"
? { borderBottomColor: primaryColor, color: primaryColor }
: {}
}
> >
Quota Details Quota Details
</button> </button>
</div> </div>
{activeTab === 'stats' && ( {activeTab === "stats" && (
<div className="space-y-8 animate-in fade-in duration-300"> <div className="space-y-8 animate-in fade-in duration-300">
{/* Summary Cards */} {/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm"> <div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg" style={{ backgroundColor: `${primaryColor}10` }}> <div
<HardDrive className="w-4 h-4" style={{ color: primaryColor }} /> className="p-2 rounded-lg"
style={{ backgroundColor: `${primaryColor}10` }}
>
<HardDrive
className="w-4 h-4"
style={{ color: primaryColor }}
/>
</div> </div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Usage</span> <span className="text-xs font-bold text-[#9aa6b2] uppercase">
Usage
</span>
</div> </div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.quota.usage_percent}% <span className="text-[10px] text-[#9aa6b2] font-medium uppercase">capacity</span></p> <p className="text-xl font-black text-[#0e1b2a]">
{stats.quota.usage_percent}%{" "}
<span className="text-[10px] text-[#9aa6b2] font-medium uppercase">
capacity
</span>
</p>
<div className="mt-2 w-full h-1.5 bg-gray-100 rounded-full overflow-hidden"> <div className="mt-2 w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full" style={{ width: `${stats.quota.usage_percent}%`, backgroundColor: primaryColor }} /> <div
className="h-full"
style={{
width: `${stats.quota.usage_percent}%`,
backgroundColor: primaryColor,
}}
/>
</div> </div>
</div> </div>
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm"> <div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-emerald-50 rounded-lg"><Files className="w-4 h-4 text-emerald-600" /></div> <div className="p-2 bg-emerald-50 rounded-lg">
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Total Files</span> <Files className="w-4 h-4 text-emerald-600" />
</div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
Total Files
</span>
</div> </div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.total}</p> <p className="text-xl font-black text-[#0e1b2a]">
{stats.files.total}
</p>
</div> </div>
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm"> <div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-orange-50 rounded-lg"><ImageIcon className="w-4 h-4 text-orange-500" /></div> <div className="p-2 bg-orange-50 rounded-lg">
<span className="text-xs font-bold text-[#9aa6b2] uppercase">Images</span> <ImageIcon className="w-4 h-4 text-orange-500" />
</div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
Images
</span>
</div> </div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.images}</p> <p className="text-xl font-black text-[#0e1b2a]">
{stats.files.images}
</p>
</div> </div>
<div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm"> <div className="bg-white p-5 rounded-xl border border-[rgba(0,0,0,0.08)] shadow-sm">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-red-50 rounded-lg"><FileText className="w-4 h-4 text-red-500" /></div> <div className="p-2 bg-red-50 rounded-lg">
<span className="text-xs font-bold text-[#9aa6b2] uppercase">DOCs / PDFs</span> <FileText className="w-4 h-4 text-red-500" />
</div>
<span className="text-xs font-bold text-[#9aa6b2] uppercase">
DOCs / PDFs
</span>
</div> </div>
<p className="text-xl font-black text-[#0e1b2a]">{stats.files.pdfs + stats.files.documents}</p> <p className="text-xl font-black text-[#0e1b2a]">
{stats.files.pdfs + stats.files.documents}
</p>
</div> </div>
</div> </div>
@ -251,8 +324,13 @@ const StorageDashboard = (): ReactElement => {
{/* Entity Table */} {/* Entity Table */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2"> <div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
<Building2 className="w-4 h-4" style={{ color: primaryColor }} /> <Building2
<h3 className="text-sm font-bold text-[#0e1b2a]">By Entity Type</h3> className="w-4 h-4"
style={{ color: primaryColor }}
/>
<h3 className="text-sm font-bold text-[#0e1b2a]">
By Entity Type
</h3>
</div> </div>
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
@ -264,10 +342,19 @@ const StorageDashboard = (): ReactElement => {
</thead> </thead>
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]"> <tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{Object.entries(stats.by_entity).map(([name, data]) => ( {Object.entries(stats.by_entity).map(([name, data]) => (
<tr key={name} className="hover:bg-gray-50 transition-colors"> <tr
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td> key={name}
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td> className="hover:bg-gray-50 transition-colors"
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td> >
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">
{name}
</td>
<td className="px-5 py-3 text-sm text-[#475569]">
{data.count}
</td>
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">
{formatBytes(data.size)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -278,7 +365,9 @@ const StorageDashboard = (): ReactElement => {
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2"> <div className="px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
<Package className="w-4 h-4 text-[#10b981]" /> <Package className="w-4 h-4 text-[#10b981]" />
<h3 className="text-sm font-bold text-[#0e1b2a]">By Source Module</h3> <h3 className="text-sm font-bold text-[#0e1b2a]">
By Source Module
</h3>
</div> </div>
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
@ -290,10 +379,19 @@ const StorageDashboard = (): ReactElement => {
</thead> </thead>
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]"> <tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{Object.entries(stats.by_module).map(([name, data]) => ( {Object.entries(stats.by_module).map(([name, data]) => (
<tr key={name} className="hover:bg-gray-50 transition-colors"> <tr
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">{name}</td> key={name}
<td className="px-5 py-3 text-sm text-[#475569]">{data.count}</td> className="hover:bg-gray-50 transition-colors"
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">{formatBytes(data.size)}</td> >
<td className="px-5 py-3 text-sm font-bold text-[#0e1b2a] capitalize">
{name}
</td>
<td className="px-5 py-3 text-sm text-[#475569]">
{data.count}
</td>
<td className="px-5 py-3 text-sm text-[#0e1b2a] font-medium">
{formatBytes(data.size)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -303,13 +401,18 @@ const StorageDashboard = (): ReactElement => {
</div> </div>
)} )}
{activeTab === 'quota' && ( {activeTab === "quota" && (
<div className="space-y-6 animate-in slide-in-from-bottom-2 duration-300"> <div className="space-y-6 animate-in slide-in-from-bottom-2 duration-300">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
<div className="px-6 py-5 border-b border-[rgba(0,0,0,0.08)] flex items-center justify-between"> <div className="px-6 py-5 border-b border-[rgba(0,0,0,0.08)] flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="w-5 h-5" style={{ color: primaryColor }} /> <Database
<h3 className="text-base font-black text-[#0e1b2a]">Quota Profile</h3> className="w-5 h-5"
style={{ color: primaryColor }}
/>
<h3 className="text-base font-black text-[#0e1b2a]">
Quota Profile
</h3>
</div> </div>
<PrimaryButton <PrimaryButton
onClick={() => setIsEditModalOpen(true)} onClick={() => setIsEditModalOpen(true)}
@ -324,18 +427,48 @@ const StorageDashboard = (): ReactElement => {
<table className="w-full"> <table className="w-full">
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]"> <tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{[ {[
{ label: "Max Total Storage", value: quota.max_storage_formatted || formatBytes(quota.max_storage_bytes), icon: HardDrive }, {
{ label: "Max Per-File Size", value: quota.max_file_size_formatted || formatBytes(quota.max_file_size_bytes), icon: FileText }, label: "Max Total Storage",
{ label: "Currently Used", value: quota.used_storage_formatted || formatBytes(quota.used_storage_bytes), icon: Save }, value:
{ label: "File Count", value: `${quota.file_count} items`, icon: Files }, quota.max_storage_formatted ||
{ label: "Last Updated", value: new Date(quota.updated_at).toLocaleString(), icon: CheckCircle2 }, formatBytes(quota.max_storage_bytes),
icon: HardDrive,
},
{
label: "Max Per-File Size",
value:
quota.max_file_size_formatted ||
formatBytes(quota.max_file_size_bytes),
icon: FileText,
},
{
label: "Currently Used",
value:
quota.used_storage_formatted ||
formatBytes(quota.used_storage_bytes),
icon: Save,
},
{
label: "File Count",
value: `${quota.file_count} items`,
icon: Files,
},
{
label: "Last Updated",
value: new Date(quota.updated_at).toLocaleString(),
icon: CheckCircle2,
},
].map((row) => ( ].map((row) => (
<tr key={row.label}> <tr key={row.label}>
<td className="px-6 py-4 flex items-center gap-3 w-1/3"> <td className="px-6 py-4 flex items-center gap-3 w-1/3">
<row.icon className="w-4 h-4 text-[#9aa6b2]" /> <row.icon className="w-4 h-4 text-[#9aa6b2]" />
<span className="text-sm font-medium text-[#475569]">{row.label}</span> <span className="text-sm font-medium text-[#475569]">
{row.label}
</span>
</td>
<td className="px-6 py-4 text-sm font-bold text-[#0e1b2a]">
{row.value}
</td> </td>
<td className="px-6 py-4 text-sm font-bold text-[#0e1b2a]">{row.value}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -343,21 +476,36 @@ const StorageDashboard = (): ReactElement => {
</div> </div>
</div> </div>
<div className="p-6 border rounded-2xl flex items-start gap-4" style={{ backgroundColor: `${primaryColor}05`, borderColor: `${primaryColor}10` }}> <div
<ShieldCheck className="w-6 h-6 shrink-0" style={{ color: primaryColor }} /> className="p-6 border rounded-2xl flex items-start gap-4"
<div> style={{
<h4 className="text-sm font-bold text-[#0e1b2a]">System Security Policy</h4> backgroundColor: `${primaryColor}05`,
<p className="text-sm text-[#475569] mt-1 leading-relaxed"> borderColor: `${primaryColor}10`,
The following extensions are strictly blocked to prevent malicious execution: }}
</p> >
<div className="flex flex-wrap gap-1.5 mt-3"> <ShieldCheck
{quota.blocked_extensions?.map(ext => ( className="w-6 h-6 shrink-0"
<span key={ext} className="px-2 py-0.5 bg-red-100/50 text-red-700 text-[10px] font-black rounded border border-red-200 uppercase"> style={{ color: primaryColor }}
{ext} />
</span> <div>
))} <h4 className="text-sm font-bold text-[#0e1b2a]">
</div> System Security Policy
</div> </h4>
<p className="text-sm text-[#475569] mt-1 leading-relaxed">
The following extensions are strictly blocked to prevent
malicious execution:
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{quota.blocked_extensions?.map((ext) => (
<span
key={ext}
className="px-2 py-0.5 bg-red-100/50 text-red-700 text-[10px] font-black rounded border border-red-200 uppercase"
>
{ext}
</span>
))}
</div>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -466,6 +466,11 @@ const ViewDocument = (): ReactElement => {
label: "Change Summary", label: "Change Summary",
render: (version) => version.change_summary || "-", render: (version) => version.change_summary || "-",
}, },
{
key: "change_reason",
label: "Change Reason",
render: (version) => version.change_reason || "-",
},
{ {
key: "module_name", key: "module_name",
label: "Module", label: "Module",
@ -912,6 +917,7 @@ const ViewDocument = (): ReactElement => {
className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-lg text-sm focus:ring-1 focus:ring-[#112868]/20 focus:outline-none transition-all" className="w-full px-3 py-2 border border-[rgba(0,0,0,0.08)] rounded-lg text-sm focus:ring-1 focus:ring-[#112868]/20 focus:outline-none transition-all"
/> />
</div> </div>
</div> </div>
<div className="p-6 py-4 border-t border-[rgba(0,0,0,0.08)] bg-gray-50/50 flex justify-end gap-3"> <div className="p-6 py-4 border-t border-[rgba(0,0,0,0.08)] bg-gray-50/50 flex justify-end gap-3">

View File

@ -48,8 +48,9 @@ class SmtpConfigService {
return response.data; return response.data;
} }
async deleteConfig(id: string) { async deleteConfig(id: string, tenantId?: string | null) {
const response = await apiClient.delete(`/smtp-config/${id}`); const params = tenantId ? { tenantId } : {};
const response = await apiClient.delete(`/smtp-config/${id}`, { params });
return response.data; return response.data;
} }
} }

View File

@ -16,9 +16,13 @@ export const showToast = {
duration: 3000, duration: 3000,
}); });
}, },
error: (message: string, description?: string, action?: ToastAction) => { error: (
message: string,
// description?: string,
action?: ToastAction
) => {
toast.error(message, { toast.error(message, {
description, // description,
action, action,
duration: 4000, duration: 4000,
}); });