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:
parent
901dde3362
commit
178b8f9046
@ -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),
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@ -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 = ({
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user