feat: add imperative handle to UsersTable and implement theme-aware dynamic styling and permission-based access controls across core tables.

This commit is contained in:
Yashwin 2026-04-30 18:05:03 +05:30
parent 901dde3362
commit 178b8f9046
16 changed files with 642 additions and 1664 deletions

View File

@ -270,25 +270,52 @@ const GroupMenuItem = ({
onClose: () => void; onClose: () => void;
}) => { }) => {
const isChildActive = (path: string) => { const isChildActive = (path: string) => {
// Special handling for Document Lists to NOT show as active when sub-actions are active // Basic exact match check
if (path === "/tenant/documents") { if (location.pathname === path) return true;
const subActions = ["/create", "/categories", "/due-for-review", "/edit"];
const isSubActionActive = subActions.some((sub) => // Special handling for paths that are prefixes of other siblings
location.pathname.startsWith(path + sub), // Example: /tenant/settings is a prefix of /tenant/settings/notifications
if (location.pathname.startsWith(`${path}/`)) {
// If there's another sibling that is a more specific match for the current path,
// then this prefix path should not be considered "active"
const hasMoreSpecificSibling = childrenItems.some(
(sibling) =>
sibling.path !== path &&
sibling.path.startsWith(path) &&
(location.pathname === sibling.path ||
location.pathname.startsWith(`${sibling.path}/`)),
); );
if (isSubActionActive) return false;
}
// Special handling for Files List to NOT show as active when Storage Dashboard is active if (hasMoreSpecificSibling) return false;
if (path === "/tenant/files") {
if (location.pathname.startsWith("/tenant/files/storage-dashboard")) { // Also keep existing special cases if they don't fit the generic rule above
return false; // (Though the generic rule above should cover most of these)
// Special handling for Document Lists to NOT show as active when sub-actions are active
if (path === "/tenant/documents") {
const subActions = [
"/create",
"/categories",
"/due-for-review",
"/edit",
];
const isSubActionActive = subActions.some((sub) =>
location.pathname.startsWith(path + sub),
);
if (isSubActionActive) return false;
} }
// Special handling for Files List to NOT show as active when Storage Dashboard is active
if (path === "/tenant/files") {
if (location.pathname.startsWith("/tenant/files/storage-dashboard")) {
return false;
}
}
return true;
} }
return ( return false;
location.pathname === path || location.pathname.startsWith(`${path}/`)
);
}; };
const isAnyChildActive = childrenItems.some((child) => const isAnyChildActive = childrenItems.some((child) =>
isChildActive(child.path), isChildActive(child.path),

View File

@ -9,9 +9,11 @@ import {
DeleteConfirmationModal, DeleteConfirmationModal,
WorkflowDefinitionModal, WorkflowDefinitionModal,
WorkflowDefinitionViewModal, WorkflowDefinitionViewModal,
SearchBox,
type Column, type Column,
ActionDropdown,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, GitBranch, Play, Power, Trash2, Copy, Edit, Eye } from "lucide-react"; import { Plus, Play, Power, Trash2, Copy, Edit, Eye } from "lucide-react";
import { workflowService } from "@/services/workflow-service"; import { workflowService } from "@/services/workflow-service";
import type { WorkflowDefinition } from "@/types/workflow"; import type { WorkflowDefinition } from "@/types/workflow";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
@ -206,9 +208,7 @@ const WorkflowDefinitionsTable = ({
{ {
key: "entity_type", key: "entity_type",
label: "Entity Type", label: "Entity Type",
render: (wf) => ( render: (wf) => <CodeBadge label={wf.entity_type} />,
<CodeBadge label={wf.entity_type} />
),
}, },
{ {
key: "version", key: "version",
@ -233,7 +233,9 @@ const WorkflowDefinitionsTable = ({
key: "source_module", key: "source_module",
label: "Module", label: "Module",
render: (wf) => ( render: (wf) => (
<span className="text-sm text-[#6b7280]">{wf.source_module?.join(", ")}</span> <span className="text-sm text-[#6b7280]">
{wf.source_module?.join(", ")}
</span>
), ),
}, },
{ {
@ -250,83 +252,51 @@ const WorkflowDefinitionsTable = ({
label: "Actions", label: "Actions",
align: "right", align: "right",
render: (wf) => ( render: (wf) => (
<div className="flex justify-end gap-2"> <div className="flex justify-end">
<button <ActionDropdown
onClick={() => handleClone(wf.id, wf.name)} actions={[
disabled={isActionLoading} {
className="p-1 hover:bg-gray-100 rounded-md transition-colors text-[#6b7280]" icon: <Copy className="w-4 h-4" />,
title="Clone" label: "Clone",
> onClick: () => handleClone(wf.id, wf.name),
<Copy className="w-4 h-4" /> },
</button> {
icon: <Eye className="w-4 h-4" />,
<button label: "View",
onClick={() => { onClick: () => {
setViewDefinitionId(wf.id); setViewDefinitionId(wf.id);
setIsViewModalOpen(true); setIsViewModalOpen(true);
}} },
disabled={isActionLoading} },
className="p-1 hover:bg-slate-100 rounded-md transition-colors text-slate-600" {
title="View Details" icon: <Edit className="w-4 h-4" />,
> label: "Edit",
<Eye className="w-4 h-4" /> onClick: () => {
</button> setSelectedDefinition(wf);
setIsModalOpen(true);
<button },
onClick={() => { },
setSelectedDefinition(wf); (wf.status === "draft" || wf.status === "deprecated") ? {
setIsModalOpen(true); icon: <Play className="w-4 h-4" />,
}} label: "Activate",
disabled={isActionLoading} onClick: () => handleActivate(wf.id),
className="p-1 hover:bg-blue-50 rounded-md transition-colors text-blue-600" } : null,
title="Edit" wf.status === "active" ? {
> icon: <Power className="w-4 h-4" />,
<Edit className="w-4 h-4" /> label: "Deprecate",
</button> onClick: () => handleDeprecate(wf.id),
} : null,
{wf.status === "draft" && ( {
<button icon: <Trash2 className="w-4 h-4" />,
onClick={() => handleActivate(wf.id)} label: "Delete",
disabled={isActionLoading} variant: "danger",
className="p-1 hover:bg-green-50 rounded-md transition-colors text-green-600" onClick: () => {
title="Activate" setSelectedDefinition(wf);
> setIsDeleteModalOpen(true);
<Play className="w-4 h-4" /> },
</button> },
)} ].filter((a): a is any => a !== null)}
/>
{wf.status === "active" && (
<button
onClick={() => handleDeprecate(wf.id)}
disabled={isActionLoading}
className="p-1 hover:bg-orange-50 rounded-md transition-colors text-orange-600"
title="Deprecate"
>
<Power className="w-4 h-4" />
</button>
)}
{wf.status === "deprecated" && (
<button
onClick={() => handleActivate(wf.id)}
disabled={isActionLoading}
className="p-1 hover:bg-green-50 rounded-md transition-colors text-green-600"
title="Activate"
>
<Play className="w-4 h-4" />
</button>
)}
<button
onClick={() => {
setSelectedDefinition(wf);
setIsDeleteModalOpen(true);
}}
disabled={isActionLoading}
className="p-1 hover:bg-red-50 rounded-md transition-colors text-red-600"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
), ),
}, },
@ -339,20 +309,14 @@ const WorkflowDefinitionsTable = ({
{showHeader && ( {showHeader && (
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3 w-full sm:w-auto"> <div className="flex items-center gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:w-64"> <SearchBox
<GitBranch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" /> value={searchQuery}
<input onChange={setSearchQuery}
type="text" placeholder="Search name, code or description"
placeholder="Search workflows..." />
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#0052cc]"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<FilterDropdown <FilterDropdown
label="Status" label="Status"
options={[ options={[
{ value: "", label: "All Status" },
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
{ value: "draft", label: "Draft" }, { value: "draft", label: "Draft" },
{ value: "deprecated", label: "Deprecated" }, { value: "deprecated", label: "Deprecated" },

View File

@ -11,6 +11,7 @@ import {
SearchBox, SearchBox,
ActiveOnlyToggle, ActiveOnlyToggle,
type Column, type Column,
PrimaryButton,
} from "@/components/shared"; } from "@/components/shared";
import { import {
NewDepartmentModal, NewDepartmentModal,
@ -29,7 +30,9 @@ import type {
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store"; import type { RootState } from "@/store/store";
import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppTheme } from "@/hooks/useAppTheme";
import { usePermissions } from "@/hooks/usePermissions";
import CodeBadge from "../shared/CodeBadge"; import CodeBadge from "../shared/CodeBadge";
import { Plus } from "lucide-react";
interface DepartmentsTableProps { interface DepartmentsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode) tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
@ -47,6 +50,7 @@ export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTable
showHeader = true, showHeader = true,
}: DepartmentsTableProps, ref): ReactElement => { }: DepartmentsTableProps, ref): ReactElement => {
const { primaryColor } = useAppTheme(); const { primaryColor } = useAppTheme();
const { canCreate, canUpdate } = usePermissions();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId); const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId; const effectiveTenantId = propsTenantId || reduxTenantId;
@ -265,10 +269,14 @@ export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTable
setSelectedDepartment(dept); setSelectedDepartment(dept);
setIsViewModalOpen(true); setIsViewModalOpen(true);
}} }}
onEdit={() => { onEdit={
setSelectedDepartment(dept); canUpdate("departments")
setIsEditModalOpen(true); ? () => {
}} setSelectedDepartment(dept);
setIsEditModalOpen(true);
}
: undefined
}
/> />
</div> </div>
), ),
@ -330,6 +338,17 @@ export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTable
/> />
)} )}
</div> </div>
{canCreate("departments") && (
<PrimaryButton
size="default"
className="flex items-center gap-2 w-full sm:w-auto"
onClick={() => setIsNewModalOpen(true)}
>
<Plus className="w-4 h-4" />
<span>New Department</span>
</PrimaryButton>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, type ReactElement } from "react"; import { useState, useEffect, type ReactElement, forwardRef, useImperativeHandle } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { import {
PrimaryButton, PrimaryButton,
@ -27,20 +27,27 @@ import type {
} from "@/types/designation"; } from "@/types/designation";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store"; import type { RootState } from "@/store/store";
import { usePermissions } from "@/hooks/usePermissions";
// import { useAppTheme } from "@/hooks/useAppTheme"; // import { useAppTheme } from "@/hooks/useAppTheme";
import CodeBadge from "../shared/CodeBadge"; import CodeBadge from "../shared/CodeBadge";
export interface DesignationsTableRef {
openNewModal: () => void;
refresh: () => void;
}
interface DesignationsTableProps { interface DesignationsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode) tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
compact?: boolean; // Compact mode for tabs compact?: boolean; // Compact mode for tabs
showHeader?: boolean; showHeader?: boolean;
} }
const DesignationsTable = ({ const DesignationsTable = forwardRef<DesignationsTableRef, DesignationsTableProps>(({
tenantId: propsTenantId, tenantId: propsTenantId,
compact = false, compact = false,
showHeader = true, showHeader = true,
}: DesignationsTableProps): ReactElement => { }, ref): ReactElement => {
const { canCreate, canUpdate } = usePermissions();
// const { primaryColor } = useAppTheme(); // const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId); const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId; const effectiveTenantId = propsTenantId || reduxTenantId;
@ -49,6 +56,12 @@ const DesignationsTable = ({
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Expose imperative methods
useImperativeHandle(ref, () => ({
openNewModal: () => setIsNewModalOpen(true),
refresh: () => fetchDesignations(),
}));
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(compact ? 10 : 5); const [limit, setLimit] = useState<number>(compact ? 10 : 5);
@ -226,14 +239,14 @@ const DesignationsTable = ({
setSelectedDesignation(desig); setSelectedDesignation(desig);
setIsViewModalOpen(true); setIsViewModalOpen(true);
}} }}
onEdit={() => { onEdit={
setSelectedDesignation(desig); canUpdate("designations")
setIsEditModalOpen(true); ? () => {
}} setSelectedDesignation(desig);
// onDelete={() => { setIsEditModalOpen(true);
// setSelectedDesignation(desig); }
// setIsDeleteModalOpen(true); : undefined
// }} }
/> />
</div> </div>
), ),
@ -257,14 +270,16 @@ const DesignationsTable = ({
onChange={(val) => setActiveOnly(val)} onChange={(val) => setActiveOnly(val)}
/> />
</div> </div>
<PrimaryButton {canCreate("designations") && (
size="default" <PrimaryButton
className="flex items-center gap-2 w-full sm:w-auto" size="default"
onClick={() => setIsNewModalOpen(true)} className="flex items-center gap-2 w-full sm:w-auto"
> onClick={() => setIsNewModalOpen(true)}
<Plus className="w-4 h-4" /> >
<span>New Designation</span> <Plus className="w-4 h-4" />
</PrimaryButton> <span>New Designation</span>
</PrimaryButton>
)}
</div> </div>
)} )}
@ -332,6 +347,6 @@ const DesignationsTable = ({
/> */} /> */}
</div> </div>
); );
}; });
export default DesignationsTable; export default DesignationsTable;

View File

@ -1,4 +1,4 @@
import { useState, useEffect, type ReactElement } from 'react'; import { useState, useEffect, type ReactElement, forwardRef, useImperativeHandle } from "react";
import { import {
PrimaryButton, PrimaryButton,
StatusBadge, StatusBadge,
@ -10,42 +10,62 @@ import {
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
SearchBox,
type Column, type Column,
} from '@/components/shared'; } from "@/components/shared";
import { Plus, Download, ArrowUpDown } from 'lucide-react'; import { Plus, ArrowUpDown } from "lucide-react";
import { roleService } from '@/services/role-service'; import { roleService } from "@/services/role-service";
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role'; import type { Role, CreateRoleRequest, UpdateRoleRequest } from "@/types/role";
import { showToast } from '@/utils/toast'; import { showToast } from "@/utils/toast";
import { formatDate } from '@/utils/format-date'; import { formatDate } from "@/utils/format-date";
// import { useAppTheme } from "@/hooks/useAppTheme";
import { usePermissions } from "@/hooks/usePermissions";
import CodeBadge from "@/components/shared/CodeBadge";
// Helper function to get scope badge variant // Helper function to get scope badge variant
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => { const getScopeVariant = (scope: string): "success" | "failure" | "process" => {
switch (scope.toLowerCase()) { switch (scope.toLowerCase()) {
case 'platform': case "platform":
return 'success'; return "success";
case 'tenant': case "tenant":
return 'process'; return "process";
case 'module': case "module":
return 'failure'; return "failure";
default: default:
return 'success'; return "success";
} }
}; };
export interface RolesTableRef {
openNewModal: () => void;
refresh: () => void;
}
interface RolesTableProps { interface RolesTableProps {
tenantId?: string | null; // If provided, fetch roles for this tenant only tenantId?: string | null; // If provided, fetch roles for this tenant only
showHeader?: boolean; // Show header with title and actions (default: true) showHeader?: boolean; // Show header with title and actions (default: true)
compact?: boolean; // Compact mode for tabs (default: false) compact?: boolean; // Compact mode for tabs (default: false)
} }
export const RolesTable = ({ tenantId, showHeader = true, compact = false }: RolesTableProps): ReactElement => { export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(({
tenantId,
showHeader = true,
compact = false,
}, ref): ReactElement => {
// const { primaryColor } = useAppTheme();
const { canCreate, canUpdate, canDelete } = usePermissions();
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false); const [isCreating, setIsCreating] = useState<boolean>(false);
// Expose imperative methods
useImperativeHandle(ref, () => ({
openNewModal: () => setIsModalOpen(true),
refresh: () => fetchRoles(currentPage, limit, orderBy, debouncedSearch),
}));
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5); const [limit, setLimit] = useState<number>(5);
@ -67,12 +87,16 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
// const [scopeFilter, setScopeFilter] = useState<string | null>(null); // const [scopeFilter, setScopeFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null); const [orderBy, setOrderBy] = useState<string[] | null>(null);
// Search state
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
// View, Edit, Delete modals // View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false); const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false); const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null); const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const [selectedRoleName, setSelectedRoleName] = useState<string>(''); const [selectedRoleName, setSelectedRoleName] = useState<string>("");
const [isUpdating, setIsUpdating] = useState<boolean>(false); const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false); const [isDeleting, setIsDeleting] = useState<boolean>(false);
@ -80,30 +104,40 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
page: number, page: number,
itemsPerPage: number, itemsPerPage: number,
// scope: string | null = null, // scope: string | null = null,
sortBy: string[] | null = null sortBy: string[] | null = null,
searchQuery: string | null = null,
): Promise<void> => { ): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const response = tenantId const response = tenantId
? await roleService.getByTenant(tenantId, page, itemsPerPage, sortBy) ? await roleService.getByTenant(tenantId, page, itemsPerPage, sortBy, searchQuery)
: await roleService.getAll(page, itemsPerPage, sortBy); : await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
if (response.success) { if (response.success) {
setRoles(response.data); setRoles(response.data);
setPagination(response.pagination); setPagination(response.pagination);
} else { } else {
setError('Failed to load roles'); setError("Failed to load roles");
} }
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load roles'); setError(err?.response?.data?.error?.message || "Failed to load roles");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Handle search debouncing
useEffect(() => { useEffect(() => {
fetchRoles(currentPage, limit, orderBy); const timer = setTimeout(() => {
}, [currentPage, limit, orderBy, tenantId]); setDebouncedSearch(search);
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
fetchRoles(currentPage, limit, orderBy, debouncedSearch);
}, [currentPage, limit, orderBy, debouncedSearch, tenantId]);
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => { const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
try { try {
@ -187,61 +221,71 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
// Table columns // Table columns
const columns: Column<Role>[] = [ const columns: Column<Role>[] = [
{ {
key: 'name', key: "name",
label: 'Name', label: "Name",
render: (role) => ( render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span> <span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
), ),
}, },
{ {
key: 'code', key: "code",
label: 'Code', label: "Code",
render: (role) => <CodeBadge label={role.code} />,
},
{
key: "scope",
label: "Scope",
render: (role) => ( render: (role) => (
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span> <StatusBadge variant={getScopeVariant(role.scope)}>
{role.scope}
</StatusBadge>
), ),
}, },
{ {
key: 'scope', key: "user_count",
label: 'Scope', label: "Users",
render: (role) => ( render: (role) => (
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge> <span className="text-sm font-normal text-[#0f1724]">
), {role.user_count || 0}
},
{
key: 'description',
label: 'Description',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">
{role.description || 'N/A'}
</span> </span>
), ),
}, },
// {
// key: 'is_system',
// label: 'System Role',
// render: (role) => (
// <span className="text-sm font-normal text-[#0f1724]">
// {role.is_system ? 'Yes' : 'No'}
// </span>
// ),
// },
{ {
key: 'created_at', key: "description",
label: 'Created Date', label: "Description",
render: (role) => ( render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span> <span className="text-sm font-normal text-[#6b7280]">
{role.description || "N/A"}
</span>
), ),
}, },
{ {
key: 'actions', key: "created_at",
label: 'Actions', label: "Created Date",
align: 'right', render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(role.created_at)}
</span>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (role) => ( render: (role) => (
<div className="flex justify-end"> <div className="flex justify-end">
<ActionDropdown <ActionDropdown
onView={() => handleViewRole(role.id)} onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)} onEdit={
onDelete={() => handleDeleteRole(role.id, role.name)} canUpdate("roles")
? () => handleEditRole(role.id, role.name)
: undefined
}
onDelete={
canDelete("roles")
? () => handleDeleteRole(role.id, role.name)
: undefined
}
/> />
</div> </div>
), ),
@ -253,25 +297,45 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
<div className="p-4"> <div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3"> <div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3> <h3 className="text-sm font-medium text-[#0f1724] truncate">
{role.name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p> <p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
</div> </div>
<ActionDropdown <ActionDropdown
onView={() => handleViewRole(role.id)} onView={() => handleViewRole(role.id)}
onEdit={() => handleEditRole(role.id, role.name)} onEdit={
onDelete={() => handleDeleteRole(role.id, role.name)} canUpdate("roles")
? () => handleEditRole(role.id, role.name)
: undefined
}
onDelete={
canDelete("roles")
? () => handleDeleteRole(role.id, role.name)
: undefined
}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3 text-xs">
<div> <div>
<span className="text-[#9aa6b2]">Scope:</span> <span className="text-[#9aa6b2]">Scope:</span>
<div className="mt-1"> <div className="mt-1">
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge> <StatusBadge variant={getScopeVariant(role.scope)}>
{role.scope}
</StatusBadge>
</div> </div>
</div> </div>
<div>
<span className="text-[#9aa6b2]">Users:</span>
<p className="text-[#0f1724] font-normal mt-1">
{role.user_count || 0}
</p>
</div>
<div> <div>
<span className="text-[#9aa6b2]">Created:</span> <span className="text-[#9aa6b2]">Created:</span>
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p> <p className="text-[#0f1724] font-normal mt-1">
{formatDate(role.created_at)}
</p>
</div> </div>
{role.description && ( {role.description && (
<div className="col-span-2"> <div className="col-span-2">
@ -288,32 +352,24 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<h3 className="text-lg font-semibold text-[#0f1724]"></h3> <h3 className="text-lg font-semibold text-[#0f1724]"></h3>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* <FilterDropdown <SearchBox
label="Scope" value={search}
options={[ onChange={setSearch}
{ value: '', label: 'All Scope' }, placeholder="Search..."
{ value: 'platform', label: 'Platform' }, />
{ value: 'tenant', label: 'Tenant' }, {canCreate("roles") && (
{ value: 'module', label: 'Module' }, <PrimaryButton
]} size="default"
value={scopeFilter || ''} className="flex items-center gap-2"
onChange={(value) => { onClick={() => setIsModalOpen(true)}
setScopeFilter(Array.isArray(value) ? null : value || null); >
setCurrentPage(1); <Plus className="w-3.5 h-3.5" />
}} <span className="text-xs">New Role</span>
placeholder="Filter by scope" </PrimaryButton>
/> */} )}
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New Role</span>
</PrimaryButton>
</div> </div>
</div> </div>
<DataTable <DataTable
@ -366,7 +422,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
onClose={() => { onClose={() => {
setEditModalOpen(false); setEditModalOpen(false);
setSelectedRoleId(null); setSelectedRoleId(null);
setSelectedRoleName(''); setSelectedRoleName("");
}} }}
roleId={selectedRoleId} roleId={selectedRoleId}
onLoadRole={loadRole} onLoadRole={loadRole}
@ -380,7 +436,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
onClose={() => { onClose={() => {
setDeleteModalOpen(false); setDeleteModalOpen(false);
setSelectedRoleId(null); setSelectedRoleId(null);
setSelectedRoleName(''); setSelectedRoleName("");
}} }}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
title="Delete Role" title="Delete Role"
@ -402,34 +458,25 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Scope Filter */} {/* Global Search */}
{/* <FilterDropdown <SearchBox
label="Scope" value={search}
options={[ onChange={setSearch}
{ value: 'platform', label: 'Platform' }, placeholder="Search by name or code..."
{ value: 'tenant', label: 'Tenant' }, />
{ value: 'module', label: 'Module' },
]}
value={scopeFilter}
onChange={(value) => {
setScopeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/> */}
{/* Sort Filter */} {/* Sort Filter */}
<FilterDropdown <FilterDropdown
label="Sort by" label="Sort by"
options={[ options={[
{ value: ['name', 'asc'], label: 'Name (A-Z)' }, { value: ["name", "asc"], label: "Name (A-Z)" },
{ value: ['name', 'desc'], label: 'Name (Z-A)' }, { value: ["name", "desc"], label: "Name (Z-A)" },
{ value: ['code', 'asc'], label: 'Code (A-Z)' }, { value: ["code", "asc"], label: "Code (A-Z)" },
{ value: ['code', 'desc'], label: 'Code (Z-A)' }, { value: ["code", "desc"], label: "Code (Z-A)" },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' }, { value: ["created_at", "asc"], label: "Created (Oldest)" },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' }, { value: ["created_at", "desc"], label: "Created (Newest)" },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' }, { value: ["updated_at", "asc"], label: "Updated (Oldest)" },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' }, { value: ["updated_at", "desc"], label: "Updated (Newest)" },
]} ]}
value={orderBy} value={orderBy}
onChange={(value) => { onChange={(value) => {
@ -445,23 +492,25 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Export Button */} {/* Export Button */}
<button {/* <button
type="button" type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer" className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button> */}
{/* New Role Button */} {/* New Role Button */}
<PrimaryButton {canCreate("roles") && (
size="default" <PrimaryButton
className="flex items-center gap-2" size="default"
onClick={() => setIsModalOpen(true)} className="flex items-center gap-2"
> onClick={() => setIsModalOpen(true)}
<Plus className="w-3.5 h-3.5" /> >
<span className="text-xs">New Role</span> <Plus className="w-3.5 h-3.5" />
</PrimaryButton> <span className="text-xs">New Role</span>
</PrimaryButton>
)}
</div> </div>
</div> </div>
)} )}
@ -489,7 +538,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
}} }}
onLimitChange={(newLimit: number) => { onLimitChange={(newLimit: number) => {
setLimit(newLimit); setLimit(newLimit);
setCurrentPage(1); setCurrentPage(1); // Reset to first page when limit changes
}} }}
/> />
)} )}
@ -543,4 +592,4 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
/> />
</> </>
); );
}; });

