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;
|
onClose: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const isChildActive = (path: string) => {
|
const isChildActive = (path: string) => {
|
||||||
// Special handling for Document Lists to NOT show as active when sub-actions are active
|
// Basic exact match check
|
||||||
if (path === "/tenant/documents") {
|
if (location.pathname === path) return true;
|
||||||
const subActions = ["/create", "/categories", "/due-for-review", "/edit"];
|
|
||||||
const isSubActionActive = subActions.some((sub) =>
|
// Special handling for paths that are prefixes of other siblings
|
||||||
location.pathname.startsWith(path + sub),
|
// Example: /tenant/settings is a prefix of /tenant/settings/notifications
|
||||||
|
if (location.pathname.startsWith(`${path}/`)) {
|
||||||
|
// If there's another sibling that is a more specific match for the current path,
|
||||||
|
// then this prefix path should not be considered "active"
|
||||||
|
const hasMoreSpecificSibling = childrenItems.some(
|
||||||
|
(sibling) =>
|
||||||
|
sibling.path !== path &&
|
||||||
|
sibling.path.startsWith(path) &&
|
||||||
|
(location.pathname === sibling.path ||
|
||||||
|
location.pathname.startsWith(`${sibling.path}/`)),
|
||||||
);
|
);
|
||||||
if (isSubActionActive) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special handling for Files List to NOT show as active when Storage Dashboard is active
|
if (hasMoreSpecificSibling) return false;
|
||||||
if (path === "/tenant/files") {
|
|
||||||
if (location.pathname.startsWith("/tenant/files/storage-dashboard")) {
|
// Also keep existing special cases if they don't fit the generic rule above
|
||||||
return false;
|
// (Though the generic rule above should cover most of these)
|
||||||
|
|
||||||
|
// Special handling for Document Lists to NOT show as active when sub-actions are active
|
||||||
|
if (path === "/tenant/documents") {
|
||||||
|
const subActions = [
|
||||||
|
"/create",
|
||||||
|
"/categories",
|
||||||
|
"/due-for-review",
|
||||||
|
"/edit",
|
||||||
|
];
|
||||||
|
const isSubActionActive = subActions.some((sub) =>
|
||||||
|
location.pathname.startsWith(path + sub),
|
||||||
|
);
|
||||||
|
if (isSubActionActive) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for Files List to NOT show as active when Storage Dashboard is active
|
||||||
|
if (path === "/tenant/files") {
|
||||||
|
if (location.pathname.startsWith("/tenant/files/storage-dashboard")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return false;
|
||||||
location.pathname === path || location.pathname.startsWith(`${path}/`)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const isAnyChildActive = childrenItems.some((child) =>
|
const isAnyChildActive = childrenItems.some((child) =>
|
||||||
isChildActive(child.path),
|
isChildActive(child.path),
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import {
|
|||||||
DeleteConfirmationModal,
|
DeleteConfirmationModal,
|
||||||
WorkflowDefinitionModal,
|
WorkflowDefinitionModal,
|
||||||
WorkflowDefinitionViewModal,
|
WorkflowDefinitionViewModal,
|
||||||
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
|
ActionDropdown,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, GitBranch, Play, Power, Trash2, Copy, Edit, Eye } from "lucide-react";
|
import { Plus, Play, Power, Trash2, Copy, Edit, Eye } from "lucide-react";
|
||||||
import { workflowService } from "@/services/workflow-service";
|
import { workflowService } from "@/services/workflow-service";
|
||||||
import type { WorkflowDefinition } from "@/types/workflow";
|
import type { WorkflowDefinition } from "@/types/workflow";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
@ -206,9 +208,7 @@ const WorkflowDefinitionsTable = ({
|
|||||||
{
|
{
|
||||||
key: "entity_type",
|
key: "entity_type",
|
||||||
label: "Entity Type",
|
label: "Entity Type",
|
||||||
render: (wf) => (
|
render: (wf) => <CodeBadge label={wf.entity_type} />,
|
||||||
<CodeBadge label={wf.entity_type} />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "version",
|
key: "version",
|
||||||
@ -233,7 +233,9 @@ const WorkflowDefinitionsTable = ({
|
|||||||
key: "source_module",
|
key: "source_module",
|
||||||
label: "Module",
|
label: "Module",
|
||||||
render: (wf) => (
|
render: (wf) => (
|
||||||
<span className="text-sm text-[#6b7280]">{wf.source_module?.join(", ")}</span>
|
<span className="text-sm text-[#6b7280]">
|
||||||
|
{wf.source_module?.join(", ")}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -250,83 +252,51 @@ const WorkflowDefinitionsTable = ({
|
|||||||
label: "Actions",
|
label: "Actions",
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (wf) => (
|
render: (wf) => (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end">
|
||||||
<button
|
<ActionDropdown
|
||||||
onClick={() => handleClone(wf.id, wf.name)}
|
actions={[
|
||||||
disabled={isActionLoading}
|
{
|
||||||
className="p-1 hover:bg-gray-100 rounded-md transition-colors text-[#6b7280]"
|
icon: <Copy className="w-4 h-4" />,
|
||||||
title="Clone"
|
label: "Clone",
|
||||||
>
|
onClick: () => handleClone(wf.id, wf.name),
|
||||||
<Copy className="w-4 h-4" />
|
},
|
||||||
</button>
|
{
|
||||||
|
icon: <Eye className="w-4 h-4" />,
|
||||||
<button
|
label: "View",
|
||||||
onClick={() => {
|
onClick: () => {
|
||||||
setViewDefinitionId(wf.id);
|
setViewDefinitionId(wf.id);
|
||||||
setIsViewModalOpen(true);
|
setIsViewModalOpen(true);
|
||||||
}}
|
},
|
||||||
disabled={isActionLoading}
|
},
|
||||||
className="p-1 hover:bg-slate-100 rounded-md transition-colors text-slate-600"
|
{
|
||||||
title="View Details"
|
icon: <Edit className="w-4 h-4" />,
|
||||||
>
|
label: "Edit",
|
||||||
<Eye className="w-4 h-4" />
|
onClick: () => {
|
||||||
</button>
|
setSelectedDefinition(wf);
|
||||||
|
setIsModalOpen(true);
|
||||||
<button
|
},
|
||||||
onClick={() => {
|
},
|
||||||
setSelectedDefinition(wf);
|
(wf.status === "draft" || wf.status === "deprecated") ? {
|
||||||
setIsModalOpen(true);
|
icon: <Play className="w-4 h-4" />,
|
||||||
}}
|
label: "Activate",
|
||||||
disabled={isActionLoading}
|
onClick: () => handleActivate(wf.id),
|
||||||
className="p-1 hover:bg-blue-50 rounded-md transition-colors text-blue-600"
|
} : null,
|
||||||
title="Edit"
|
wf.status === "active" ? {
|
||||||
>
|
icon: <Power className="w-4 h-4" />,
|
||||||
<Edit className="w-4 h-4" />
|
label: "Deprecate",
|
||||||
</button>
|
onClick: () => handleDeprecate(wf.id),
|
||||||
|
} : null,
|
||||||
{wf.status === "draft" && (
|
{
|
||||||
<button
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
onClick={() => handleActivate(wf.id)}
|
label: "Delete",
|
||||||
disabled={isActionLoading}
|
variant: "danger",
|
||||||
className="p-1 hover:bg-green-50 rounded-md transition-colors text-green-600"
|
onClick: () => {
|
||||||
title="Activate"
|
setSelectedDefinition(wf);
|
||||||
>
|
setIsDeleteModalOpen(true);
|
||||||
<Play className="w-4 h-4" />
|
},
|
||||||
</button>
|
},
|
||||||
)}
|
].filter((a): a is any => a !== null)}
|
||||||
|
/>
|
||||||
{wf.status === "active" && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeprecate(wf.id)}
|
|
||||||
disabled={isActionLoading}
|
|
||||||
className="p-1 hover:bg-orange-50 rounded-md transition-colors text-orange-600"
|
|
||||||
title="Deprecate"
|
|
||||||
>
|
|
||||||
<Power className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{wf.status === "deprecated" && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleActivate(wf.id)}
|
|
||||||
disabled={isActionLoading}
|
|
||||||
className="p-1 hover:bg-green-50 rounded-md transition-colors text-green-600"
|
|
||||||
title="Activate"
|
|
||||||
>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDefinition(wf);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
disabled={isActionLoading}
|
|
||||||
className="p-1 hover:bg-red-50 rounded-md transition-colors text-red-600"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -339,20 +309,14 @@ const WorkflowDefinitionsTable = ({
|
|||||||
{showHeader && (
|
{showHeader && (
|
||||||
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
|
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
<div className="relative flex-1 sm:w-64">
|
<SearchBox
|
||||||
<GitBranch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
|
value={searchQuery}
|
||||||
<input
|
onChange={setSearchQuery}
|
||||||
type="text"
|
placeholder="Search name, code or description"
|
||||||
placeholder="Search workflows..."
|
/>
|
||||||
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#0052cc]"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "All Status" },
|
|
||||||
{ value: "active", label: "Active" },
|
{ value: "active", label: "Active" },
|
||||||
{ value: "draft", label: "Draft" },
|
{ value: "draft", label: "Draft" },
|
||||||
{ value: "deprecated", label: "Deprecated" },
|
{ value: "deprecated", label: "Deprecated" },
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
SearchBox,
|
SearchBox,
|
||||||
ActiveOnlyToggle,
|
ActiveOnlyToggle,
|
||||||
type Column,
|
type Column,
|
||||||
|
PrimaryButton,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import {
|
import {
|
||||||
NewDepartmentModal,
|
NewDepartmentModal,
|
||||||
@ -29,7 +30,9 @@ import type {
|
|||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import type { RootState } from "@/store/store";
|
import type { RootState } from "@/store/store";
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
import CodeBadge from "../shared/CodeBadge";
|
import CodeBadge from "../shared/CodeBadge";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
interface DepartmentsTableProps {
|
interface DepartmentsTableProps {
|
||||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||||
@ -47,6 +50,7 @@ export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTable
|
|||||||
showHeader = true,
|
showHeader = true,
|
||||||
}: DepartmentsTableProps, ref): ReactElement => {
|
}: DepartmentsTableProps, ref): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
const { primaryColor } = useAppTheme();
|
||||||
|
const { canCreate, canUpdate } = usePermissions();
|
||||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||||
|
|
||||||
@ -265,10 +269,14 @@ export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTable
|
|||||||
setSelectedDepartment(dept);
|
setSelectedDepartment(dept);
|
||||||
setIsViewModalOpen(true);
|
setIsViewModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={() => {
|
onEdit={
|
||||||
setSelectedDepartment(dept);
|
canUpdate("departments")
|
||||||
setIsEditModalOpen(true);
|
? () => {
|
||||||
}}
|
setSelectedDepartment(dept);
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -330,6 +338,17 @@ export const DepartmentsTable = forwardRef<DepartmentsTableRef, DepartmentsTable
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canCreate("departments") && (
|
||||||
|
<PrimaryButton
|
||||||
|
size="default"
|
||||||
|
className="flex items-center gap-2 w-full sm:w-auto"
|
||||||
|
onClick={() => setIsNewModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>New Department</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, type ReactElement } from "react";
|
import { useState, useEffect, type ReactElement, forwardRef, useImperativeHandle } from "react";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
@ -27,20 +27,27 @@ import type {
|
|||||||
} from "@/types/designation";
|
} from "@/types/designation";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import type { RootState } from "@/store/store";
|
import type { RootState } from "@/store/store";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
// import { useAppTheme } from "@/hooks/useAppTheme";
|
// import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
import CodeBadge from "../shared/CodeBadge";
|
import CodeBadge from "../shared/CodeBadge";
|
||||||
|
|
||||||
|
export interface DesignationsTableRef {
|
||||||
|
openNewModal: () => void;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface DesignationsTableProps {
|
interface DesignationsTableProps {
|
||||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||||
compact?: boolean; // Compact mode for tabs
|
compact?: boolean; // Compact mode for tabs
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DesignationsTable = ({
|
const DesignationsTable = forwardRef<DesignationsTableRef, DesignationsTableProps>(({
|
||||||
tenantId: propsTenantId,
|
tenantId: propsTenantId,
|
||||||
compact = false,
|
compact = false,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
}: DesignationsTableProps): ReactElement => {
|
}, ref): ReactElement => {
|
||||||
|
const { canCreate, canUpdate } = usePermissions();
|
||||||
// const { primaryColor } = useAppTheme();
|
// const { primaryColor } = useAppTheme();
|
||||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||||
const effectiveTenantId = propsTenantId || reduxTenantId;
|
const effectiveTenantId = propsTenantId || reduxTenantId;
|
||||||
@ -49,6 +56,12 @@ const DesignationsTable = ({
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Expose imperative methods
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openNewModal: () => setIsNewModalOpen(true),
|
||||||
|
refresh: () => fetchDesignations(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
|
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
|
||||||
@ -226,14 +239,14 @@ const DesignationsTable = ({
|
|||||||
setSelectedDesignation(desig);
|
setSelectedDesignation(desig);
|
||||||
setIsViewModalOpen(true);
|
setIsViewModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={() => {
|
onEdit={
|
||||||
setSelectedDesignation(desig);
|
canUpdate("designations")
|
||||||
setIsEditModalOpen(true);
|
? () => {
|
||||||
}}
|
setSelectedDesignation(desig);
|
||||||
// onDelete={() => {
|
setIsEditModalOpen(true);
|
||||||
// setSelectedDesignation(desig);
|
}
|
||||||
// setIsDeleteModalOpen(true);
|
: undefined
|
||||||
// }}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -257,14 +270,16 @@ const DesignationsTable = ({
|
|||||||
onChange={(val) => setActiveOnly(val)}
|
onChange={(val) => setActiveOnly(val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton
|
{canCreate("designations") && (
|
||||||
size="default"
|
<PrimaryButton
|
||||||
className="flex items-center gap-2 w-full sm:w-auto"
|
size="default"
|
||||||
onClick={() => setIsNewModalOpen(true)}
|
className="flex items-center gap-2 w-full sm:w-auto"
|
||||||
>
|
onClick={() => setIsNewModalOpen(true)}
|
||||||
<Plus className="w-4 h-4" />
|
>
|
||||||
<span>New Designation</span>
|
<Plus className="w-4 h-4" />
|
||||||
</PrimaryButton>
|
<span>New Designation</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -332,6 +347,6 @@ const DesignationsTable = ({
|
|||||||
/> */}
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default DesignationsTable;
|
export default DesignationsTable;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, type ReactElement } from 'react';
|
import { useState, useEffect, type ReactElement, forwardRef, useImperativeHandle } from "react";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -10,42 +10,62 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from "@/components/shared";
|
||||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
import { Plus, ArrowUpDown } from "lucide-react";
|
||||||
import { roleService } from '@/services/role-service';
|
import { roleService } from "@/services/role-service";
|
||||||
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
import type { Role, CreateRoleRequest, UpdateRoleRequest } from "@/types/role";
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from "@/utils/toast";
|
||||||
import { formatDate } from '@/utils/format-date';
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
// import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import CodeBadge from "@/components/shared/CodeBadge";
|
||||||
|
|
||||||
// Helper function to get scope badge variant
|
// Helper function to get scope badge variant
|
||||||
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
const getScopeVariant = (scope: string): "success" | "failure" | "process" => {
|
||||||
switch (scope.toLowerCase()) {
|
switch (scope.toLowerCase()) {
|
||||||
case 'platform':
|
case "platform":
|
||||||
return 'success';
|
return "success";
|
||||||
case 'tenant':
|
case "tenant":
|
||||||
return 'process';
|
return "process";
|
||||||
case 'module':
|
case "module":
|
||||||
return 'failure';
|
return "failure";
|
||||||
default:
|
default:
|
||||||
return 'success';
|
return "success";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface RolesTableRef {
|
||||||
|
openNewModal: () => void;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface RolesTableProps {
|
interface RolesTableProps {
|
||||||
tenantId?: string | null; // If provided, fetch roles for this tenant only
|
tenantId?: string | null; // If provided, fetch roles for this tenant only
|
||||||
showHeader?: boolean; // Show header with title and actions (default: true)
|
showHeader?: boolean; // Show header with title and actions (default: true)
|
||||||
compact?: boolean; // Compact mode for tabs (default: false)
|
compact?: boolean; // Compact mode for tabs (default: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RolesTable = ({ tenantId, showHeader = true, compact = false }: RolesTableProps): ReactElement => {
|
export const RolesTable = forwardRef<RolesTableRef, RolesTableProps>(({
|
||||||
|
tenantId,
|
||||||
|
showHeader = true,
|
||||||
|
compact = false,
|
||||||
|
}, ref): ReactElement => {
|
||||||
|
// const { primaryColor } = useAppTheme();
|
||||||
|
const { canCreate, canUpdate, canDelete } = usePermissions();
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Expose imperative methods
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openNewModal: () => setIsModalOpen(true),
|
||||||
|
refresh: () => fetchRoles(currentPage, limit, orderBy, debouncedSearch),
|
||||||
|
}));
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [limit, setLimit] = useState<number>(5);
|
const [limit, setLimit] = useState<number>(5);
|
||||||
@ -67,12 +87,16 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
||||||
const [selectedRoleName, setSelectedRoleName] = useState<string>('');
|
const [selectedRoleName, setSelectedRoleName] = useState<string>("");
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
|
|
||||||
@ -80,30 +104,40 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
page: number,
|
page: number,
|
||||||
itemsPerPage: number,
|
itemsPerPage: number,
|
||||||
// scope: string | null = null,
|
// scope: string | null = null,
|
||||||
sortBy: string[] | null = null
|
sortBy: string[] | null = null,
|
||||||
|
searchQuery: string | null = null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = tenantId
|
const response = tenantId
|
||||||
? await roleService.getByTenant(tenantId, page, itemsPerPage, sortBy)
|
? await roleService.getByTenant(tenantId, page, itemsPerPage, sortBy, searchQuery)
|
||||||
: await roleService.getAll(page, itemsPerPage, sortBy);
|
: await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setRoles(response.data);
|
setRoles(response.data);
|
||||||
setPagination(response.pagination);
|
setPagination(response.pagination);
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to load roles');
|
setError("Failed to load roles");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error?.message || 'Failed to load roles');
|
setError(err?.response?.data?.error?.message || "Failed to load roles");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle search debouncing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRoles(currentPage, limit, orderBy);
|
const timer = setTimeout(() => {
|
||||||
}, [currentPage, limit, orderBy, tenantId]);
|
setDebouncedSearch(search);
|
||||||
|
if (search) setCurrentPage(1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles(currentPage, limit, orderBy, debouncedSearch);
|
||||||
|
}, [currentPage, limit, orderBy, debouncedSearch, tenantId]);
|
||||||
|
|
||||||
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -187,61 +221,71 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
// Table columns
|
// Table columns
|
||||||
const columns: Column<Role>[] = [
|
const columns: Column<Role>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: "name",
|
||||||
label: 'Name',
|
label: "Name",
|
||||||
render: (role) => (
|
render: (role) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'code',
|
key: "code",
|
||||||
label: 'Code',
|
label: "Code",
|
||||||
|
render: (role) => <CodeBadge label={role.code} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scope",
|
||||||
|
label: "Scope",
|
||||||
render: (role) => (
|
render: (role) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724] font-mono">{role.code}</span>
|
<StatusBadge variant={getScopeVariant(role.scope)}>
|
||||||
|
{role.scope}
|
||||||
|
</StatusBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'scope',
|
key: "user_count",
|
||||||
label: 'Scope',
|
label: "Users",
|
||||||
render: (role) => (
|
render: (role) => (
|
||||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
),
|
{role.user_count || 0}
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'description',
|
|
||||||
label: 'Description',
|
|
||||||
render: (role) => (
|
|
||||||
<span className="text-sm font-normal text-[#6b7280]">
|
|
||||||
{role.description || 'N/A'}
|
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// key: 'is_system',
|
|
||||||
// label: 'System Role',
|
|
||||||
// render: (role) => (
|
|
||||||
// <span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
// {role.is_system ? 'Yes' : 'No'}
|
|
||||||
// </span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
key: 'created_at',
|
key: "description",
|
||||||
label: 'Created Date',
|
label: "Description",
|
||||||
render: (role) => (
|
render: (role) => (
|
||||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
|
<span className="text-sm font-normal text-[#6b7280]">
|
||||||
|
{role.description || "N/A"}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: "created_at",
|
||||||
label: 'Actions',
|
label: "Created Date",
|
||||||
align: 'right',
|
render: (role) => (
|
||||||
|
<span className="text-sm font-normal text-[#6b7280]">
|
||||||
|
{formatDate(role.created_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
render: (role) => (
|
render: (role) => (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onView={() => handleViewRole(role.id)}
|
onView={() => handleViewRole(role.id)}
|
||||||
onEdit={() => handleEditRole(role.id, role.name)}
|
onEdit={
|
||||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
canUpdate("roles")
|
||||||
|
? () => handleEditRole(role.id, role.name)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDelete={
|
||||||
|
canDelete("roles")
|
||||||
|
? () => handleDeleteRole(role.id, role.name)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -253,25 +297,45 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
|
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
||||||
|
{role.name}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
|
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onView={() => handleViewRole(role.id)}
|
onView={() => handleViewRole(role.id)}
|
||||||
onEdit={() => handleEditRole(role.id, role.name)}
|
onEdit={
|
||||||
onDelete={() => handleDeleteRole(role.id, role.name)}
|
canUpdate("roles")
|
||||||
|
? () => handleEditRole(role.id, role.name)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDelete={
|
||||||
|
canDelete("roles")
|
||||||
|
? () => handleDeleteRole(role.id, role.name)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Scope:</span>
|
<span className="text-[#9aa6b2]">Scope:</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
<StatusBadge variant={getScopeVariant(role.scope)}>
|
||||||
|
{role.scope}
|
||||||
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[#9aa6b2]">Users:</span>
|
||||||
|
<p className="text-[#0f1724] font-normal mt-1">
|
||||||
|
{role.user_count || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[#9aa6b2]">Created:</span>
|
<span className="text-[#9aa6b2]">Created:</span>
|
||||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
<p className="text-[#0f1724] font-normal mt-1">
|
||||||
|
{formatDate(role.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{role.description && (
|
{role.description && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
@ -288,32 +352,24 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h3 className="text-lg font-semibold text-[#0f1724]"></h3>
|
<h3 className="text-lg font-semibold text-[#0f1724]"></h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* <FilterDropdown
|
<SearchBox
|
||||||
label="Scope"
|
value={search}
|
||||||
options={[
|
onChange={setSearch}
|
||||||
{ value: '', label: 'All Scope' },
|
placeholder="Search..."
|
||||||
{ value: 'platform', label: 'Platform' },
|
/>
|
||||||
{ value: 'tenant', label: 'Tenant' },
|
{canCreate("roles") && (
|
||||||
{ value: 'module', label: 'Module' },
|
<PrimaryButton
|
||||||
]}
|
size="default"
|
||||||
value={scopeFilter || ''}
|
className="flex items-center gap-2"
|
||||||
onChange={(value) => {
|
onClick={() => setIsModalOpen(true)}
|
||||||
setScopeFilter(Array.isArray(value) ? null : value || null);
|
>
|
||||||
setCurrentPage(1);
|
<Plus className="w-3.5 h-3.5" />
|
||||||
}}
|
<span className="text-xs">New Role</span>
|
||||||
placeholder="Filter by scope"
|
</PrimaryButton>
|
||||||
/> */}
|
)}
|
||||||
<PrimaryButton
|
|
||||||
size="default"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
<span className="text-xs">New Role</span>
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -366,7 +422,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
setSelectedRoleName('');
|
setSelectedRoleName("");
|
||||||
}}
|
}}
|
||||||
roleId={selectedRoleId}
|
roleId={selectedRoleId}
|
||||||
onLoadRole={loadRole}
|
onLoadRole={loadRole}
|
||||||
@ -380,7 +436,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
setSelectedRoleName('');
|
setSelectedRoleName("");
|
||||||
}}
|
}}
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
title="Delete Role"
|
title="Delete Role"
|
||||||
@ -402,34 +458,25 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* Scope Filter */}
|
{/* Global Search */}
|
||||||
{/* <FilterDropdown
|
<SearchBox
|
||||||
label="Scope"
|
value={search}
|
||||||
options={[
|
onChange={setSearch}
|
||||||
{ value: 'platform', label: 'Platform' },
|
placeholder="Search by name or code..."
|
||||||
{ value: 'tenant', label: 'Tenant' },
|
/>
|
||||||
{ value: 'module', label: 'Module' },
|
|
||||||
]}
|
|
||||||
value={scopeFilter}
|
|
||||||
onChange={(value) => {
|
|
||||||
setScopeFilter(value as string | null);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
placeholder="All"
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{/* Sort Filter */}
|
{/* Sort Filter */}
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Sort by"
|
label="Sort by"
|
||||||
options={[
|
options={[
|
||||||
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
{ value: ["name", "asc"], label: "Name (A-Z)" },
|
||||||
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
{ value: ["name", "desc"], label: "Name (Z-A)" },
|
||||||
{ value: ['code', 'asc'], label: 'Code (A-Z)' },
|
{ value: ["code", "asc"], label: "Code (A-Z)" },
|
||||||
{ value: ['code', 'desc'], label: 'Code (Z-A)' },
|
{ value: ["code", "desc"], label: "Code (Z-A)" },
|
||||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
|
||||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
{ value: ["created_at", "desc"], label: "Created (Newest)" },
|
||||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
|
||||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
|
||||||
]}
|
]}
|
||||||
value={orderBy}
|
value={orderBy}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@ -445,23 +492,25 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<button
|
{/* <button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
</button>
|
</button> */}
|
||||||
|
|
||||||
{/* New Role Button */}
|
{/* New Role Button */}
|
||||||
<PrimaryButton
|
{canCreate("roles") && (
|
||||||
size="default"
|
<PrimaryButton
|
||||||
className="flex items-center gap-2"
|
size="default"
|
||||||
onClick={() => setIsModalOpen(true)}
|
className="flex items-center gap-2"
|
||||||
>
|
onClick={() => setIsModalOpen(true)}
|
||||||
<Plus className="w-3.5 h-3.5" />
|
>
|
||||||
<span className="text-xs">New Role</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</PrimaryButton>
|
<span className="text-xs">New Role</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -489,7 +538,7 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
}}
|
}}
|
||||||
onLimitChange={(newLimit: number) => {
|
onLimitChange={(newLimit: number) => {
|
||||||
setLimit(newLimit);
|
setLimit(newLimit);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1); // Reset to first page when limit changes
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -543,4 +592,4 @@ export const RolesTable = ({ tenantId, showHeader = true, compact = false }: Rol
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, type ReactElement } from "react";
|
import { useState, useEffect, type ReactElement, forwardRef, useImperativeHandle } from "react";
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@ -13,13 +13,15 @@ import {
|
|||||||
SearchBox,
|
SearchBox,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, Download, ArrowUpDown } from "lucide-react";
|
import { Plus, ArrowUpDown } from "lucide-react";
|
||||||
import { userService } from "@/services/user-service";
|
import { userService } from "@/services/user-service";
|
||||||
import { roleService } from "@/services/role-service";
|
import { roleService } from "@/services/role-service";
|
||||||
import type { Role } from "@/types/role";
|
import type { Role } from "@/types/role";
|
||||||
import type { User } from "@/types/user";
|
import type { User } from "@/types/user";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { formatDate } from "@/utils/format-date";
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
// Helper function to get user initials
|
// Helper function to get user initials
|
||||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||||
@ -46,23 +48,36 @@ const getStatusVariant = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface UsersTableRef {
|
||||||
|
openNewModal: () => void;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface UsersTableProps {
|
interface UsersTableProps {
|
||||||
tenantId?: string | null; // If provided, fetch users for this tenant only
|
tenantId?: string | null; // If provided, fetch users for this tenant only
|
||||||
showHeader?: boolean; // Show header with title and actions (default: true)
|
showHeader?: boolean; // Show header with title and actions (default: true)
|
||||||
compact?: boolean; // Compact mode for tabs (default: false)
|
compact?: boolean; // Compact mode for tabs (default: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UsersTable = ({
|
export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(({
|
||||||
tenantId,
|
tenantId,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: UsersTableProps): ReactElement => {
|
}, ref): ReactElement => {
|
||||||
|
const { primaryColor } = useAppTheme();
|
||||||
|
const { canCreate } = usePermissions();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
const [isCreating, setIsCreating] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Expose imperative methods
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
openNewModal: () => setIsModalOpen(true),
|
||||||
|
refresh: () => fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter),
|
||||||
|
}));
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
|
const [limit, setLimit] = useState<number>(compact ? 10 : 5);
|
||||||
@ -322,7 +337,8 @@ export const UsersTable = ({
|
|||||||
user.role_module_combinations.map((combo, idx) => (
|
user.role_module_combinations.map((combo, idx) => (
|
||||||
<span
|
<span
|
||||||
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
|
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
|
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
||||||
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
|
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
|
||||||
>
|
>
|
||||||
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
|
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
|
||||||
@ -332,7 +348,8 @@ export const UsersTable = ({
|
|||||||
user.roles.map((role) => (
|
user.roles.map((role) => (
|
||||||
<span
|
<span
|
||||||
key={role.id}
|
key={role.id}
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
|
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
||||||
>
|
>
|
||||||
{role.name}
|
{role.name}
|
||||||
</span>
|
</span>
|
||||||
@ -502,7 +519,7 @@ export const UsersTable = ({
|
|||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Status"
|
label="Status"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "All Status" },
|
// { value: "", label: "All Status" },
|
||||||
{ value: "active", label: "Active" },
|
{ value: "active", label: "Active" },
|
||||||
{ value: "suspended", label: "Suspended" },
|
{ value: "suspended", label: "Suspended" },
|
||||||
{ value: "deleted", label: "Deleted" },
|
{ value: "deleted", label: "Deleted" },
|
||||||
@ -512,12 +529,12 @@ export const UsersTable = ({
|
|||||||
setStatusFilter(Array.isArray(value) ? null : value || null);
|
setStatusFilter(Array.isArray(value) ? null : value || null);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="Filter by status"
|
placeholder="All"
|
||||||
/>
|
/>
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Role"
|
label="Role"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "All Roles" },
|
// { value: "", label: "All Roles" },
|
||||||
...roles.map(role => ({ value: role.id, label: role.name }))
|
...roles.map(role => ({ value: role.id, label: role.name }))
|
||||||
]}
|
]}
|
||||||
value={roleFilter || ""}
|
value={roleFilter || ""}
|
||||||
@ -525,16 +542,18 @@ export const UsersTable = ({
|
|||||||
setRoleFilter(Array.isArray(value) ? null : value || null);
|
setRoleFilter(Array.isArray(value) ? null : value || null);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="Filter by role"
|
placeholder="All"
|
||||||
/>
|
/>
|
||||||
<PrimaryButton
|
{canCreate("users") && (
|
||||||
size="default"
|
<PrimaryButton
|
||||||
className="flex items-center gap-2"
|
size="default"
|
||||||
onClick={() => setIsModalOpen(true)}
|
className="flex items-center gap-2"
|
||||||
>
|
onClick={() => setIsModalOpen(true)}
|
||||||
<Plus className="w-3.5 h-3.5" />
|
>
|
||||||
<span className="text-xs">New User</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</PrimaryButton>
|
<span className="text-xs">New User</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -655,7 +674,7 @@ export const UsersTable = ({
|
|||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
label="Role"
|
label="Role"
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "All Roles" },
|
// { value: "", label: "All Roles" },
|
||||||
...roles.map(role => ({ value: role.id, label: role.name }))
|
...roles.map(role => ({ value: role.id, label: role.name }))
|
||||||
]}
|
]}
|
||||||
value={roleFilter || ""}
|
value={roleFilter || ""}
|
||||||
@ -663,7 +682,7 @@ export const UsersTable = ({
|
|||||||
setRoleFilter(Array.isArray(value) ? null : value || null);
|
setRoleFilter(Array.isArray(value) ? null : value || null);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="Filter by role"
|
placeholder="All"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sort Filter */}
|
{/* Sort Filter */}
|
||||||
@ -695,23 +714,25 @@ export const UsersTable = ({
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<button
|
{/* <button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
</button>
|
</button> */}
|
||||||
|
|
||||||
{/* New User Button */}
|
{/* New User Button */}
|
||||||
<PrimaryButton
|
{canCreate("users") && (
|
||||||
size="default"
|
<PrimaryButton
|
||||||
className="flex items-center gap-2"
|
size="default"
|
||||||
onClick={() => setIsModalOpen(true)}
|
className="flex items-center gap-2"
|
||||||
>
|
onClick={() => setIsModalOpen(true)}
|
||||||
<Plus className="w-3.5 h-3.5" />
|
>
|
||||||
<span className="text-xs">New User</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</PrimaryButton>
|
<span className="text-xs">New User</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -792,4 +813,4 @@ export const UsersTable = ({
|
|||||||
/> */}
|
/> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export { ViewModuleModal } from './ViewModuleModal';
|
|||||||
export { EditModuleModal } from './EditModuleModal';
|
export { EditModuleModal } from './EditModuleModal';
|
||||||
export { WebhookSyncModal } from './WebhookSyncModal';
|
export { WebhookSyncModal } from './WebhookSyncModal';
|
||||||
export { ApikeyReissueModal } from './ApikeyReissueModal';
|
export { ApikeyReissueModal } from './ApikeyReissueModal';
|
||||||
export { UsersTable } from './UsersTable';
|
export { UsersTable, type UsersTableRef } from './UsersTable';
|
||||||
export { RolesTable } from './RolesTable';
|
export { RolesTable, type RolesTableRef } from './RolesTable';
|
||||||
export { DepartmentsTable, type DepartmentsTableRef } from './DepartmentsTable';
|
export { DepartmentsTable, type DepartmentsTableRef } from './DepartmentsTable';
|
||||||
export { default as DesignationsTable } from './DesignationsTable';
|
export { default as DesignationsTable, type DesignationsTableRef } from './DesignationsTable';
|
||||||
|
|||||||
@ -772,7 +772,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Max Users and Max Modules Row */}
|
{/* Max Users and Max Modules Row */}
|
||||||
<div className="flex gap-5">
|
{/* <div className="flex gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
label="Max Users"
|
label="Max Users"
|
||||||
@ -821,7 +821,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
{/* Modules Multiselect */}
|
{/* Modules Multiselect */}
|
||||||
<MultiselectPaginatedSelect
|
<MultiselectPaginatedSelect
|
||||||
label="Modules"
|
label="Modules"
|
||||||
|
|||||||
@ -925,7 +925,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-5">
|
{/* <div className="flex gap-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FormField
|
<FormField
|
||||||
label="Max Users"
|
label="Max Users"
|
||||||
@ -974,7 +974,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
<MultiselectPaginatedSelect
|
<MultiselectPaginatedSelect
|
||||||
label="Modules"
|
label="Modules"
|
||||||
placeholder="Select modules"
|
placeholder="Select modules"
|
||||||
|
|||||||
@ -304,16 +304,16 @@ const TenantDetails = (): ReactElement => {
|
|||||||
<OverviewTab tenant={tenant} stats={stats} />
|
<OverviewTab tenant={tenant} stats={stats} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "users" && id && (
|
{activeTab === "users" && id && (
|
||||||
<UsersTable tenantId={id} compact={true} />
|
<UsersTable tenantId={id} compact={false} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "roles" && id && (
|
{activeTab === "roles" && id && (
|
||||||
<RolesTable tenantId={id} compact={true} />
|
<RolesTable tenantId={id} compact={false} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "departments" && id && (
|
{activeTab === "departments" && id && (
|
||||||
<DepartmentsTable ref={departmentsRef} tenantId={id} compact={true} />
|
<DepartmentsTable ref={departmentsRef} tenantId={id} compact={true} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "designations" && id && (
|
{activeTab === "designations" && id && (
|
||||||
<DesignationsTable tenantId={id} compact={true} />
|
<DesignationsTable tenantId={id} compact={false} />
|
||||||
)}
|
)}
|
||||||
{/* {activeTab === "user-categories" && id && (
|
{/* {activeTab === "user-categories" && id && (
|
||||||
<UserCategoriesTable tenantId={id} compact={true} />
|
<UserCategoriesTable tenantId={id} compact={true} />
|
||||||
@ -437,7 +437,7 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
|
|||||||
{tenant.subscription_tier || "N/A"}
|
{tenant.subscription_tier || "N/A"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* <div>
|
||||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||||
Max Users
|
Max Users
|
||||||
</div>
|
</div>
|
||||||
@ -452,7 +452,7 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
|
|||||||
<div className="text-sm font-normal text-[#0f1724]">
|
<div className="text-sm font-normal text-[#0f1724]">
|
||||||
{tenant.max_modules || "Unlimited"}
|
{tenant.max_modules || "Unlimited"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||||
Created At
|
Created At
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
DeleteConfirmationModal,
|
// DeleteConfirmationModal,
|
||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
FilterDropdown,
|
FilterDropdown,
|
||||||
@ -94,10 +94,10 @@ const Tenants = (): ReactElement => {
|
|||||||
// View, Edit, Delete modals
|
// View, Edit, Delete modals
|
||||||
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
|
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
|
||||||
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead
|
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
||||||
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
|
// const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
|
||||||
const [selectedTenantName, setSelectedTenantName] = useState<string>("");
|
// const [selectedTenantName, setSelectedTenantName] = useState<string>("");
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
|
|
||||||
const fetchTenants = async (
|
const fetchTenants = async (
|
||||||
page: number,
|
page: number,
|
||||||
@ -181,29 +181,29 @@ const Tenants = (): ReactElement => {
|
|||||||
// Update tenant handler - removed, now handled in EditTenant page
|
// Update tenant handler - removed, now handled in EditTenant page
|
||||||
|
|
||||||
// Delete tenant handler
|
// Delete tenant handler
|
||||||
const handleDeleteTenant = (tenantId: string, tenantName: string): void => {
|
// const handleDeleteTenant = (tenantId: string, tenantName: string): void => {
|
||||||
setSelectedTenantId(tenantId);
|
// setSelectedTenantId(tenantId);
|
||||||
setSelectedTenantName(tenantName);
|
// setSelectedTenantName(tenantName);
|
||||||
setDeleteModalOpen(true);
|
// setDeleteModalOpen(true);
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Confirm delete handler
|
// Confirm delete handler
|
||||||
const handleConfirmDelete = async (): Promise<void> => {
|
// const handleConfirmDelete = async (): Promise<void> => {
|
||||||
if (!selectedTenantId) return;
|
// if (!selectedTenantId) return;
|
||||||
|
|
||||||
try {
|
// try {
|
||||||
setIsDeleting(true);
|
// setIsDeleting(true);
|
||||||
await tenantService.delete(selectedTenantId);
|
// await tenantService.delete(selectedTenantId);
|
||||||
setDeleteModalOpen(false);
|
// setDeleteModalOpen(false);
|
||||||
setSelectedTenantId(null);
|
// setSelectedTenantId(null);
|
||||||
setSelectedTenantName("");
|
// setSelectedTenantName("");
|
||||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
// await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||||
} catch (err: any) {
|
// } catch (err: any) {
|
||||||
throw err; // Let the modal handle the error display
|
// throw err; // Let the modal handle the error display
|
||||||
} finally {
|
// } finally {
|
||||||
setIsDeleting(false);
|
// setIsDeleting(false);
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Define table columns
|
// Define table columns
|
||||||
const columns: Column<Tenant>[] = [
|
const columns: Column<Tenant>[] = [
|
||||||
@ -252,17 +252,17 @@ const Tenants = (): ReactElement => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "max_users",
|
key: "user_count",
|
||||||
label: "Users",
|
label: "Users",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
{tenant.max_users ?? "N/A"}
|
{tenant.user_count ?? 0}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "subscription_tier",
|
key: "subscription_tier",
|
||||||
label: "Plan",
|
label: "Subscription Tier",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
{formatSubscriptionTier(tenant.subscription_tier)}
|
{formatSubscriptionTier(tenant.subscription_tier)}
|
||||||
@ -270,11 +270,11 @@ const Tenants = (): ReactElement => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "max_modules",
|
key: "module_count",
|
||||||
label: "Modules",
|
label: "Modules",
|
||||||
render: (tenant) => (
|
render: (tenant) => (
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
<span className="text-sm font-normal text-[#0f1724]">
|
||||||
{tenant.max_modules ?? "N/A"}
|
{tenant.module_count ?? 0}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -297,7 +297,7 @@ const Tenants = (): ReactElement => {
|
|||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onView={() => handleViewTenant(tenant.id)}
|
onView={() => handleViewTenant(tenant.id)}
|
||||||
onEdit={() => handleEditTenant(tenant.id)}
|
onEdit={() => handleEditTenant(tenant.id)}
|
||||||
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
// onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -329,7 +329,7 @@ const Tenants = (): ReactElement => {
|
|||||||
<ActionDropdown
|
<ActionDropdown
|
||||||
onView={() => handleViewTenant(tenant.id)}
|
onView={() => handleViewTenant(tenant.id)}
|
||||||
onEdit={() => handleEditTenant(tenant.id)}
|
onEdit={() => handleEditTenant(tenant.id)}
|
||||||
onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
// onDelete={() => handleDeleteTenant(tenant.id, tenant.name)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
@ -485,29 +485,8 @@ const Tenants = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Tenant Modal - Commented out, using wizard instead */}
|
|
||||||
{/* <NewTenantModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
onSubmit={handleCreateTenant}
|
|
||||||
isLoading={isCreating}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{/* View Tenant Modal - Commented out, using details page instead */}
|
|
||||||
{/* <ViewTenantModal
|
|
||||||
isOpen={viewModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setViewModalOpen(false);
|
|
||||||
setSelectedTenantId(null);
|
|
||||||
}}
|
|
||||||
tenantId={selectedTenantId}
|
|
||||||
onLoadTenant={loadTenant}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{/* Edit Tenant Modal - Removed, using edit page instead */}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
<DeleteConfirmationModal
|
{/* <DeleteConfirmationModal
|
||||||
isOpen={deleteModalOpen}
|
isOpen={deleteModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
@ -519,7 +498,7 @@ const Tenants = (): ReactElement => {
|
|||||||
message="Are you sure you want to delete this tenant"
|
message="Are you sure you want to delete this tenant"
|
||||||
itemName={selectedTenantName}
|
itemName={selectedTenantName}
|
||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
/>
|
/> */}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,30 +1,17 @@
|
|||||||
import { type ReactElement, useRef } from 'react';
|
import { type ReactElement } from 'react';
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from '@/components/layout/Layout';
|
||||||
import { DepartmentsTable, type DepartmentsTableRef } from '@/components/superadmin';
|
import { DepartmentsTable } from '@/components/superadmin';
|
||||||
import { PrimaryButton } from '@/components/shared';
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
|
|
||||||
const Departments = (): ReactElement => {
|
const Departments = (): ReactElement => {
|
||||||
const tableRef = useRef<DepartmentsTableRef>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Departments"
|
currentPage="Departments"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: 'Department Management',
|
title: 'Department Management',
|
||||||
description: 'View and manage all departments within your organization.',
|
description: 'View and manage all departments within your organization.',
|
||||||
action: (
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => tableRef.current?.openNewModal()}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>New Department</span>
|
|
||||||
</PrimaryButton>
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DepartmentsTable ref={tableRef} />
|
<DepartmentsTable />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "re
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import {
|
import {
|
||||||
Search,
|
|
||||||
Upload,
|
Upload,
|
||||||
FileText,
|
FileText,
|
||||||
Image,
|
Image,
|
||||||
@ -22,7 +21,12 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import { Pagination } from "@/components/shared";
|
import {
|
||||||
|
Pagination,
|
||||||
|
DataTable,
|
||||||
|
SearchBox,
|
||||||
|
type Column,
|
||||||
|
} from "@/components/shared";
|
||||||
import { DeleteConfirmationModal } from "@/components/shared/DeleteConfirmationModal";
|
import { DeleteConfirmationModal } from "@/components/shared/DeleteConfirmationModal";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import fileAttachmentService, {
|
import fileAttachmentService, {
|
||||||
@ -212,6 +216,126 @@ const FilesList = (): ReactElement => {
|
|||||||
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*")
|
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Table columns
|
||||||
|
const columns = useMemo<Column<FileAttachment>[]>(() => [
|
||||||
|
{
|
||||||
|
key: "original_name",
|
||||||
|
label: "File Name",
|
||||||
|
render: (file) => (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/tenant/files/${file.id}`)}
|
||||||
|
className="flex items-center gap-2.5 transition-colors text-left group/link"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
|
||||||
|
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.color = primaryColor}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.color = '#0e1b2a'}
|
||||||
|
>
|
||||||
|
{file.original_name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "file_size",
|
||||||
|
label: "Size",
|
||||||
|
render: (file) => (
|
||||||
|
<span className="text-sm text-[#6b7280]">
|
||||||
|
{file.file_size_formatted || formatBytes(file.file_size)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "category",
|
||||||
|
label: "Category",
|
||||||
|
render: (file) => (
|
||||||
|
file.category ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
|
||||||
|
getCategoryStyle(file.category)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.category}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[#c4cbd6] text-sm">—</span>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "source_module",
|
||||||
|
label: "Source Module",
|
||||||
|
render: (file) => (
|
||||||
|
file.source_module ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
|
||||||
|
getModuleStyle(file.source_module)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.source_module}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[#c4cbd6] text-sm">—</span>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "uploaded_by_email",
|
||||||
|
label: "Uploaded By",
|
||||||
|
render: (file) => (
|
||||||
|
file.uploaded_by_email ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
|
||||||
|
getAvatarColor(file.uploaded_by_email)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getInitials(file.uploaded_by_email)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
|
||||||
|
{file.uploaded_by_email.split("@")[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-[#9aa6b2]">Unknown</span>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at",
|
||||||
|
label: "Upload Date",
|
||||||
|
render: (file) => (
|
||||||
|
<span className="text-sm text-[#6b7280]">{formatDate(file.created_at)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "version",
|
||||||
|
label: "Version",
|
||||||
|
render: (file) => (
|
||||||
|
<span className="text-sm text-[#0e1b2a] font-medium">v{file.version}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
|
render: (file) => (
|
||||||
|
<ActionDropdown
|
||||||
|
onView={() => navigate(`/tenant/files/${file.id}`)}
|
||||||
|
onDownload={() => handleDownload(file)}
|
||||||
|
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
|
||||||
|
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [canUpdate, canDelete, navigate, primaryColor]);
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@ -315,6 +439,64 @@ const FilesList = (): ReactElement => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mobile card renderer
|
||||||
|
const mobileCardRenderer = (file: FileAttachment) => (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
|
||||||
|
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h3
|
||||||
|
className="text-sm font-medium text-[#0e1b2a] truncate cursor-pointer hover:underline"
|
||||||
|
style={{ '--hover-color': primaryColor } as any}
|
||||||
|
onClick={() => navigate(`/tenant/files/${file.id}`)}
|
||||||
|
>
|
||||||
|
{file.original_name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#6b7280]">
|
||||||
|
{file.file_size_formatted || formatBytes(file.file_size)} • v{file.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionDropdown
|
||||||
|
onView={() => navigate(`/tenant/files/${file.id}`)}
|
||||||
|
onDownload={() => handleDownload(file)}
|
||||||
|
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
|
||||||
|
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{file.category && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 rounded font-semibold capitalize",
|
||||||
|
getCategoryStyle(file.category)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.source_module && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 rounded font-semibold capitalize",
|
||||||
|
getModuleStyle(file.source_module)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.source_module}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">
|
||||||
|
{formatDate(file.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Render
|
// Render
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@ -345,30 +527,11 @@ const FilesList = (): ReactElement => {
|
|||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-5 py-3.5">
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-5 py-3.5">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<SearchBox
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
|
value={search}
|
||||||
<input
|
onChange={(v) => { setSearch(v); setCurrentPage(1); }}
|
||||||
id="files-search"
|
placeholder="Search by name, ID..."
|
||||||
type="text"
|
/>
|
||||||
value={search}
|
|
||||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
|
||||||
placeholder="Search by name, ID..."
|
|
||||||
className="h-9 w-[240px] pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.1)] rounded-lg text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2"
|
|
||||||
style={{
|
|
||||||
// @ts-ignore
|
|
||||||
'--tw-ring-color': `${primaryColor}33`,
|
|
||||||
borderColor: 'rgba(0,0,0,0.1)'
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = primaryColor;
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}33`;
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.1)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category filter */}
|
{/* Category filter */}
|
||||||
<FilterPill
|
<FilterPill
|
||||||
@ -409,165 +572,15 @@ const FilesList = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto">
|
<DataTable
|
||||||
<table className="w-full">
|
data={files}
|
||||||
<thead>
|
columns={columns}
|
||||||
<tr className="border-b border-[rgba(0,0,0,0.06)]">
|
keyExtractor={(file) => file.id}
|
||||||
{["File Name", "Size", "Category", "Source Module", "Uploaded By", "Upload Date", "Version", "Actions"].map((h) => (
|
isLoading={isLoading}
|
||||||
<th
|
error={error}
|
||||||
key={h}
|
emptyMessage="No files found"
|
||||||
className="px-4 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide whitespace-nowrap"
|
mobileCardRenderer={mobileCardRenderer}
|
||||||
>
|
/>
|
||||||
{h}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
|
|
||||||
{isLoading ? (
|
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
{Array.from({ length: 8 }).map((__, j) => (
|
|
||||||
<td key={j} className="px-4 py-3">
|
|
||||||
<div className="h-4 bg-gray-100 rounded animate-pulse" />
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : error ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-4 py-12 text-center text-sm text-red-500">
|
|
||||||
{error}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : files.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-4 py-12 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<FileText className="w-10 h-10 text-gray-200" />
|
|
||||||
<p className="text-sm text-[#9aa6b2]">No files found</p>
|
|
||||||
{canCreate && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowUpload(true)}
|
|
||||||
className="mt-2 text-sm font-medium hover:underline"
|
|
||||||
style={{ color: primaryColor }}
|
|
||||||
>
|
|
||||||
Upload your first file
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
files.map((file) => (
|
|
||||||
<tr
|
|
||||||
key={file.id}
|
|
||||||
className="hover:bg-[#f6f9ff]/60 transition-colors group"
|
|
||||||
>
|
|
||||||
{/* File Name */}
|
|
||||||
<td className="px-4 py-3 min-w-[200px]">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/tenant/files/${file.id}`)}
|
|
||||||
className="flex items-center gap-2.5 transition-colors text-left group/link"
|
|
||||||
>
|
|
||||||
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
|
|
||||||
{getFileIcon(file.mime_type, file.original_name, primaryColor)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="text-sm font-medium text-[#0e1b2a] truncate max-w-[200px]"
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.color = primaryColor}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.color = '#0e1b2a'}
|
|
||||||
>
|
|
||||||
{file.original_name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Size */}
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span className="text-sm text-[#6b7280]">
|
|
||||||
{file.file_size_formatted || formatBytes(file.file_size)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Category */}
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{file.category ? (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
|
|
||||||
getCategoryStyle(file.category)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{file.category}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[#c4cbd6] text-sm">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Source Module */}
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{file.source_module ? (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
|
|
||||||
getModuleStyle(file.source_module)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{file.source_module}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[#c4cbd6] text-sm">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Uploaded By */}
|
|
||||||
<td className="px-4 py-3 min-w-[140px]">
|
|
||||||
{file.uploaded_by_email ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
|
|
||||||
getAvatarColor(file.uploaded_by_email)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getInitials(file.uploaded_by_email)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
|
|
||||||
{file.uploaded_by_email.split("@")[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-[#9aa6b2]">Unknown</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Upload Date */}
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span className="text-sm text-[#6b7280]">{formatDate(file.created_at)}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Version */}
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span className="text-sm text-[#0e1b2a] font-medium">v{file.version}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<ActionDropdown
|
|
||||||
onView={() => navigate(`/tenant/files/${file.id}`)}
|
|
||||||
onDownload={() => handleDownload(file)}
|
|
||||||
onEdit={canUpdate ? () => navigate(`/tenant/files/${file.id}`) : undefined}
|
|
||||||
onDelete={canDelete ? () => openDeleteConfirm(file) : undefined}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
|
|||||||
@ -1,480 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { type ReactElement } from "react";
|
||||||
import type { ReactElement } from 'react';
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { RolesTable } from "@/components/superadmin";
|
||||||
import {
|
|
||||||
PrimaryButton,
|
|
||||||
StatusBadge,
|
|
||||||
ActionDropdown,
|
|
||||||
ViewRoleModal,
|
|
||||||
EditRoleModal,
|
|
||||||
DeleteConfirmationModal,
|
|
||||||
DataTable,
|
|
||||||
Pagination,
|
|
||||||
FilterDropdown,
|
|
||||||
SearchBox,
|
|
||||||
type Column,
|
|
||||||
} from '@/components/shared';
|
|
||||||
import { Plus, ArrowUpDown } from 'lucide-react';
|
|
||||||
import { roleService } from '@/services/role-service';
|
|
||||||
import type { Role, CreateRoleRequest, UpdateRoleRequest } from '@/types/role';
|
|
||||||
import { showToast } from '@/utils/toast';
|
|
||||||
import { NewRoleModal } from '@/components/shared/NewRoleModal';
|
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
|
||||||
import CodeBadge from '@/components/shared/CodeBadge';
|
|
||||||
|
|
||||||
// Helper function to format date
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get scope badge variant
|
|
||||||
const getScopeVariant = (scope: string): 'success' | 'failure' | 'process' => {
|
|
||||||
switch (scope.toLowerCase()) {
|
|
||||||
case 'platform':
|
|
||||||
return 'success';
|
|
||||||
case 'tenant':
|
|
||||||
return 'process';
|
|
||||||
case 'module':
|
|
||||||
return 'failure';
|
|
||||||
default:
|
|
||||||
return 'success';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Roles = (): ReactElement => {
|
const Roles = (): ReactElement => {
|
||||||
const { canCreate, canUpdate, canDelete } = usePermissions();
|
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
|
||||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
||||||
const [limit, setLimit] = useState<number>(5);
|
|
||||||
const [pagination, setPagination] = useState<{
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
}>({
|
|
||||||
page: 1,
|
|
||||||
limit: 5,
|
|
||||||
total: 0,
|
|
||||||
totalPages: 1,
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter state
|
|
||||||
// const [scopeFilter, setScopeFilter] = useState<string | null>(null);
|
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
|
||||||
|
|
||||||
// Search state
|
|
||||||
const [search, setSearch] = useState<string>('');
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
|
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
|
||||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
|
||||||
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
|
|
||||||
const [selectedRoleName, setSelectedRoleName] = useState<string>('');
|
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const fetchRoles = async (
|
|
||||||
page: number,
|
|
||||||
itemsPerPage: number,
|
|
||||||
// scope: string | null = null,
|
|
||||||
sortBy: string[] | null = null,
|
|
||||||
searchQuery: string | null = null
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await roleService.getAll(page, itemsPerPage, sortBy, searchQuery);
|
|
||||||
if (response.success) {
|
|
||||||
setRoles(response.data);
|
|
||||||
setPagination(response.pagination);
|
|
||||||
} else {
|
|
||||||
setError('Failed to load roles');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.response?.data?.error?.message || 'Failed to load roles');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle search debouncing
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setDebouncedSearch(search);
|
|
||||||
if (search) setCurrentPage(1);
|
|
||||||
}, 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchRoles(currentPage, limit, orderBy, debouncedSearch);
|
|
||||||
}, [currentPage, limit, orderBy, debouncedSearch]);
|
|
||||||
|
|
||||||
const handleCreateRole = async (data: CreateRoleRequest): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsCreating(true);
|
|
||||||
const response = await roleService.create(data);
|
|
||||||
const message = response.message || `Role created successfully`;
|
|
||||||
const description = response.message ? undefined : `${data.name} has been added`;
|
|
||||||
showToast.success(message, description);
|
|
||||||
setIsModalOpen(false);
|
|
||||||
await fetchRoles(currentPage, limit, orderBy);
|
|
||||||
} catch (err: any) {
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// View role handler
|
|
||||||
const handleViewRole = (roleId: string): void => {
|
|
||||||
setSelectedRoleId(roleId);
|
|
||||||
setViewModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Edit role handler
|
|
||||||
const handleEditRole = (roleId: string, roleName: string): void => {
|
|
||||||
setSelectedRoleId(roleId);
|
|
||||||
setSelectedRoleName(roleName);
|
|
||||||
setEditModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update role handler
|
|
||||||
const handleUpdateRole = async (
|
|
||||||
id: string,
|
|
||||||
data: UpdateRoleRequest
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsUpdating(true);
|
|
||||||
const response = await roleService.update(id, data);
|
|
||||||
const message = response.message || `Role updated successfully`;
|
|
||||||
const description = response.message ? undefined : `${data.name} has been updated`;
|
|
||||||
showToast.success(message, description);
|
|
||||||
setEditModalOpen(false);
|
|
||||||
setSelectedRoleId(null);
|
|
||||||
await fetchRoles(currentPage, limit, orderBy);
|
|
||||||
} catch (err: any) {
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete role handler
|
|
||||||
const handleDeleteRole = (roleId: string, roleName: string): void => {
|
|
||||||
setSelectedRoleId(roleId);
|
|
||||||
setSelectedRoleName(roleName);
|
|
||||||
setDeleteModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Confirm delete handler
|
|
||||||
const handleConfirmDelete = async (): Promise<void> => {
|
|
||||||
if (!selectedRoleId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsDeleting(true);
|
|
||||||
await roleService.delete(selectedRoleId);
|
|
||||||
setDeleteModalOpen(false);
|
|
||||||
setSelectedRoleId(null);
|
|
||||||
setSelectedRoleName('');
|
|
||||||
await fetchRoles(currentPage, limit, orderBy);
|
|
||||||
} catch (err: any) {
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load role for view/edit
|
|
||||||
const loadRole = async (id: string): Promise<Role> => {
|
|
||||||
const response = await roleService.getById(id);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Table columns
|
|
||||||
const columns: Column<Role>[] = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
label: 'Name',
|
|
||||||
render: (role) => (
|
|
||||||
<span className="text-sm font-normal text-[#0f1724]">{role.name}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'code',
|
|
||||||
label: 'Code',
|
|
||||||
render: (role) => (
|
|
||||||
<CodeBadge label={role.code} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scope',
|
|
||||||
label: 'Scope',
|
|
||||||
render: (role) => (
|
|
||||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'user_count',
|
|
||||||
label: 'Users',
|
|
||||||
render: (role) => (
|
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
{role.user_count || 0}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'description',
|
|
||||||
label: 'Description',
|
|
||||||
render: (role) => (
|
|
||||||
<span className="text-sm font-normal text-[#6b7280]">
|
|
||||||
{role.description || 'N/A'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// key: 'is_system',
|
|
||||||
// label: 'System Role',
|
|
||||||
// render: (role) => (
|
|
||||||
// <span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
// {role.is_system ? 'Yes' : 'No'}
|
|
||||||
// </span>
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
key: 'created_at',
|
|
||||||
label: 'Created Date',
|
|
||||||
render: (role) => (
|
|
||||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(role.created_at)}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
label: 'Actions',
|
|
||||||
align: 'right',
|
|
||||||
render: (role) => (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<ActionDropdown
|
|
||||||
onView={() => handleViewRole(role.id)}
|
|
||||||
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
|
|
||||||
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mobile card renderer
|
|
||||||
const mobileCardRenderer = (role: Role) => (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">{role.name}</h3>
|
|
||||||
<p className="text-xs text-[#6b7280] mt-0.5 font-mono">{role.code}</p>
|
|
||||||
</div>
|
|
||||||
<ActionDropdown
|
|
||||||
onView={() => handleViewRole(role.id)}
|
|
||||||
onEdit={canUpdate('roles') ? () => handleEditRole(role.id, role.name) : undefined}
|
|
||||||
onDelete={canDelete('roles') ? () => handleDeleteRole(role.id, role.name) : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<span className="text-[#9aa6b2]">Scope:</span>
|
|
||||||
<div className="mt-1">
|
|
||||||
<StatusBadge variant={getScopeVariant(role.scope)}>{role.scope}</StatusBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[#9aa6b2]">Users:</span>
|
|
||||||
<p className="text-[#0f1724] font-normal mt-1">{role.user_count || 0}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[#9aa6b2]">Created:</span>
|
|
||||||
<p className="text-[#0f1724] font-normal mt-1">{formatDate(role.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
{role.description && (
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-[#9aa6b2]">Description:</span>
|
|
||||||
<p className="text-[#0f1724] font-normal mt-1">{role.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Roles"
|
currentPage="Roles"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: 'Role List',
|
title: "Role Management",
|
||||||
description: 'Define and manage roles to control user access based on job responsibilities',
|
description: "Define and manage roles to control user access based on job responsibilities",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Table Container */}
|
<RolesTable showHeader={true} />
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
|
||||||
{/* Table Header with Filters */}
|
|
||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{/* Global Search */}
|
|
||||||
<SearchBox
|
|
||||||
value={search}
|
|
||||||
onChange={setSearch}
|
|
||||||
placeholder="Search by name, code or description..."
|
|
||||||
/>
|
|
||||||
{/* Scope Filter */}
|
|
||||||
{/* <FilterDropdown
|
|
||||||
label="Scope"
|
|
||||||
options={[
|
|
||||||
{ value: 'platform', label: 'Platform' },
|
|
||||||
{ value: 'tenant', label: 'Tenant' },
|
|
||||||
{ value: 'module', label: 'Module' },
|
|
||||||
]}
|
|
||||||
value={scopeFilter}
|
|
||||||
onChange={(value) => {
|
|
||||||
setScopeFilter(value as string | null);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
placeholder="All"
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{/* Sort Filter */}
|
|
||||||
<FilterDropdown
|
|
||||||
label="Sort by"
|
|
||||||
options={[
|
|
||||||
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
|
||||||
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
|
||||||
{ value: ['code', 'asc'], label: 'Code (A-Z)' },
|
|
||||||
{ value: ['code', 'desc'], label: 'Code (Z-A)' },
|
|
||||||
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
|
||||||
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
|
||||||
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
|
||||||
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
|
||||||
]}
|
|
||||||
value={orderBy}
|
|
||||||
onChange={(value) => {
|
|
||||||
setOrderBy(value as string[] | null);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
placeholder="Default"
|
|
||||||
showIcon
|
|
||||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Export Button */}
|
|
||||||
{/* <button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
<span>Export</span>
|
|
||||||
</button> */}
|
|
||||||
|
|
||||||
{/* New Role Button */}
|
|
||||||
{canCreate('roles') && (
|
|
||||||
<PrimaryButton
|
|
||||||
size="default"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
<span className="text-xs">New Role</span>
|
|
||||||
</PrimaryButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DataTable
|
|
||||||
data={roles}
|
|
||||||
columns={columns}
|
|
||||||
keyExtractor={(role) => role.id}
|
|
||||||
mobileCardRenderer={mobileCardRenderer}
|
|
||||||
emptyMessage="No roles found"
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Table Footer with Pagination */}
|
|
||||||
{pagination.total > 0 && (
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={pagination.totalPages}
|
|
||||||
totalItems={pagination.total}
|
|
||||||
limit={limit}
|
|
||||||
onPageChange={(page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
}}
|
|
||||||
onLimitChange={(newLimit: number) => {
|
|
||||||
setLimit(newLimit);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New Role Modal */}
|
|
||||||
<NewRoleModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
onSubmit={handleCreateRole}
|
|
||||||
isLoading={isCreating}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* View Role Modal */}
|
|
||||||
<ViewRoleModal
|
|
||||||
isOpen={viewModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setViewModalOpen(false);
|
|
||||||
setSelectedRoleId(null);
|
|
||||||
}}
|
|
||||||
roleId={selectedRoleId}
|
|
||||||
onLoadRole={loadRole}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Edit Role Modal */}
|
|
||||||
<EditRoleModal
|
|
||||||
isOpen={editModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setEditModalOpen(false);
|
|
||||||
setSelectedRoleId(null);
|
|
||||||
setSelectedRoleName('');
|
|
||||||
}}
|
|
||||||
roleId={selectedRoleId}
|
|
||||||
onLoadRole={loadRole}
|
|
||||||
onSubmit={handleUpdateRole}
|
|
||||||
isLoading={isUpdating}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
<DeleteConfirmationModal
|
|
||||||
isOpen={deleteModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setDeleteModalOpen(false);
|
|
||||||
setSelectedRoleId(null);
|
|
||||||
setSelectedRoleName('');
|
|
||||||
}}
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
title="Delete Role"
|
|
||||||
message={`Are you sure you want to delete this role`}
|
|
||||||
itemName={selectedRoleName}
|
|
||||||
isLoading={isDeleting}
|
|
||||||
/>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,652 +1,17 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { type ReactElement } from "react";
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import { UsersTable } from "@/components/superadmin";
|
||||||
PrimaryButton,
|
|
||||||
StatusBadge,
|
|
||||||
ActionDropdown,
|
|
||||||
NewUserModal,
|
|
||||||
ViewUserModal,
|
|
||||||
EditUserModal,
|
|
||||||
// DeleteConfirmationModal,
|
|
||||||
DataTable,
|
|
||||||
Pagination,
|
|
||||||
FilterDropdown,
|
|
||||||
SearchBox,
|
|
||||||
type Column,
|
|
||||||
} from "@/components/shared";
|
|
||||||
import { Plus, ArrowUpDown } from "lucide-react";
|
|
||||||
import { userService } from "@/services/user-service";
|
|
||||||
import { roleService } from "@/services/role-service";
|
|
||||||
import type { User } from "@/types/user";
|
|
||||||
import type { Role } from "@/types/role";
|
|
||||||
import { showToast } from "@/utils/toast";
|
|
||||||
import { usePermissions } from "@/hooks/usePermissions";
|
|
||||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
|
||||||
|
|
||||||
// Helper function to get user initials
|
|
||||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
|
||||||
return `${firstName[0]}${lastName[0]}`.toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to format date
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get status badge variant
|
|
||||||
const getStatusVariant = (
|
|
||||||
status: string,
|
|
||||||
): "success" | "failure" | "process" => {
|
|
||||||
switch (status.toLowerCase()) {
|
|
||||||
case "active":
|
|
||||||
return "success";
|
|
||||||
case "pending_verification":
|
|
||||||
return "process";
|
|
||||||
case "inactive":
|
|
||||||
return "failure";
|
|
||||||
case "deleted":
|
|
||||||
return "failure";
|
|
||||||
case "suspended":
|
|
||||||
return "process";
|
|
||||||
default:
|
|
||||||
return "success";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Users = (): ReactElement => {
|
const Users = (): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
|
||||||
const { canCreate, canUpdate
|
|
||||||
// , canDelete
|
|
||||||
} = usePermissions();
|
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
|
||||||
const [isCreating, setIsCreating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
||||||
const [limit, setLimit] = useState<number>(5);
|
|
||||||
const [pagination, setPagination] = useState<{
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
}>({
|
|
||||||
page: 1,
|
|
||||||
limit: 5,
|
|
||||||
total: 0,
|
|
||||||
totalPages: 1,
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter state
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
|
||||||
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Search state
|
|
||||||
const [search, setSearch] = useState<string>("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
|
|
||||||
|
|
||||||
// Roles list for filter
|
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
|
||||||
|
|
||||||
// View, Edit, Delete modals
|
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
|
||||||
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
|
|
||||||
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
||||||
// const [selectedUserName, setSelectedUserName] = useState<string>("");
|
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
|
||||||
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const fetchUsers = async (
|
|
||||||
page: number,
|
|
||||||
itemsPerPage: number,
|
|
||||||
status: string | null = null,
|
|
||||||
sortBy: string[] | null = null,
|
|
||||||
searchQuery: string | null = null,
|
|
||||||
roleId: string | null = null,
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const response = await userService.getAll(
|
|
||||||
page,
|
|
||||||
itemsPerPage,
|
|
||||||
status,
|
|
||||||
sortBy,
|
|
||||||
searchQuery,
|
|
||||||
roleId,
|
|
||||||
);
|
|
||||||
if (response.success) {
|
|
||||||
setUsers(response.data);
|
|
||||||
setPagination(response.pagination);
|
|
||||||
} else {
|
|
||||||
setError("Failed to load users");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.response?.data?.error?.message || "Failed to load users");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle search debouncing
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setDebouncedSearch(search);
|
|
||||||
// We only reset to first page if we are actively searching.
|
|
||||||
if (search) setCurrentPage(1);
|
|
||||||
}, 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
// Fetch roles for filter
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchRoles = async () => {
|
|
||||||
try {
|
|
||||||
const response = await roleService.getAll(1, 100);
|
|
||||||
if (response.success) {
|
|
||||||
setRoles(response.data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch roles:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchRoles();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch users on mount and when pagination/filters change
|
|
||||||
useEffect(() => {
|
|
||||||
fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter);
|
|
||||||
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter]);
|
|
||||||
|
|
||||||
const handleCreateUser = async (data: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
status: "active" | "suspended" | "deleted";
|
|
||||||
auth_provider: "local";
|
|
||||||
role_module_combinations: { role_id: string; module_id?: string | null }[];
|
|
||||||
department_id?: string;
|
|
||||||
designation_id?: string;
|
|
||||||
}): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsCreating(true);
|
|
||||||
const response = await userService.create(data);
|
|
||||||
const message = response.message || `User created successfully`;
|
|
||||||
const description = response.message
|
|
||||||
? undefined
|
|
||||||
: `${data.first_name} ${data.last_name} has been added`;
|
|
||||||
showToast.success(message, description);
|
|
||||||
setIsModalOpen(false);
|
|
||||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
|
||||||
} catch (err: any) {
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// View user handler
|
|
||||||
const handleViewUser = (userId: string): void => {
|
|
||||||
setSelectedUserId(userId);
|
|
||||||
setViewModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Edit user handler
|
|
||||||
const handleEditUser = (userId: string
|
|
||||||
// , userName: string
|
|
||||||
): void => {
|
|
||||||
setSelectedUserId(userId);
|
|
||||||
// setSelectedUserName(userName);
|
|
||||||
setEditModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update user handler
|
|
||||||
const handleUpdateUser = async (
|
|
||||||
id: string,
|
|
||||||
data: {
|
|
||||||
email: string;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
status: "active" | "suspended" | "deleted";
|
|
||||||
tenant_id: string;
|
|
||||||
role_module_combinations: { role_id: string; module_id?: string | null }[];
|
|
||||||
department_id?: string;
|
|
||||||
designation_id?: string;
|
|
||||||
},
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsUpdating(true);
|
|
||||||
const response = await userService.update(id, data);
|
|
||||||
const message = response.message || `User updated successfully`;
|
|
||||||
const description = response.message
|
|
||||||
? undefined
|
|
||||||
: `${data.first_name} ${data.last_name} has been updated`;
|
|
||||||
showToast.success(message, description);
|
|
||||||
setEditModalOpen(false);
|
|
||||||
setSelectedUserId(null);
|
|
||||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
|
||||||
} catch (err: any) {
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete user handler
|
|
||||||
// const handleDeleteUser = (userId: string, userName: string): void => {
|
|
||||||
// setSelectedUserId(userId);
|
|
||||||
// setSelectedUserName(userName);
|
|
||||||
// setDeleteModalOpen(true);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// Confirm delete handler
|
|
||||||
// const handleConfirmDelete = async (): Promise<void> => {
|
|
||||||
// if (!selectedUserId) return;
|
|
||||||
|
|
||||||
// try {
|
|
||||||
// setIsDeleting(true);
|
|
||||||
// await userService.delete(selectedUserId);
|
|
||||||
// setDeleteModalOpen(false);
|
|
||||||
// setSelectedUserId(null);
|
|
||||||
// setSelectedUserName("");
|
|
||||||
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
|
||||||
// } catch (err: any) {
|
|
||||||
// throw err;
|
|
||||||
// } finally {
|
|
||||||
// setIsDeleting(false);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// Load user for view/edit
|
|
||||||
const loadUser = async (id: string): Promise<User> => {
|
|
||||||
const response = await userService.getById(id);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define table columns
|
|
||||||
const columns: Column<User>[] = [
|
|
||||||
{
|
|
||||||
key: "name",
|
|
||||||
label: "User Name",
|
|
||||||
render: (user) => (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
|
||||||
{getUserInitials(user.first_name, user.last_name)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
mobileLabel: "Name",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "email",
|
|
||||||
label: "Email",
|
|
||||||
render: (user) => (
|
|
||||||
<span className="text-sm font-normal text-[#0f1724]">{user.email}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "role",
|
|
||||||
label: "Role",
|
|
||||||
render: (user) => (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{user.role_module_combinations && user.role_module_combinations.length > 0 ? (
|
|
||||||
user.role_module_combinations.map((combo, idx) => (
|
|
||||||
<span
|
|
||||||
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
|
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
|
|
||||||
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
|
||||||
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
|
|
||||||
>
|
|
||||||
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : user.roles && user.roles.length > 0 ? (
|
|
||||||
user.roles.map((role) => (
|
|
||||||
<span
|
|
||||||
key={role.id}
|
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
|
|
||||||
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
|
|
||||||
>
|
|
||||||
{role.name}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
{user.role?.name || "-"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
label: "Status",
|
|
||||||
render: (user) => (
|
|
||||||
<StatusBadge variant={getStatusVariant(user.status)}>
|
|
||||||
{user.status}
|
|
||||||
</StatusBadge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "auth_provider",
|
|
||||||
label: "Auth Provider",
|
|
||||||
render: (user) => (
|
|
||||||
<span className="text-sm font-normal text-[#0f1724]">
|
|
||||||
{user.auth_provider}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "created_at",
|
|
||||||
label: "Joined Date",
|
|
||||||
render: (user) => (
|
|
||||||
<span className="text-sm font-normal text-[#6b7280]">
|
|
||||||
{formatDate(user.created_at)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
mobileLabel: "Joined",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
label: "Actions",
|
|
||||||
align: "right",
|
|
||||||
render: (user) => (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<ActionDropdown
|
|
||||||
onView={() => handleViewUser(user.id)}
|
|
||||||
onEdit={
|
|
||||||
canUpdate("users")
|
|
||||||
? () =>
|
|
||||||
handleEditUser(
|
|
||||||
user.id,
|
|
||||||
// `${user.first_name} ${user.last_name}`,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
// onDelete={
|
|
||||||
// canDelete("users")
|
|
||||||
// ? () =>
|
|
||||||
// handleDeleteUser(
|
|
||||||
// user.id,
|
|
||||||
// `${user.first_name} ${user.last_name}`,
|
|
||||||
// )
|
|
||||||
// : undefined
|
|
||||||
// }
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mobile card renderer
|
|
||||||
const mobileCardRenderer = (user: User) => (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-xs font-normal text-[#9aa6b2]">
|
|
||||||
{getUserInitials(user.first_name, user.last_name)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-medium text-[#0f1724] truncate">
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-[#6b7280] mt-0.5 truncate">
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ActionDropdown
|
|
||||||
onView={() => handleViewUser(user.id)}
|
|
||||||
onEdit={
|
|
||||||
canUpdate("users")
|
|
||||||
? () =>
|
|
||||||
handleEditUser(
|
|
||||||
user.id,
|
|
||||||
// `${user.first_name} ${user.last_name}`,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
// onDelete={
|
|
||||||
// canDelete("users")
|
|
||||||
// ? () =>
|
|
||||||
// handleDeleteUser(
|
|
||||||
// user.id,
|
|
||||||
// `${user.first_name} ${user.last_name}`,
|
|
||||||
// )
|
|
||||||
// : undefined
|
|
||||||
// }
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<span className="text-[#9aa6b2]">Status:</span>
|
|
||||||
<div className="mt-1">
|
|
||||||
<StatusBadge variant={getStatusVariant(user.status)}>
|
|
||||||
{user.status}
|
|
||||||
</StatusBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[#9aa6b2]">Auth Provider:</span>
|
|
||||||
<p className="text-[#0f1724] font-normal mt-1">
|
|
||||||
{user.auth_provider}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[#9aa6b2]">Joined:</span>
|
|
||||||
<p className="text-[#6b7280] font-normal mt-1">
|
|
||||||
{formatDate(user.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
currentPage="Users"
|
currentPage="Users"
|
||||||
pageHeader={{
|
pageHeader={{
|
||||||
title: "User List",
|
title: "User Management",
|
||||||
description:
|
description: "View and manage all users within your organization.",
|
||||||
"View and manage all users in your QAssure platform from a single place.",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Table Container */}
|
<UsersTable showHeader={true} />
|
||||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
|
||||||
{/* Table Header with Filters */}
|
|
||||||
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{/* Global Search */}
|
|
||||||
<SearchBox
|
|
||||||
value={search}
|
|
||||||
onChange={setSearch}
|
|
||||||
placeholder="Search by name or email..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<FilterDropdown
|
|
||||||
label="Status"
|
|
||||||
options={[
|
|
||||||
{ value: "active", label: "Active" },
|
|
||||||
// {
|
|
||||||
// value: "pending_verification",
|
|
||||||
// label: "Pending Verification",
|
|
||||||
// },
|
|
||||||
// { value: "inactive", label: "Inactive" },
|
|
||||||
{ value: "suspended", label: "Suspended" },
|
|
||||||
{ value: "deleted", label: "Deleted" },
|
|
||||||
]}
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(value) => {
|
|
||||||
setStatusFilter(value as string | null);
|
|
||||||
setCurrentPage(1); // Reset to first page when filter changes
|
|
||||||
}}
|
|
||||||
placeholder="All"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Role Filter */}
|
|
||||||
<FilterDropdown
|
|
||||||
label="Role"
|
|
||||||
options={[
|
|
||||||
// { value: "", label: "All" },
|
|
||||||
...roles.map(role => ({ value: role.id, label: role.name }))
|
|
||||||
]}
|
|
||||||
value={roleFilter || ""}
|
|
||||||
onChange={(value) => {
|
|
||||||
setRoleFilter(Array.isArray(value) ? null : value || null);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
placeholder="All"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Sort Filter */}
|
|
||||||
<FilterDropdown
|
|
||||||
label="Sort by"
|
|
||||||
options={[
|
|
||||||
{ value: ["first_name", "asc"], label: "First Name (A-Z)" },
|
|
||||||
{ value: ["first_name", "desc"], label: "First Name (Z-A)" },
|
|
||||||
{ value: ["last_name", "asc"], label: "Last Name (A-Z)" },
|
|
||||||
{ value: ["last_name", "desc"], label: "Last Name (Z-A)" },
|
|
||||||
{ value: ["email", "asc"], label: "Email (A-Z)" },
|
|
||||||
{ value: ["email", "desc"], label: "Email (Z-A)" },
|
|
||||||
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
|
|
||||||
{ value: ["created_at", "desc"], label: "Created (Newest)" },
|
|
||||||
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
|
|
||||||
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
|
|
||||||
]}
|
|
||||||
value={orderBy}
|
|
||||||
onChange={(value) => {
|
|
||||||
setOrderBy(value as string[] | null);
|
|
||||||
setCurrentPage(1); // Reset to first page when sort changes
|
|
||||||
}}
|
|
||||||
placeholder="Default"
|
|
||||||
showIcon
|
|
||||||
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Export Button */}
|
|
||||||
{/* <button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
<span>Export</span>
|
|
||||||
</button> */}
|
|
||||||
|
|
||||||
{/* New User Button */}
|
|
||||||
{canCreate("users") && (
|
|
||||||
<PrimaryButton
|
|
||||||
size="default"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
<span className="text-xs">New User</span>
|
|
||||||
</PrimaryButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Table */}
|
|
||||||
<DataTable
|
|
||||||
data={users}
|
|
||||||
columns={columns}
|
|
||||||
keyExtractor={(user) => user.id}
|
|
||||||
mobileCardRenderer={mobileCardRenderer}
|
|
||||||
emptyMessage="No users found"
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Table Footer with Pagination */}
|
|
||||||
{pagination.total > 0 && (
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={pagination.totalPages}
|
|
||||||
totalItems={pagination.total}
|
|
||||||
limit={limit}
|
|
||||||
onPageChange={(page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
}}
|
|
||||||
onLimitChange={(newLimit: number) => {
|
|
||||||
setLimit(newLimit);
|
|
||||||
setCurrentPage(1); // Reset to first page when limit changes
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New User Modal */}
|
|
||||||
<NewUserModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
onSubmit={handleCreateUser}
|
|
||||||
isLoading={isCreating}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* View User Modal */}
|
|
||||||
<ViewUserModal
|
|
||||||
isOpen={viewModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setViewModalOpen(false);
|
|
||||||
setSelectedUserId(null);
|
|
||||||
}}
|
|
||||||
userId={selectedUserId}
|
|
||||||
onLoadUser={loadUser}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Edit User Modal */}
|
|
||||||
<EditUserModal
|
|
||||||
isOpen={editModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setEditModalOpen(false);
|
|
||||||
setSelectedUserId(null);
|
|
||||||
// setSelectedUserName("");
|
|
||||||
}}
|
|
||||||
userId={selectedUserId}
|
|
||||||
onLoadUser={loadUser}
|
|
||||||
onSubmit={handleUpdateUser}
|
|
||||||
isLoading={isUpdating}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{/* <DeleteConfirmationModal
|
|
||||||
isOpen={deleteModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setDeleteModalOpen(false);
|
|
||||||
setSelectedUserId(null);
|
|
||||||
setSelectedUserName("");
|
|
||||||
}}
|
|
||||||
onConfirm={handleConfirmDelete}
|
|
||||||
title="Delete User"
|
|
||||||
message="Are you sure you want to delete this user"
|
|
||||||
itemName={selectedUserName}
|
|
||||||
isLoading={isDeleting}
|
|
||||||
/> */}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -66,6 +66,8 @@ export interface Tenant {
|
|||||||
assignedModules?: AssignedModule[]; // Array of assigned modules with full details
|
assignedModules?: AssignedModule[]; // Array of assigned modules with full details
|
||||||
users?: TenantUser[]; // Array of tenant users
|
users?: TenantUser[]; // Array of tenant users
|
||||||
tenant_admin?: TenantAdmin; // Tenant admin user details
|
tenant_admin?: TenantAdmin; // Tenant admin user details
|
||||||
|
user_count?: number;
|
||||||
|
module_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user