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;
}) => {
const isChildActive = (path: string) => {
// 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),
// Basic exact match check
if (location.pathname === path) return true;
// Special handling for paths that are prefixes of other siblings
// 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 (path === "/tenant/files") {
if (location.pathname.startsWith("/tenant/files/storage-dashboard")) {
return false;
if (hasMoreSpecificSibling) return false;
// Also keep existing special cases if they don't fit the generic rule above
// (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 (
location.pathname === path || location.pathname.startsWith(`${path}/`)
);
return false;
};
const isAnyChildActive = childrenItems.some((child) =>
isChildActive(child.path),

View File

@ -9,9 +9,11 @@ import {
DeleteConfirmationModal,
WorkflowDefinitionModal,
WorkflowDefinitionViewModal,
SearchBox,
type Column,
ActionDropdown,
} 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 type { WorkflowDefinition } from "@/types/workflow";
import { showToast } from "@/utils/toast";
@ -206,9 +208,7 @@ const WorkflowDefinitionsTable = ({
{
key: "entity_type",
label: "Entity Type",
render: (wf) => (
<CodeBadge label={wf.entity_type} />
),
render: (wf) => <CodeBadge label={wf.entity_type} />,
},
{
key: "version",
@ -233,7 +233,9 @@ const WorkflowDefinitionsTable = ({
key: "source_module",
label: "Module",
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",
align: "right",
render: (wf) => (
<div className="flex justify-end gap-2">
<button
onClick={() => handleClone(wf.id, wf.name)}
disabled={isActionLoading}
className="p-1 hover:bg-gray-100 rounded-md transition-colors text-[#6b7280]"
title="Clone"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => {
setViewDefinitionId(wf.id);
setIsViewModalOpen(true);
}}
disabled={isActionLoading}
className="p-1 hover:bg-slate-100 rounded-md transition-colors text-slate-600"
title="View Details"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => {
setSelectedDefinition(wf);
setIsModalOpen(true);
}}
disabled={isActionLoading}
className="p-1 hover:bg-blue-50 rounded-md transition-colors text-blue-600"
title="Edit"
>
<Edit className="w-4 h-4" />
</button>
{wf.status === "draft" && (
<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>
)}
{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 className="flex justify-end">
<ActionDropdown
actions={[
{
icon: <Copy className="w-4 h-4" />,
label: "Clone",
onClick: () => handleClone(wf.id, wf.name),
},
{
icon: <Eye className="w-4 h-4" />,
label: "View",
onClick: () => {
setViewDefinitionId(wf.id);
setIsViewModalOpen(true);
},
},
{
icon: <Edit className="w-4 h-4" />,
label: "Edit",
onClick: () => {
setSelectedDefinition(wf);
setIsModalOpen(true);
},
},
(wf.status === "draft" || wf.status === "deprecated") ? {
icon: <Play className="w-4 h-4" />,
label: "Activate",
onClick: () => handleActivate(wf.id),
} : null,
wf.status === "active" ? {
icon: <Power className="w-4 h-4" />,
label: "Deprecate",
onClick: () => handleDeprecate(wf.id),
} : null,
{
icon: <Trash2 className="w-4 h-4" />,
label: "Delete",
variant: "danger",
onClick: () => {
setSelectedDefinition(wf);
setIsDeleteModalOpen(true);
},
},
].filter((a): a is any => a !== null)}
/>
</div>
),
},
@ -339,20 +309,14 @@ const WorkflowDefinitionsTable = ({
{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="flex items-center gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:w-64">
<GitBranch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
<input
type="text"
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>
<SearchBox
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search name, code or description"
/>
<FilterDropdown
label="Status"
options={[
{ value: "", label: "All Status" },
{ value: "active", label: "Active" },
{ value: "draft", label: "Draft" },
{ value: "deprecated", label: "Deprecated" },

View File

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

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 {
PrimaryButton,
@ -27,20 +27,27 @@ import type {
} from "@/types/designation";
import { showToast } from "@/utils/toast";
import type { RootState } from "@/store/store";
import { usePermissions } from "@/hooks/usePermissions";
// import { useAppTheme } from "@/hooks/useAppTheme";
import CodeBadge from "../shared/CodeBadge";
export interface DesignationsTableRef {
openNewModal: () => void;
refresh: () => void;
}
interface DesignationsTableProps {
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
compact?: boolean; // Compact mode for tabs
showHeader?: boolean;
}
const DesignationsTable = ({
const DesignationsTable = forwardRef<DesignationsTableRef, DesignationsTableProps>(({
tenantId: propsTenantId,
compact = false,
showHeader = true,
}: DesignationsTableProps): ReactElement => {
}, ref): ReactElement => {
const { canCreate, canUpdate } = usePermissions();
// const { primaryColor } = useAppTheme();
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
const effectiveTenantId = propsTenantId || reduxTenantId;
@ -49,6 +56,12 @@ const DesignationsTable = ({
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Expose imperative methods
useImperativeHandle(ref, () => ({
openNewModal: () => setIsNewModalOpen(true),
refresh: () => fetchDesignations(),
}));
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
@ -226,14 +239,14 @@ const DesignationsTable = ({
setSelectedDesignation(desig);
setIsViewModalOpen(true);
}}
onEdit={() => {
setSelectedDesignation(desig);
setIsEditModalOpen(true);
}}
// onDelete={() => {
// setSelectedDesignation(desig);
// setIsDeleteModalOpen(true);
// }}
onEdit={
canUpdate("designations")
? () => {
setSelectedDesignation(desig);
setIsEditModalOpen(true);
}
: undefined
}
/>
</div>
),
@ -257,14 +270,16 @@ const DesignationsTable = ({
onChange={(val) => setActiveOnly(val)}
/>
</div>
<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 Designation</span>
</PrimaryButton>
{canCreate("designations") && (
<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 Designation</span>
</PrimaryButton>
)}
</div>
)}
@ -332,6 +347,6 @@ const DesignationsTable = ({
/> */}
</div>
);
};
});
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 {
PrimaryButton,
StatusBadge,
@ -10,42 +10,62 @@ import {
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from '@/components/shared';
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { roleService } from '@/services/role-service';
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
import { showToast } from '@/utils/toast';
import { formatDate } from '@/utils/format-date';
} 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 { 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
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
const getScopeVariant = (scope: string): "success" | "failure" | "process" => {
switch (scope.toLowerCase()) {
case 'platform':
return 'success';
case 'tenant':
return 'process';
case 'module':
return 'failure';
case "platform":
return "success";
case "tenant":
return "process";
case "module":
return "failure";
default:
return 'success';
return "success";
}
};
export interface RolesTableRef {
openNewModal: () => void;
refresh: () => void;
}
interface RolesTableProps {
tenantId?: string | null; // If provided, fetch roles for this tenant only
showHeader?: boolean; // Show header with title and actions (default: true)
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 [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = 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
const [currentPage, setCurrentPage] = useState<number>(1);
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 [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 [selectedRoleName, setSelectedRoleName] = useState<string>("");
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
@ -80,30 +104,40 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
page: number,
itemsPerPage: number,
// scope: string | null = null,
sortBy: string[] | null = null
sortBy: string[] | null = null,
searchQuery: string | null = null,
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = tenantId
? await roleService.getByTenant(tenantId, page, itemsPerPage, sortBy)
: await roleService.getAll(page, itemsPerPage, sortBy);
? await roleService.getByTenant(tenantId, page, itemsPerPage, sortBy, searchQuery)
: await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
if (response.success) {
setRoles(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load roles');
setError("Failed to load roles");
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load roles');
setError(err?.response?.data?.error?.message || "Failed to load roles");
} finally {
setIsLoading(false);
}
};
// Handle search debouncing
useEffect(() => {
fetchRoles(currentPage, limit, orderBy);
}, [currentPage, limit, orderBy, tenantId]);
const timer = setTimeout(() => {
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> => {
try {
@ -187,61 +221,71 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
// Table columns
const columns: Column<Role>[] = [
{
key: 'name',
label: 'Name',
key: "name",
label: "Name",
render: (role) => (
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
),
},
{
key: 'code',
label: 'Code',
key: "code",
label: "Code",
render: (role) => <CodeBadge label={role.code} />,
},
{
key: "scope",
label: "Scope",
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',
label: 'Scope',
key: "user_count",
label: "Users",
render: (role) => (
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
),
},
{
key: 'description',
label: 'Description',
render: (role) => (
<span className="text-sm font-normal text-[#6b7280]">
{role.description || 'N/A'}
<span className="text-sm font-normal text-[#0f1724]">
{role.user_count || 0}
</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',
key: "description",
label: "Description",
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',
label: 'Actions',
align: 'right',
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={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
onEdit={
canUpdate("roles")
? () => handleEditRole(role.id, role.name)
: undefined
}
onDelete={
canDelete("roles")
? () => handleDeleteRole(role.id, role.name)
: undefined
}
/>
</div>
),
@ -253,25 +297,45 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
<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>
<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={() => handleEditRole(role.id, role.name)}
onDelete={() => handleDeleteRole(role.id, role.name)}
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>
<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>
<p className="text-[#0f1724] font-normal mt-1">
{formatDate(role.created_at)}
</p>
</div>
{role.description && (
<div className="col-span-2">
@ -288,32 +352,24 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
return (
<>
<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>
<div className="flex items-center gap-2">
{/* <FilterDropdown
label="Scope"
options={[
{ value: '', label: 'All Scope' },
{ value: 'platform', label: 'Platform' },
{ value: 'tenant', label: 'Tenant' },
{ value: 'module', label: 'Module' },
]}
value={scopeFilter || ''}
onChange={(value) => {
setScopeFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="Filter by scope"
/> */}
<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 className="flex flex-wrap items-center gap-2">
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search..."
/>
{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>
<DataTable
@ -366,7 +422,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
onClose={() => {
setEditModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
setSelectedRoleName("");
}}
roleId={selectedRoleId}
onLoadRole={loadRole}
@ -380,7 +436,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
onClose={() => {
setDeleteModalOpen(false);
setSelectedRoleId(null);
setSelectedRoleName('');
setSelectedRoleName("");
}}
onConfirm={handleConfirmDelete}
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">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* 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"
/> */}
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or code..."
/>
{/* 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: ["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) => {
@ -445,23 +492,25 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<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>
</button> */}
{/* New Role Button */}
<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>
{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>
)}
@ -489,7 +538,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
}}
onLimitChange={(newLimit: number) => {
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 {
PrimaryButton,
StatusBadge,
@ -13,13 +13,15 @@ import {
SearchBox,
type Column,
} from "@/components/shared";
import { Plus, Download, ArrowUpDown } from "lucide-react";
import { Plus, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service";
import { roleService } from "@/services/role-service";
import type { Role } from "@/types/role";
import type { User } from "@/types/user";
import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date";
import { useAppTheme } from "@/hooks/useAppTheme";
import { usePermissions } from "@/hooks/usePermissions";
// Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => {
@ -46,23 +48,36 @@ const getStatusVariant = (
}
};
export interface UsersTableRef {
openNewModal: () => void;
refresh: () => void;
}
interface UsersTableProps {
tenantId?: string | null; // If provided, fetch users for this tenant only
showHeader?: boolean; // Show header with title and actions (default: true)
compact?: boolean; // Compact mode for tabs (default: false)
}
export const UsersTable = ({
export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(({
tenantId,
showHeader = true,
compact = false,
}: UsersTableProps): ReactElement => {
}, ref): ReactElement => {
const { primaryColor } = useAppTheme();
const { canCreate } = 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);
// Expose imperative methods
useImperativeHandle(ref, () => ({
openNewModal: () => setIsModalOpen(true),
refresh: () => fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter),
}));
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
@ -322,7 +337,8 @@ export const UsersTable = ({
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 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"}
>
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
@ -332,7 +348,8 @@ export const UsersTable = ({
user.roles.map((role) => (
<span
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}
</span>
@ -502,7 +519,7 @@ export const UsersTable = ({
<FilterDropdown
label="Status"
options={[
{ value: "", label: "All Status" },
// { value: "", label: "All Status" },
{ value: "active", label: "Active" },
{ value: "suspended", label: "Suspended" },
{ value: "deleted", label: "Deleted" },
@ -512,12 +529,12 @@ export const UsersTable = ({
setStatusFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="Filter by status"
placeholder="All"
/>
<FilterDropdown
label="Role"
options={[
{ value: "", label: "All Roles" },
// { value: "", label: "All Roles" },
...roles.map(role => ({ value: role.id, label: role.name }))
]}
value={roleFilter || ""}
@ -525,16 +542,18 @@ export const UsersTable = ({
setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="Filter by role"
placeholder="All"
/>
<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>
{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>
<DataTable
@ -655,7 +674,7 @@ export const UsersTable = ({
<FilterDropdown
label="Role"
options={[
{ value: "", label: "All Roles" },
// { value: "", label: "All Roles" },
...roles.map(role => ({ value: role.id, label: role.name }))
]}
value={roleFilter || ""}
@ -663,7 +682,7 @@ export const UsersTable = ({
setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="Filter by role"
placeholder="All"
/>
{/* Sort Filter */}
@ -695,23 +714,25 @@ export const UsersTable = ({
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<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>
</button> */}
{/* New User Button */}
<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>
{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>
)}
@ -792,4 +813,4 @@ export const UsersTable = ({
/> */}
</>
);
};
});

View File

@ -6,7 +6,7 @@ export { ViewModuleModal } from './ViewModuleModal';
export { EditModuleModal } from './EditModuleModal';
export { WebhookSyncModal } from './WebhookSyncModal';
export { ApikeyReissueModal } from './ApikeyReissueModal';
export { UsersTable } from './UsersTable';
export { RolesTable } from './RolesTable';
export { UsersTable, type UsersTableRef } from './UsersTable';
export { RolesTable, type RolesTableRef } from './RolesTable';
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>
{/* Max Users and Max Modules Row */}
<div className="flex gap-5">
{/* <div className="flex gap-5">
<div className="flex-1">
<FormField
label="Max Users"
@ -821,7 +821,7 @@ const CreateTenantWizard = (): ReactElement => {
})}
/>
</div>
</div>
</div> */}
{/* Modules Multiselect */}
<MultiselectPaginatedSelect
label="Modules"

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import {
PrimaryButton,
StatusBadge,
ActionDropdown,
DeleteConfirmationModal,
// DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
@ -94,10 +94,10 @@ const Tenants = (): ReactElement => {
// View, Edit, Delete modals
// 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 [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
const [selectedTenantName, setSelectedTenantName] = useState<string>("");
const [isDeleting, setIsDeleting] = useState<boolean>(false);
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
// const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
// const [selectedTenantName, setSelectedTenantName] = useState<string>("");
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchTenants = async (
page: number,
@ -181,29 +181,29 @@ const Tenants = (): ReactElement => {
// Update tenant handler - removed, now handled in EditTenant page
// Delete tenant handler
const handleDeleteTenant = (tenantId: string, tenantName: string): void => {
setSelectedTenantId(tenantId);
setSelectedTenantName(tenantName);
setDeleteModalOpen(true);
};
// const handleDeleteTenant = (tenantId: string, tenantName: string): void => {
// setSelectedTenantId(tenantId);
// setSelectedTenantName(tenantName);
// setDeleteModalOpen(true);
// };
// Confirm delete handler
const handleConfirmDelete = async (): Promise<void> => {
if (!selectedTenantId) return;
// const handleConfirmDelete = async (): Promise<void> => {
// if (!selectedTenantId) return;
try {
setIsDeleting(true);
await tenantService.delete(selectedTenantId);
setDeleteModalOpen(false);
setSelectedTenantId(null);
setSelectedTenantName("");
await fetchTenants(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err; // Let the modal handle the error display
} finally {
setIsDeleting(false);
}
};
// try {
// setIsDeleting(true);
// await tenantService.delete(selectedTenantId);
// setDeleteModalOpen(false);
// setSelectedTenantId(null);
// setSelectedTenantName("");
// await fetchTenants(currentPage, limit, statusFilter, orderBy);
// } catch (err: any) {
// throw err; // Let the modal handle the error display
// } finally {
// setIsDeleting(false);
// }
// };
// Define table columns
const columns: Column<Tenant>[] = [
@ -252,17 +252,17 @@ const Tenants = (): ReactElement => {
),
},
{
key: "max_users",
key: "user_count",
label: "Users",
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_users ?? "N/A"}
{tenant.user_count ?? 0}
</span>
),
},
{
key: "subscription_tier",
label: "Plan",
label: "Subscription Tier",
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{formatSubscriptionTier(tenant.subscription_tier)}
@ -270,11 +270,11 @@ const Tenants = (): ReactElement => {
),
},
{
key: "max_modules",
key: "module_count",
label: "Modules",
render: (tenant) => (
<span className="text-sm font-normal text-[#0f1724]">
{tenant.max_modules ?? "N/A"}
{tenant.module_count ?? 0}
</span>
),
},
@ -297,7 +297,7 @@ const Tenants = (): ReactElement => {
<ActionDropdown
onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
// onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
/>
</div>
),
@ -329,7 +329,7 @@ const Tenants = (): ReactElement => {
<ActionDropdown
onView={() => handleViewTenant(tenant.id)}
onEdit={() => handleEditTenant(tenant.id)}
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
// onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
@ -485,29 +485,8 @@ const Tenants = (): ReactElement => {
)}
</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 */}
<DeleteConfirmationModal
{/* <DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
@ -519,7 +498,7 @@ const Tenants = (): ReactElement => {
message="Are you sure you want to delete this tenant"
itemName={selectedTenantName}
isLoading={isDeleting}
/>
/> */}
</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 { DepartmentsTable, type DepartmentsTableRef } from '@/components/superadmin';
import { PrimaryButton } from '@/components/shared';
import { Plus } from 'lucide-react';
import { DepartmentsTable } from '@/components/superadmin';
const Departments = (): ReactElement => {
const tableRef = useRef<DepartmentsTableRef>(null);
return (
<Layout
currentPage="Departments"
pageHeader={{
title: 'Department Management',
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>
);
};

View File

@ -13,7 +13,6 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "re
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import {
Search,
Upload,
FileText,
Image,
@ -22,7 +21,12 @@ import {
ChevronDown,
} from "lucide-react";
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 { cn } from "@/lib/utils";
import fileAttachmentService, {
@ -212,6 +216,126 @@ const FilesList = (): ReactElement => {
(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 ──
const [files, setFiles] = useState<FileAttachment[]>([]);
const [total, setTotal] = useState(0);
@ -315,6 +439,64 @@ const FilesList = (): ReactElement => {
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
// ─────────────────────────────────────────────────────────────────────────
@ -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="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
<input
id="files-search"
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>
<SearchBox
value={search}
onChange={(v) => { setSearch(v); setCurrentPage(1); }}
placeholder="Search by name, ID..."
/>
{/* Category filter */}
<FilterPill
@ -409,165 +572,15 @@ const FilesList = (): ReactElement => {
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[rgba(0,0,0,0.06)]">
{["File Name", "Size", "Category", "Source Module", "Uploaded By", "Upload Date", "Version", "Actions"].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide whitespace-nowrap"
>
{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>
<DataTable
data={files}
columns={columns}
keyExtractor={(file) => file.id}
isLoading={isLoading}
error={error}
emptyMessage="No files found"
mobileCardRenderer={mobileCardRenderer}
/>
{/* Pagination */}
{total > 0 && (

View File

@ -1,480 +1,17 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
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';
}
};
import { type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import { RolesTable } from "@/components/superadmin";
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 (
<Layout
currentPage="Roles"
pageHeader={{
title: 'Role List',
description: 'Define and manage roles to control user access based on job responsibilities',
title: "Role Management",
description: "Define and manage roles to control user access based on job responsibilities",
}}
>
{/* Table Container */}
<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}
/>
<RolesTable showHeader={true} />
</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 {
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";
}
};
import { UsersTable } from "@/components/superadmin";
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 (
<Layout
currentPage="Users"
pageHeader={{
title: "User List",
description:
"View and manage all users in your QAssure platform from a single place.",
title: "User Management",
description: "View and manage all users within your organization.",
}}
>
{/* Table Container */}
<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}
/> */}
<UsersTable showHeader={true} />
</Layout>
);
};

View File

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