View File

@ -1,4 +1,4 @@
import { useState, useEffect, type ReactElement } from "react"; import { useState, useEffect, type ReactElement, forwardRef, useImperativeHandle } from "react";
import { import {
PrimaryButton, PrimaryButton,
StatusBadge, StatusBadge,
@ -13,13 +13,15 @@ import {
SearchBox, SearchBox,
type Column, type Column,
} from "@/components/shared"; } from "@/components/shared";
import { Plus, Download, ArrowUpDown } from "lucide-react"; import { Plus, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service"; import { userService } from "@/services/user-service";
import { roleService } from "@/services/role-service"; import { roleService } from "@/services/role-service";
import type { Role } from "@/types/role"; import type { Role } from "@/types/role";
import type { User } from "@/types/user"; import type { User } from "@/types/user";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date"; import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme";
import { usePermissions } from "@/hooks/usePermissions";
// Helper function to get user initials // Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => { const getUserInitials = (firstName: string, lastName: string): string => {
@ -46,23 +48,36 @@ const getStatusVariant = (
} }
}; };
export interface UsersTableRef {
openNewModal: () => void;
refresh: () => void;
}
interface UsersTableProps { interface UsersTableProps {
tenantId?: string | null; // If provided, fetch users for this tenant only tenantId?: string | null; // If provided, fetch users for this tenant only
showHeader?: boolean; // Show header with title and actions (default: true) showHeader?: boolean; // Show header with title and actions (default: true)
compact?: boolean; // Compact mode for tabs (default: false) compact?: boolean; // Compact mode for tabs (default: false)
} }
export const UsersTable = ({ export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(({
tenantId, tenantId,
showHeader = true, showHeader = true,
compact = false, compact = false,
}: UsersTableProps): ReactElement => { }, ref): ReactElement => {
const { primaryColor } = useAppTheme();
const { canCreate } = usePermissions();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false); const [isCreating, setIsCreating] = useState<boolean>(false);
// Expose imperative methods
useImperativeHandle(ref, () => ({
openNewModal: () => setIsModalOpen(true),
refresh: () => fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter),
}));
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(compact ? 10 : 5); const [limit, setLimit] = useState<number>(compact ? 10 : 5);
@ -322,7 +337,8 @@ export const UsersTable = ({
user.role_module_combinations.map((combo, idx) => ( user.role_module_combinations.map((combo, idx) => (
<span <span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`} key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]" className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"} title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
> >
{combo.role_name} {combo.module_name && `(${combo.module_name})`} {combo.role_name} {combo.module_name && `(${combo.module_name})`}
@ -332,7 +348,8 @@ export const UsersTable = ({
user.roles.map((role) => ( user.roles.map((role) => (
<span <span
key={role.id} key={role.id}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]" className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
> >
{role.name} {role.name}
</span> </span>
@ -502,7 +519,7 @@ export const UsersTable = ({
<FilterDropdown <FilterDropdown
label="Status" label="Status"
options={[ options={[
{ value: "", label: "All Status" }, // { value: "", label: "All Status" },
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
{ value: "suspended", label: "Suspended" }, { value: "suspended", label: "Suspended" },
{ value: "deleted", label: "Deleted" }, { value: "deleted", label: "Deleted" },
@ -512,12 +529,12 @@ export const UsersTable = ({
setStatusFilter(Array.isArray(value) ? null : value || null); setStatusFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="Filter by status" placeholder="All"
/> />
<FilterDropdown <FilterDropdown
label="Role" label="Role"
options={[ options={[
{ value: "", label: "All Roles" }, // { value: "", label: "All Roles" },
...roles.map(role => ({ value: role.id, label: role.name })) ...roles.map(role => ({ value: role.id, label: role.name }))
]} ]}
value={roleFilter || ""} value={roleFilter || ""}
@ -525,16 +542,18 @@ export const UsersTable = ({
setRoleFilter(Array.isArray(value) ? null : value || null); setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="Filter by role" placeholder="All"
/> />
<PrimaryButton {canCreate("users") && (
size="default" <PrimaryButton
className="flex items-center gap-2" size="default"
onClick={() => setIsModalOpen(true)} className="flex items-center gap-2"
> onClick={() => setIsModalOpen(true)}
<Plus className="w-3.5 h-3.5" /> >
<span className="text-xs">New User</span> <Plus className="w-3.5 h-3.5" />
</PrimaryButton> <span className="text-xs">New User</span>
</PrimaryButton>
)}
</div> </div>
</div> </div>
<DataTable <DataTable
@ -655,7 +674,7 @@ export const UsersTable = ({
<FilterDropdown <FilterDropdown
label="Role" label="Role"
options={[ options={[
{ value: "", label: "All Roles" }, // { value: "", label: "All Roles" },
...roles.map(role => ({ value: role.id, label: role.name })) ...roles.map(role => ({ value: role.id, label: role.name }))
]} ]}
value={roleFilter || ""} value={roleFilter || ""}
@ -663,7 +682,7 @@ export const UsersTable = ({
setRoleFilter(Array.isArray(value) ? null : value || null); setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1); setCurrentPage(1);
}} }}
placeholder="Filter by role" placeholder="All"
/> />
{/* Sort Filter */} {/* Sort Filter */}
@ -695,23 +714,25 @@ export const UsersTable = ({
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Export Button */} {/* Export Button */}
<button {/* <button
type="button" type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer" className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span>Export</span>
</button> </button> */}
{/* New User Button */} {/* New User Button */}
<PrimaryButton {canCreate("users") && (
size="default" <PrimaryButton
className="flex items-center gap-2" size="default"
onClick={() => setIsModalOpen(true)} className="flex items-center gap-2"
> onClick={() => setIsModalOpen(true)}
<Plus className="w-3.5 h-3.5" /> >
<span className="text-xs">New User</span> <Plus className="w-3.5 h-3.5" />
</PrimaryButton> <span className="text-xs">New User</span>
</PrimaryButton>
)}
</div> </div>
</div> </div>
)} )}
@ -792,4 +813,4 @@ export const UsersTable = ({
/> */} /> */}
</> </>
); );
}; });

View File

@ -6,7 +6,7 @@ export { ViewModuleModal } from './ViewModuleModal';
export { EditModuleModal } from './EditModuleModal'; export { EditModuleModal } from './EditModuleModal';
export { WebhookSyncModal } from './WebhookSyncModal'; export { WebhookSyncModal } from './WebhookSyncModal';
export { ApikeyReissueModal } from './ApikeyReissueModal'; export { ApikeyReissueModal } from './ApikeyReissueModal';
export { UsersTable } from './UsersTable'; export { UsersTable, type UsersTableRef } from './UsersTable';
export { RolesTable } from './RolesTable'; export { RolesTable, type RolesTableRef } from './RolesTable';
export { DepartmentsTable, type DepartmentsTableRef } from './DepartmentsTable'; export { DepartmentsTable, type DepartmentsTableRef } from './DepartmentsTable';
export { default as DesignationsTable } from './DesignationsTable'; export { default as DesignationsTable, type DesignationsTableRef } from './DesignationsTable';

View File

@ -772,7 +772,7 @@ const CreateTenantWizard = (): ReactElement => {
</div> </div>
</div> </div>
{/* Max Users and Max Modules Row */} {/* Max Users and Max Modules Row */}
<div className="flex gap-5"> {/* <div className="flex gap-5">
<div className="flex-1"> <div className="flex-1">
<FormField <FormField
label="Max Users" label="Max Users"
@ -821,7 +821,7 @@ const CreateTenantWizard = (): ReactElement => {
})} })}
/> />
</div> </div>
</div> </div> */}
{/* Modules Multiselect */} {/* Modules Multiselect */}
<MultiselectPaginatedSelect <MultiselectPaginatedSelect
label="Modules" label="Modules"

View File

@ -925,7 +925,7 @@ const EditTenant = (): ReactElement => {
/> />
</div> </div>
</div> </div>
<div className="flex gap-5"> {/* <div className="flex gap-5">
<div className="flex-1"> <div className="flex-1">
<FormField <FormField
label="Max Users" label="Max Users"
@ -974,7 +974,7 @@ const EditTenant = (): ReactElement => {
})} })}
/> />
</div> </div>
</div> </div> */}
<MultiselectPaginatedSelect <MultiselectPaginatedSelect
label="Modules" label="Modules"
placeholder="Select modules" placeholder="Select modules"

View File

@ -304,16 +304,16 @@ const TenantDetails = (): ReactElement => {
<OverviewTab tenant={tenant} stats={stats} /> <OverviewTab tenant={tenant} stats={stats} />
)} )}
{activeTab === "users" && id && ( {activeTab === "users" && id && (
<UsersTable tenantId={id} compact={true} /> <UsersTable tenantId={id} compact={false} />
)} )}
{activeTab === "roles" && id && ( {activeTab === "roles" && id && (
<RolesTable tenantId={id} compact={true} /> <RolesTable tenantId={id} compact={false} />
)} )}
{activeTab === "departments" && id && ( {activeTab === "departments" && id && (
<DepartmentsTable ref={departmentsRef} tenantId={id} compact={true} /> <DepartmentsTable ref={departmentsRef} tenantId={id} compact={true} />
)} )}
{activeTab === "designations" && id && ( {activeTab === "designations" && id && (
<DesignationsTable tenantId={id} compact={true} /> <DesignationsTable tenantId={id} compact={false} />
)} )}
{/* {activeTab === "user-categories" && id && ( {/* {activeTab === "user-categories" && id && (
<UserCategoriesTable tenantId={id} compact={true} /> <UserCategoriesTable tenantId={id} compact={true} />
@ -437,7 +437,7 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
{tenant.subscription_tier || "N/A"} {tenant.subscription_tier || "N/A"}
</div> </div>
</div> </div>
<div> {/* <div>
<div className="text-sm font-medium text-[#6b7280] mb-1"> <div className="text-sm font-medium text-[#6b7280] mb-1">
Max Users Max Users
</div> </div>
@ -452,7 +452,7 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
<div className="text-sm font-normal text-[#0f1724]"> <div className="text-sm font-normal text-[#0f1724]">
{tenant.max_modules || "Unlimited"} {tenant.max_modules || "Unlimited"}
</div> </div>
</div> </div> */}
<div> <div>
<div className="text-sm font-medium text-[#6b7280] mb-1"> <div className="text-sm font-medium text-[#6b7280] mb-1">
Created At Created At

View File

@ -5,7 +5,7 @@ import {
PrimaryButton, PrimaryButton,
StatusBadge, StatusBadge,
ActionDropdown, ActionDropdown,
DeleteConfirmationModal, // DeleteConfirmationModal,
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
@ -94,10 +94,10 @@ const Tenants = (): ReactElement => {
// View, Edit, Delete modals // View, Edit, Delete modals
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead // const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead // const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false); // const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null); // const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
const [selectedTenantName, setSelectedTenantName] = useState<string>(""); // const [selectedTenantName, setSelectedTenantName] = useState<string>("");
const [isDeleting, setIsDeleting] = useState<boolean>(false); // const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchTenants = async ( const fetchTenants = async (
page: number, page: number,
@ -181,29 +181,29 @@ const Tenants = (): ReactElement => {
// Update tenant handler - removed, now handled in EditTenant page // Update tenant handler - removed, now handled in EditTenant page
// Delete tenant handler // Delete tenant handler
const handleDeleteTenant = (tenantId: string, tenantName: string): void => { // const handleDeleteTenant = (tenantId: string, tenantName: string): void => {
setSelectedTenantId(tenantId); // setSelectedTenantId(tenantId);
setSelectedTenantName(tenantName); // setSelectedTenantName(tenantName);
setDeleteModalOpen(true); // setDeleteModalOpen(true);
}; // };
// Confirm delete handler // Confirm delete handler
const handleConfirmDelete = async (): Promise<void> => { // const handleConfirmDelete = async (): Promise<void> => {
if (!selectedTenantId) return; // if (!selectedTenantId) return;
try { // try {
setIsDeleting(true); // setIsDeleting(true);
await tenantService.delete(selectedTenantId); // await tenantService.delete(selectedTenantId);
setDeleteModalOpen(false); // setDeleteModalOpen(false);
setSelectedTenantId(null); // setSelectedTenantId(null);
setSelectedTenantName(""); // setSelectedTenantName("");
await fetchTenants(currentPage, limit, statusFilter, orderBy); // await fetchTenants(currentPage, limit, statusFilter, orderBy);
} catch (err: any) { // } catch (err: any) {
throw err; // Let the modal handle the error display // throw err; // Let the modal handle the error display
} finally { // } finally {
setIsDeleting(false); // setIsDeleting(false);
} // }
}; // };
// Define table columns // Define table columns
const columns: Column<Tenant>[] = [ const columns: Column<Tenant>[] = [
@ -252,17 +252,17 @@ const Tenants = (): ReactElement => {
), ),
}, },
{ {
key: "max_users", key: "user_count",
label: "Users", label: "Users",
render: (tenant) => ( render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]"> <span className="text-sm font-normal text-[#0f1724]">
{tenant.max_users ?? "N/A"} {tenant.user_count ?? 0}
</span> </span>
), ),
}, },
{ {
key: "subscription_tier", key: "subscription_tier",
label: "Plan", label: "Subscription Tier",
render: (tenant) => ( render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]"> <span className="text-sm font-normal text-[#0f1724]">
{formatSubscriptionTier(tenant.subscription_tier)} {formatSubscriptionTier(tenant.subscription_tier)}
@ -270,11 +270,11 @@ const Tenants = (): ReactElement => {
), ),
}, },
{ {
key: "max_modules", key: "module_count",
label: "Modules", label: "Modules",
render: (tenant) => ( render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]"> <span className="text-sm font-normal text-[#0f1724]">
{tenant.max_modules ?? "N/A"} {tenant.module_count ?? 0}
</span> </span>
), ),
}, },
@ -297,7 +297,7 @@ const Tenants = (): ReactElement => {
<ActionDropdown <ActionDropdown
onView={() => handleViewTenant(tenant.id)} onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id)} onEdit={() => handleEditTenant(tenant.id)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} // onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
/> />
</div> </div>
), ),
@ -329,7 +329,7 @@ const Tenants = (): ReactElement => {
<ActionDropdown <ActionDropdown
onView={() => handleViewTenant(tenant.id)} onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id)} onEdit={() => handleEditTenant(tenant.id)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} // onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3 text-xs">
@ -485,29 +485,8 @@ const Tenants = (): ReactElement => {
)} )}
</div> </div>
{/* New Tenant Modal - Commented out, using wizard instead */}
{/* <NewTenantModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateTenant}
isLoading={isCreating}
/> */}
{/* View Tenant Modal - Commented out, using details page instead */}
{/* <ViewTenantModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedTenantId(null);
}}
tenantId={selectedTenantId}
onLoadTenant={loadTenant}
/> */}
{/* Edit Tenant Modal - Removed, using edit page instead */}
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
<DeleteConfirmationModal {/* <DeleteConfirmationModal
isOpen={deleteModalOpen} isOpen={deleteModalOpen}
onClose={() => { onClose={() => {
setDeleteModalOpen(false); setDeleteModalOpen(false);
@ -519,7 +498,7 @@ const Tenants = (): ReactElement => {
message="Are you sure you want to delete this tenant" message="Are you sure you want to delete this tenant"
itemName={selectedTenantName} itemName={selectedTenantName}
isLoading={isDeleting} isLoading={isDeleting}
/> /> */}
</Layout> </Layout>
); );
}; };

View File

@ -1,30 +1,17 @@
import { type ReactElement, useRef } from 'react'; import { type ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout'; import { Layout } from '@/components/layout/Layout';
import { DepartmentsTable, type DepartmentsTableRef } from '@/components/superadmin'; import { DepartmentsTable } from '@/components/superadmin';
import { PrimaryButton } from '@/components/shared';
import { Plus } from 'lucide-react';
const Departments = (): ReactElement => { const Departments = (): ReactElement => {
const tableRef = useRef<DepartmentsTableRef>(null);
return ( return (
<Layout <Layout
currentPage="Departments" currentPage="Departments"
pageHeader={{ pageHeader={{
title: 'Department Management', title: 'Department Management',
description: 'View and manage all departments within your organization.', description: 'View and manage all departments within your organization.',
action: (
<PrimaryButton
onClick={() => tableRef.current?.openNewModal()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
<span>New Department</span>
</PrimaryButton>
)
}} }}
> >
<DepartmentsTable ref={tableRef} /> <DepartmentsTable />
</Layout> </Layout>
); );
}; };

View File

@ -13,7 +13,6 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "re
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { import {
Search,
Upload, Upload,
FileText, FileText,
Image, Image,
@ -22,7 +21,12 @@ import {
ChevronDown, ChevronDown,
} from "lucide-react"; } from "lucide-react";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { Pagination } from "@/components/shared"; import {
Pagination,
DataTable,
SearchBox,
type Column,
} from "@/components/shared";
import { DeleteConfirmationModal } from "@/components/shared/DeleteConfirmationModal"; import { DeleteConfirmationModal } from "@/components/shared/DeleteConfirmationModal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import fileAttachmentService, { import fileAttachmentService, {
@ -212,6 +216,126 @@ const FilesList = (): ReactElement => {
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*") (p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*")
); );
// Table columns
const columns = useMemo<Column<FileAttachment>[]>(() => [
{
key: "original_name",
label: "File Name",
render: (file) => (
<button
onClick={() => navigate(`/tenant/files/${file.id}`)}
className="flex items-center gap-2.5 transition-colors text-left group/link"
>
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
</div>
<span
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
onMouseEnter={(e) => e.currentTarget.style.color = primaryColor}
onMouseLeave={(e) => e.currentTarget.style.color = '#0e1b2a'}
>
{file.original_name}
</span>
</button>
),
},
{
key: "file_size",
label: "Size",
render: (file) => (
<span className="text-sm text-[#6b7280]">
{file.file_size_formatted || formatBytes(file.file_size)}
</span>
),
},
{
key: "category",
label: "Category",
render: (file) => (
file.category ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getCategoryStyle(file.category)
)}
>
{file.category}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)
),
},
{
key: "source_module",
label: "Source Module",
render: (file) => (
file.source_module ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getModuleStyle(file.source_module)
)}
>
{file.source_module}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)
),
},
{
key: "uploaded_by_email",
label: "Uploaded By",
render: (file) => (
file.uploaded_by_email ? (
<div className="flex items-center gap-2">
<div
className={cn(
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
getAvatarColor(file.uploaded_by_email)
)}
>
{getInitials(file.uploaded_by_email)}
</div>
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
{file.uploaded_by_email.split("@")[0]}
</span>
</div>
) : (
<span className="text-sm text-[#9aa6b2]">Unknown</span>
)
),
},
{
key: "created_at",
label: "Upload Date",
render: (file) => (
<span className="text-sm text-[#6b7280]">{formatDate(file.created_at)}</span>
),
},
{
key: "version",
label: "Version",
render: (file) => (
<span className="text-sm text-[#0e1b2a] font-medium">v{file.version}</span>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (file) => (
<ActionDropdown
onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)}
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
/>
),
},
], [canUpdate, canDelete, navigate, primaryColor]);
// ── State ── // ── State ──
const [files, setFiles] = useState<FileAttachment[]>([]); const [files, setFiles] = useState<FileAttachment[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@ -315,6 +439,64 @@ const FilesList = (): ReactElement => {
setCurrentPage(1); setCurrentPage(1);
}; };
// Mobile card renderer
const mobileCardRenderer = (file: FileAttachment) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
</div>
<div className="flex flex-col">
<h3
className="text-sm font-medium text-[#0e1b2a] truncate cursor-pointer hover:underline"
style={{ '--hover-color': primaryColor } as any}
onClick={() => navigate(`/tenant/files/${file.id}`)}
>
{file.original_name}
</h3>
<p className="text-xs text-[#6b7280]">
{file.file_size_formatted || formatBytes(file.file_size)} v{file.version}
</p>
</div>
</div>
</div>
<ActionDropdown
onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)}
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
/>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{file.category && (
<span
className={cn(
"px-2 py-0.5 rounded font-semibold capitalize",
getCategoryStyle(file.category)
)}
>
{file.category}
</span>
)}
{file.source_module && (
<span
className={cn(
"px-2 py-0.5 rounded font-semibold capitalize",
getModuleStyle(file.source_module)
)}
>
{file.source_module}
</span>
)}
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">
{formatDate(file.created_at)}
</span>
</div>
</div>
);
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// Render // Render
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@ -345,30 +527,11 @@ const FilesList = (): ReactElement => {
<div className="border-b border-[rgba(0,0,0,0.08)] px-5 py-3.5"> <div className="border-b border-[rgba(0,0,0,0.08)] px-5 py-3.5">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Search */} {/* Search */}
<div className="relative"> <SearchBox
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" /> value={search}
<input onChange={(v) => { setSearch(v); setCurrentPage(1); }}
id="files-search" placeholder="Search by name, ID..."
type="text" />
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
placeholder="Search by name, ID..."
className="h-9 w-[240px] pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.1)] rounded-lg text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2"
style={{
// @ts-ignore
'--tw-ring-color': `${primaryColor}33`,
borderColor: 'rgba(0,0,0,0.1)'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}33`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.1)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{/* Category filter */} {/* Category filter */}
<FilterPill <FilterPill
@ -409,165 +572,15 @@ const FilesList = (): ReactElement => {
</div> </div>
{/* Table */} {/* Table */}
<div className="overflow-x-auto"> <DataTable
<table className="w-full"> data={files}
<thead> columns={columns}
<tr className="border-b border-[rgba(0,0,0,0.06)]"> keyExtractor={(file) => file.id}
{["File Name", "Size", "Category", "Source Module", "Uploaded By", "Upload Date", "Version", "Actions"].map((h) => ( isLoading={isLoading}
<th error={error}
key={h} emptyMessage="No files found"
className="px-4 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide whitespace-nowrap" mobileCardRenderer={mobileCardRenderer}
> />
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((__, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 bg-gray-100 rounded animate-pulse" />
</td>
))}
</tr>
))
) : error ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-sm text-red-500">
{error}
</td>
</tr>
) : files.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center">
<div className="flex flex-col items-center gap-2">
<FileText className="w-10 h-10 text-gray-200" />
<p className="text-sm text-[#9aa6b2]">No files found</p>
{canCreate && (
<button
onClick={() => setShowUpload(true)}
className="mt-2 text-sm font-medium hover:underline"
style={{ color: primaryColor }}
>
Upload your first file
</button>
)}
</div>
</td>
</tr>
) : (
files.map((file) => (
<tr
key={file.id}
className="hover:bg-[#f6f9ff]/60 transition-colors group"
>
{/* File Name */}
<td className="px-4 py-3 min-w-[200px]">
<button
onClick={() => navigate(`/tenant/files/${file.id}`)}
className="flex items-center gap-2.5 transition-colors text-left group/link"
>
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
</div>
<span
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
onMouseEnter={(e) => e.currentTarget.style.color = primaryColor}
onMouseLeave={(e) => e.currentTarget.style.color = '#0e1b2a'}
>
{file.original_name}
</span>
</button>
</td>
{/* Size */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-sm text-[#6b7280]">
{file.file_size_formatted || formatBytes(file.file_size)}
</span>
</td>
{/* Category */}
<td className="px-4 py-3">
{file.category ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getCategoryStyle(file.category)
)}
>
{file.category}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)}
</td>
{/* Source Module */}
<td className="px-4 py-3">
{file.source_module ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getModuleStyle(file.source_module)
)}
>
{file.source_module}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)}
</td>
{/* Uploaded By */}
<td className="px-4 py-3 min-w-[140px]">
{file.uploaded_by_email ? (
<div className="flex items-center gap-2">
<div
className={cn(
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
getAvatarColor(file.uploaded_by_email)
)}
>
{getInitials(file.uploaded_by_email)}
</div>
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
{file.uploaded_by_email.split("@")[0]}
</span>
</div>
) : (
<span className="text-sm text-[#9aa6b2]">Unknown</span>
)}
</td>
{/* Upload Date */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-sm text-[#6b7280]">{formatDate(file.created_at)}</span>
</td>
{/* Version */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-sm text-[#0e1b2a] font-medium">v{file.version}</span>
</td>
{/* Actions */}
<td className="px-4 py-3">
<ActionDropdown
onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)}
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
/>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */} {/* Pagination */}
{total > 0 && ( {total > 0 && (

View File

@ -1,480 +1,17 @@
import { useState, useEffect } from 'react'; import { type ReactElement } from "react";
import type { ReactElement } from 'react'; import { Layout } from "@/components/layout/Layout";
import { Layout } from '@/components/layout/Layout'; import { RolesTable } from "@/components/superadmin";
import {
PrimaryButton,
StatusBadge,
ActionDropdown,
ViewRoleModal,
EditRoleModal,
DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from '@/components/shared';
import { Plus, ArrowUpDown } from 'lucide-react';
import { roleService } from '@/services/role-service';
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast';
import { NewRoleModal } from '@/components/shared/NewRoleModal';
import { usePermissions } from '@/hooks/usePermissions';
import CodeBadge from '@/components/shared/CodeBadge';
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
// Helper function to get scope badge variant
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
switch (scope.toLowerCase()) {
case 'platform':
return 'success';
case 'tenant':
return 'process';
case 'module':
return 'failure';
default:
return 'success';
}
};
const Roles = (): ReactElement => { const Roles = (): ReactElement => {
const { canCreate, canUpdate, canDelete } = usePermissions();
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const [pagination, setPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasMore: false,
});
// Filter state
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// Search state
const [search, setSearch] = useState<string>('');
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
// View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const [selectedRoleName, setSelectedRoleName] = useState<string>('');
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchRoles = async (
page: number,
itemsPerPage: number,
// scope: string | null = null,
sortBy: string[] | null = null,
searchQuery: string | null = null
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
if (response.success) {
setRoles(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load roles');
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load roles');
} finally {
setIsLoading(false);
}
};
// Handle search debouncing
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
fetchRoles(currentPage, limit, orderBy, debouncedSearch);
}, [currentPage, limit, orderBy, debouncedSearch]);
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
try {
setIsCreating(true);
const response = await roleService.create(data);
const message = response.message || `Role created successfully`;
const description = response.message ? undefined : `${data.name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchRoles(currentPage, limit, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsCreating(false);
}
};
// View role handler
const handleViewRole = (roleId: string): void => {
setSelectedRoleId(roleId);
setViewModalOpen(true);
};
// Edit role handler
const handleEditRole = (roleId: string, roleName: string): void => {
setSelectedRoleId(roleId);
setSelectedRoleName(roleName);
setEditModalOpen(true);
};
// Update role handler
const handleUpdateRole = async (
id: string,
data: UpdateRoleRequest
): Promise<void> => {
try {
setIsUpdating(true);
const response = await roleService.update(id, data);
const message = response.message || `Role updated successfully`;
const description = response.message ? undefined : `${data.name} has been updated`;
showToast.success(message, description);
setEditModalOpen(false);
setSelectedRoleId(null);
await fetchRoles(currentPage, limit, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsUpdating(false);
}
};
// Delete role handler
const handleDeleteRole = (roleId: string, roleName: string): void => {
setSelectedRoleId(roleId);
setSelectedRoleName(roleName);
setDeleteModalOpen(true);
};
// Confirm delete handler
const handleConfirmDelete = async (): Promise<void> => {
if (!selectedRoleId) return;
try {
setIsDeleting(true);
await roleService.delete(selectedRoleId);
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
await fetchRoles(currentPage, limit, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsDeleting(false);
}
};
// Load role for view/edit
const loadRole = async (id: string): Promise<Role> => {
const response = await roleService.getById(id);
return response.data;
};
// Table columns
const columns: Column<Role>[] = [
{
key: 'name',
label: 'Name',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
),
},
{
key: 'code',
label: 'Code',
render: (role) => (
<CodeBadge label={role.code} />
),
},
{
key: 'scope',
label: 'Scope',
render: (role) => (
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
),
},
{
key: 'user_count',
label: 'Users',
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">
{role.user_count || 0}
</span>
),
},
{
key: 'description',
label: 'Description',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">
{role.description || 'N/A'}
</span>
),
},
// {
// key: 'is_system',
// label: 'System Role',
// render: (role) => (
// <span className="text-sm font-normal text-[#0f1724]">
// {role.is_system ? 'Yes' : 'No'}
// </span>
// ),
// },
{
key: 'created_at',
label: 'Created Date',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (role) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
/>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (role: Role) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
</div>
<ActionDropdown
onView={() => handleViewRole(role.id)}
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Scope:</span>
<div className="mt-1">
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Users:</span>
<p className="text-[#0f1724] font-normal mt-1">{role.user_count || 0}</p>
</div>
<div>
<span className="text-[#9aa6b2]">Created:</span>
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
</div>
{role.description && (
<div className="col-span-2">
<span className="text-[#9aa6b2]">Description:</span>
<p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
</div>
)}
</div>
</div>
);
return ( return (
<Layout <Layout
currentPage="Roles" currentPage="Roles"
pageHeader={{ pageHeader={{
title: 'Role List', title: "Role Management",
description: 'Define and manage roles to control user access based on job responsibilities', description: "Define and manage roles to control user access based on job responsibilities",
}} }}
> >
{/* Table Container */} <RolesTable showHeader={true} />
<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">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name, code or description..."
/>
{/* Scope Filter */}
{/* <FilterDropdown
label="Scope"
options={[
{ value: 'platform', label: 'Platform' },
{ value: 'tenant', label: 'Tenant' },
{ value: 'module', label: 'Module' },
]}
value={scopeFilter}
onChange={(value) => {
setScopeFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/> */}
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
{ value: ['code', 'asc'], label: 'Code (A-Z)' },
{ value: ['code', 'desc'], label: 'Code (Z-A)' },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
{/* <button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button> */}
{/* New Role Button */}
{canCreate('roles') && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New Role</span>
</PrimaryButton>
)}
</div>
</div>
{/* Table */}
<DataTable
data={roles}
columns={columns}
keyExtractor={(role) => role.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No roles found"
isLoading={isLoading}
error={error}
/>
{/* Table Footer with Pagination */}
{pagination.total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1);
}}
/>
)}
</div>
{/* New Role Modal */}
<NewRoleModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateRole}
isLoading={isCreating}
/>
{/* View Role Modal */}
<ViewRoleModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedRoleId(null);
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
/>
{/* Edit Role Modal */}
<EditRoleModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
onSubmit={handleUpdateRole}
isLoading={isUpdating}
/>
{/* Delete Confirmation Modal */}
<DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
}}
onConfirm={handleConfirmDelete}
title="Delete Role"
message={`Are you sure you want to delete this role`}
itemName={selectedRoleName}
isLoading={isDeleting}
/>
</Layout> </Layout>
); );
}; };

View File

@ -1,652 +1,17 @@
import { useState, useEffect } from "react"; import { type ReactElement } from "react";
import type { ReactElement } from "react";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import { import { UsersTable } from "@/components/superadmin";
PrimaryButton,
StatusBadge,
ActionDropdown,
NewUserModal,
ViewUserModal,
EditUserModal,
// DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from "@/components/shared";
import { Plus, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service";
import { roleService } from "@/services/role-service";
import type { User } from "@/types/user";
import type { Role } from "@/types/role";
import { showToast } from "@/utils/toast";
import { usePermissions } from "@/hooks/usePermissions";
import { useAppTheme } from "@/hooks/useAppTheme";
// Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => {
return `${firstName[0]}${lastName[0]}`.toUpperCase();
};
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
// Helper function to get status badge variant
const getStatusVariant = (
status: string,
): "success" | "failure" | "process" => {
switch (status.toLowerCase()) {
case "active":
return "success";
case "pending_verification":
return "process";
case "inactive":
return "failure";
case "deleted":
return "failure";
case "suspended":
return "process";
default:
return "success";
}
};
const Users = (): ReactElement => { const Users = (): ReactElement => {
const { primaryColor } = useAppTheme();
const { canCreate, canUpdate
// , canDelete
} = usePermissions();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const [pagination, setPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasMore: false,
});
// Filter state
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
// Search state
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
// Roles list for filter
const [roles, setRoles] = useState<Role[]>([]);
// View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
// const [selectedUserName, setSelectedUserName] = useState<string>("");
const [isUpdating, setIsUpdating] = useState<boolean>(false);
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchUsers = async (
page: number,
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null,
searchQuery: string | null = null,
roleId: string | null = null,
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await userService.getAll(
page,
itemsPerPage,
status,
sortBy,
searchQuery,
roleId,
);
if (response.success) {
setUsers(response.data);
setPagination(response.pagination);
} else {
setError("Failed to load users");
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || "Failed to load users");
} finally {
setIsLoading(false);
}
};
// Handle search debouncing
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
// We only reset to first page if we are actively searching.
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
// Fetch roles for filter
useEffect(() => {
const fetchRoles = async () => {
try {
const response = await roleService.getAll(1, 100);
if (response.success) {
setRoles(response.data);
}
} catch (err) {
console.error("Failed to fetch roles:", err);
}
};
fetchRoles();
}, []);
// Fetch users on mount and when pagination/filters change
useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter);
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter]);
const handleCreateUser = async (data: {
email: string;
password: string;
first_name: string;
last_name: string;
status: "active" | "suspended" | "deleted";
auth_provider: "local";
role_module_combinations: { role_id: string; module_id?: string | null }[];
department_id?: string;
designation_id?: string;
}): Promise<void> => {
try {
setIsCreating(true);
const response = await userService.create(data);
const message = response.message || `User created successfully`;
const description = response.message
? undefined
: `${data.first_name} ${data.last_name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsCreating(false);
}
};
// View user handler
const handleViewUser = (userId: string): void => {
setSelectedUserId(userId);
setViewModalOpen(true);
};
// Edit user handler
const handleEditUser = (userId: string
// , userName: string
): void => {
setSelectedUserId(userId);
// setSelectedUserName(userName);
setEditModalOpen(true);
};
// Update user handler
const handleUpdateUser = async (
id: string,
data: {
email: string;
first_name: string;
last_name: string;
status: "active" | "suspended" | "deleted";
tenant_id: string;
role_module_combinations: { role_id: string; module_id?: string | null }[];
department_id?: string;
designation_id?: string;
},
): Promise<void> => {
try {
setIsUpdating(true);
const response = await userService.update(id, data);
const message = response.message || `User updated successfully`;
const description = response.message
? undefined
: `${data.first_name} ${data.last_name} has been updated`;
showToast.success(message, description);
setEditModalOpen(false);
setSelectedUserId(null);
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsUpdating(false);
}
};
// Delete user handler
// const handleDeleteUser = (userId: string, userName: string): void => {
// setSelectedUserId(userId);
// setSelectedUserName(userName);
// setDeleteModalOpen(true);
// };
// Confirm delete handler
// const handleConfirmDelete = async (): Promise<void> => {
// if (!selectedUserId) return;
// try {
// setIsDeleting(true);
// await userService.delete(selectedUserId);
// setDeleteModalOpen(false);
// setSelectedUserId(null);
// setSelectedUserName("");
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsDeleting(false);
// }
// };
// Load user for view/edit
const loadUser = async (id: string): Promise<User> => {
const response = await userService.getById(id);
return response.data;
};
// Define table columns
const columns: Column<User>[] = [
{
key: "name",
label: "User Name",
render: (user) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{getUserInitials(user.first_name, user.last_name)}
</span>
</div>
<span className="text-sm font-normal text-[#0f1724]">
{user.first_name} {user.last_name}
</span>
</div>
),
mobileLabel: "Name",
},
{
key: "email",
label: "Email",
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">{user.email}</span>
),
},
{
key: "role",
label: "Role",
render: (user) => (
<div className="flex flex-wrap gap-1">
{user.role_module_combinations && user.role_module_combinations.length > 0 ? (
user.role_module_combinations.map((combo, idx) => (
<span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
>
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
</span>
))
) : user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
>
{role.name}
</span>
))
) : (
<span className="text-sm font-normal text-[#0f1724]">
{user.role?.name || "-"}
</span>
)}
</div>
),
},
{
key: "status",
label: "Status",
render: (user) => (
<StatusBadge variant={getStatusVariant(user.status)}>
{user.status}
</StatusBadge>
),
},
{
key: "auth_provider",
label: "Auth Provider",
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">
{user.auth_provider}
</span>
),
},
{
key: "created_at",
label: "Joined Date",
render: (user) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(user.created_at)}
</span>
),
mobileLabel: "Joined",
},
{
key: "actions",
label: "Actions",
align: "right",
render: (user) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={
canUpdate("users")
? () =>
handleEditUser(
user.id,
// `${user.first_name} ${user.last_name}`,
)
: undefined
}
// onDelete={
// canDelete("users")
// ? () =>
// handleDeleteUser(
// user.id,
// `${user.first_name} ${user.last_name}`,
// )
// : undefined
// }
/>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (user: User) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{getUserInitials(user.first_name, user.last_name)}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">
{user.first_name} {user.last_name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate">
{user.email}
</p>
</div>
</div>
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={
canUpdate("users")
? () =>
handleEditUser(
user.id,
// `${user.first_name} ${user.last_name}`,
)
: undefined
}
// onDelete={
// canDelete("users")
// ? () =>
// handleDeleteUser(
// user.id,
// `${user.first_name} ${user.last_name}`,
// )
// : undefined
// }
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Status:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(user.status)}>
{user.status}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Auth Provider:</span>
<p className="text-[#0f1724] font-normal mt-1">
{user.auth_provider}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Joined:</span>
<p className="text-[#6b7280] font-normal mt-1">
{formatDate(user.created_at)}
</p>
</div>
</div>
</div>
);
return ( return (
<Layout <Layout
currentPage="Users" currentPage="Users"
pageHeader={{ pageHeader={{
title: "User List", title: "User Management",
description: description: "View and manage all users within your organization.",
"View and manage all users in your QAssure platform from a single place.",
}} }}
> >
{/* Table Container */} <UsersTable showHeader={true} />
<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">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or email..."
/>
{/* Status Filter */}
<FilterDropdown
label="Status"
options={[
{ value: "active", label: "Active" },
// {
// value: "pending_verification",
// label: "Pending Verification",
// },
// { value: "inactive", label: "Inactive" },
{ value: "suspended", label: "Suspended" },
{ value: "deleted", label: "Deleted" },
]}
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string | null);
setCurrentPage(1); // Reset to first page when filter changes
}}
placeholder="All"
/>
{/* Role Filter */}
<FilterDropdown
label="Role"
options={[
// { value: "", label: "All" },
...roles.map(role => ({ value: role.id, label: role.name }))
]}
value={roleFilter || ""}
onChange={(value) => {
setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="All"
/>
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ["first_name", "asc"], label: "First Name (A-Z)" },
{ value: ["first_name", "desc"], label: "First Name (Z-A)" },
{ value: ["last_name", "asc"], label: "Last Name (A-Z)" },
{ value: ["last_name", "desc"], label: "Last Name (Z-A)" },
{ value: ["email", "asc"], label: "Email (A-Z)" },
{ value: ["email", "desc"], label: "Email (Z-A)" },
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
{ value: ["created_at", "desc"], label: "Created (Newest)" },
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1); // Reset to first page when sort changes
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
{/* <button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button> */}
{/* New User Button */}
{canCreate("users") && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New User</span>
</PrimaryButton>
)}
</div>
</div>
{/* Data Table */}
<DataTable
data={users}
columns={columns}
keyExtractor={(user) => user.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No users found"
isLoading={isLoading}
error={error}
/>
{/* Table Footer with Pagination */}
{pagination.total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1); // Reset to first page when limit changes
}}
/>
)}
</div>
{/* New User Modal */}
<NewUserModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateUser}
isLoading={isCreating}
/>
{/* View User Modal */}
<ViewUserModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedUserId(null);
}}
userId={selectedUserId}
onLoadUser={loadUser}
/>
{/* Edit User Modal */}
<EditUserModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedUserId(null);
// setSelectedUserName("");
}}
userId={selectedUserId}
onLoadUser={loadUser}
onSubmit={handleUpdateUser}
isLoading={isUpdating}
/>
{/* Delete Confirmation Modal */}
{/* <DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setSelectedUserId(null);
setSelectedUserName("");
}}
onConfirm={handleConfirmDelete}
title="Delete User"
message="Are you sure you want to delete this user"
itemName={selectedUserName}
isLoading={isDeleting}
/> */}
</Layout> </Layout>
); );
}; };

View File

@ -66,6 +66,8 @@ export interface Tenant {
assignedModules?: AssignedModule[]; // Array of assigned modules with full details assignedModules?: AssignedModule[]; // Array of assigned modules with full details
users?: TenantUser[]; // Array of tenant users users?: TenantUser[]; // Array of tenant users
tenant_admin?: TenantAdmin; // Tenant admin user details tenant_admin?: TenantAdmin; // Tenant admin user details
user_count?: number;
module_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }