feat: implement comprehensive workflow definition management, including UI components, services, and routing for creating, editing, and listing workflow definitions.
This commit is contained in:
parent
939bd4ddc9
commit
f460a89201
@ -1,4 +1,4 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
@ -9,12 +9,14 @@ import {
|
||||
HelpCircle,
|
||||
X,
|
||||
Shield,
|
||||
BadgeCheck
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { useTenantTheme } from '@/hooks/useTenantTheme';
|
||||
import { AuthenticatedImage } from '@/components/shared';
|
||||
BadgeCheck,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
import { useTenantTheme } from "@/hooks/useTenantTheme";
|
||||
import { AuthenticatedImage } from "@/components/shared";
|
||||
|
||||
interface MenuItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
@ -33,31 +35,67 @@ interface SidebarProps {
|
||||
|
||||
// Super Admin menu items
|
||||
const superAdminPlatformMenu: MenuItem[] = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' },
|
||||
{ icon: Building2, label: 'Tenants', path: '/tenants' },
|
||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/dashboard" },
|
||||
{ icon: Building2, label: "Tenants", path: "/tenants" },
|
||||
// { icon: Users, label: 'User Management', path: '/users' },
|
||||
// { icon: Shield, label: 'Roles', path: '/roles' },
|
||||
{ icon: Package, label: 'Modules', path: '/modules' },
|
||||
{ icon: Package, label: "Modules", path: "/modules" },
|
||||
];
|
||||
|
||||
const superAdminSystemMenu: MenuItem[] = [
|
||||
{ icon: FileText, label: 'Audit Logs', path: '/audit-logs' },
|
||||
{ icon: FileText, label: "Audit Logs", path: "/audit-logs" },
|
||||
// { icon: Settings, label: 'Settings', path: '/settings' },
|
||||
];
|
||||
|
||||
// Tenant Admin menu items
|
||||
const tenantAdminPlatformMenu: MenuItem[] = [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' },
|
||||
{ icon: Shield, label: 'Roles', path: '/tenant/roles', requiredPermission: { resource: 'roles' } },
|
||||
{ icon: Users, label: 'Users', path: '/tenant/users', requiredPermission: { resource: 'users' } },
|
||||
{ icon: Building2, label: 'Departments', path: '/tenant/departments', requiredPermission: { resource: 'departments' } },
|
||||
{ icon: BadgeCheck, label: 'Designations', path: '/tenant/designations', requiredPermission: { resource: 'designations' } },
|
||||
{ icon: Package, label: 'Modules', path: '/tenant/modules' },
|
||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/tenant" },
|
||||
{
|
||||
icon: Shield,
|
||||
label: "Roles",
|
||||
path: "/tenant/roles",
|
||||
requiredPermission: { resource: "roles" },
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: "Users",
|
||||
path: "/tenant/users",
|
||||
requiredPermission: { resource: "users" },
|
||||
},
|
||||
{
|
||||
icon: Building2,
|
||||
label: "Departments",
|
||||
path: "/tenant/departments",
|
||||
requiredPermission: { resource: "departments" },
|
||||
},
|
||||
{
|
||||
icon: BadgeCheck,
|
||||
label: "Designations",
|
||||
path: "/tenant/designations",
|
||||
requiredPermission: { resource: "designations" },
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
label: "Workflow Definitions",
|
||||
path: "/tenant/workflow-definitions",
|
||||
requiredPermission: { resource: "workflow" },
|
||||
},
|
||||
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
||||
];
|
||||
|
||||
const tenantAdminSystemMenu: MenuItem[] = [
|
||||
{ icon: FileText, label: 'Audit Logs', path: '/tenant/audit-logs', requiredPermission: { resource: 'audit_logs' } },
|
||||
{ icon: Settings, label: 'Settings', path: '/tenant/settings', requiredPermission: { resource: 'tenants' } },
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Audit Logs",
|
||||
path: "/tenant/audit-logs",
|
||||
requiredPermission: { resource: "audit_logs" },
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: "Settings",
|
||||
path: "/tenant/settings",
|
||||
requiredPermission: { resource: "tenants" },
|
||||
},
|
||||
];
|
||||
|
||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
@ -70,14 +108,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
let rolesArray: string[] = [];
|
||||
if (Array.isArray(roles)) {
|
||||
rolesArray = roles;
|
||||
} else if (typeof roles === 'string') {
|
||||
} else if (typeof roles === "string") {
|
||||
try {
|
||||
rolesArray = JSON.parse(roles);
|
||||
} catch {
|
||||
rolesArray = [];
|
||||
}
|
||||
}
|
||||
return rolesArray.includes('super_admin');
|
||||
return rolesArray.includes("super_admin");
|
||||
};
|
||||
|
||||
const isSuperAdmin = isSuperAdminCheck();
|
||||
@ -85,12 +123,12 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
// Get role name for display
|
||||
const getRoleName = (): string => {
|
||||
if (isSuperAdmin) {
|
||||
return 'Super Admin';
|
||||
return "Super Admin";
|
||||
}
|
||||
let rolesArray: string[] = [];
|
||||
if (Array.isArray(roles)) {
|
||||
rolesArray = roles;
|
||||
} else if (typeof roles === 'string') {
|
||||
} else if (typeof roles === "string") {
|
||||
try {
|
||||
rolesArray = JSON.parse(roles);
|
||||
} catch {
|
||||
@ -102,11 +140,13 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
const role = rolesArray[0];
|
||||
// Convert snake_case to Title Case
|
||||
return role
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
.split("_")
|
||||
.map(
|
||||
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
return 'User';
|
||||
return "User";
|
||||
};
|
||||
|
||||
const roleName = getRoleName();
|
||||
@ -117,20 +157,24 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
}
|
||||
|
||||
// Helper function to check if user has permission for a resource
|
||||
const hasPermission = (resource: string, requiredAction?: string): boolean => {
|
||||
const hasPermission = (
|
||||
resource: string,
|
||||
requiredAction?: string,
|
||||
): boolean => {
|
||||
if (isSuperAdmin) {
|
||||
return true; // Super admin has all permissions
|
||||
}
|
||||
|
||||
const allowedActions = requiredAction ? [requiredAction] : ['*', 'read'];
|
||||
const allowedActions = requiredAction ? [requiredAction] : ["*", "read"];
|
||||
|
||||
return permissions.some((perm) => {
|
||||
// Check if resource matches (exact match or wildcard)
|
||||
const resourceMatches = perm.resource === resource || perm.resource === '*';
|
||||
const resourceMatches =
|
||||
perm.resource === resource || perm.resource === "*";
|
||||
|
||||
// Check if action matches (exact match or wildcard)
|
||||
const actionMatches = allowedActions.some(
|
||||
(allowedAction) => perm.action === allowedAction || perm.action === '*'
|
||||
(allowedAction) => perm.action === allowedAction || perm.action === "*",
|
||||
);
|
||||
|
||||
return resourceMatches && actionMatches;
|
||||
@ -151,20 +195,26 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
|
||||
return hasPermission(
|
||||
item.requiredPermission.resource,
|
||||
item.requiredPermission.action
|
||||
item.requiredPermission.action,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Select and filter menu items based on role and permissions
|
||||
const platformMenu = filterMenuItems(
|
||||
isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu
|
||||
isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu,
|
||||
);
|
||||
const systemMenu = filterMenuItems(
|
||||
isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu
|
||||
isSuperAdmin ? superAdminSystemMenu : tenantAdminSystemMenu,
|
||||
);
|
||||
|
||||
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||
const MenuSection = ({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: MenuItem[];
|
||||
}) => (
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="pb-1 px-2 md:px-2 lg:px-3">
|
||||
@ -187,22 +237,30 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 md:gap-2 lg:gap-2.5 px-2 md:px-2 lg:px-3 py-2 rounded-md transition-colors min-h-[44px]',
|
||||
"flex items-center gap-2 md:gap-2 lg:gap-2.5 px-2 md:px-2 lg:px-3 py-2 rounded-md transition-colors min-h-[44px]",
|
||||
isActive
|
||||
? 'shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]'
|
||||
: 'text-[#0f1724] hover:bg-gray-50'
|
||||
? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]"
|
||||
: "text-[#0f1724] hover:bg-gray-50",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? {
|
||||
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
|
||||
color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : '#23dce1',
|
||||
backgroundColor:
|
||||
!isSuperAdmin && theme?.primary_color
|
||||
? theme.primary_color
|
||||
: "#112868",
|
||||
color:
|
||||
!isSuperAdmin && theme?.secondary_color
|
||||
? theme.secondary_color
|
||||
: "#23dce1",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span className="text-xs md:text-xs lg:text-[13px] font-medium whitespace-nowrap">{item.label}</span>
|
||||
<span className="text-xs md:text-xs lg:text-[13px] font-medium whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@ -216,10 +274,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
{/* Mobile Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-0 left-0 h-full bg-white border-r border-[rgba(0,0,0,0.08)] z-50 flex flex-col gap-6 p-4 transition-transform duration-300 ease-in-out md:hidden',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
"fixed top-0 left-0 h-full bg-white border-r border-[rgba(0,0,0,0.08)] z-50 flex flex-col gap-6 p-4 transition-transform duration-300 ease-in-out md:hidden",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
style={{ width: '280px' }}
|
||||
style={{ width: "280px" }}
|
||||
>
|
||||
{/* Mobile Header with Close Button */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@ -234,21 +292,27 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
|
||||
style={{
|
||||
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex',
|
||||
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
|
||||
display: !isSuperAdmin && logoUrl ? "none" : "flex",
|
||||
backgroundColor:
|
||||
!isSuperAdmin && theme?.primary_color
|
||||
? theme.primary_color
|
||||
: "#112868",
|
||||
}}
|
||||
>
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
||||
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
|
||||
{!isSuperAdmin && logoUrl ? "" : "QAssure"}
|
||||
</div>
|
||||
{(!isSuperAdmin && logoUrl) ? null : (
|
||||
{!isSuperAdmin && logoUrl ? null : (
|
||||
<div
|
||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||
style={{
|
||||
color: !isSuperAdmin && theme?.accent_color ? theme.accent_color : '#084cc8',
|
||||
color:
|
||||
!isSuperAdmin && theme?.accent_color
|
||||
? theme.accent_color
|
||||
: "#084cc8",
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
@ -275,7 +339,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<div className="mt-auto">
|
||||
<button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-[13px] py-[9px] flex gap-2.5 items-center hover:bg-gray-50 transition-colors min-h-[44px]">
|
||||
<HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
|
||||
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span>
|
||||
<span className="text-[13px] font-medium text-[#0f1724]">
|
||||
Support Center
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -303,30 +369,37 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
alt="Logo"
|
||||
className="h-9 w-auto max-w-[180px] object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = 'flex';
|
||||
e.currentTarget.style.display = "none";
|
||||
const fallback = e.currentTarget
|
||||
.nextElementSibling as HTMLElement;
|
||||
if (fallback) fallback.style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] flex items-center justify-center shadow-[0px_4px_12px_0px_rgba(15,23,42,0.1)] shrink-0"
|
||||
style={{
|
||||
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex',
|
||||
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868',
|
||||
display: !isSuperAdmin && logoUrl ? "none" : "flex",
|
||||
backgroundColor:
|
||||
!isSuperAdmin && theme?.primary_color
|
||||
? theme.primary_color
|
||||
: "#112868",
|
||||
}}
|
||||
>
|
||||
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-base md:text-base lg:text-base xl:text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
|
||||
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
|
||||
{!isSuperAdmin && logoUrl ? "" : "QAssure"}
|
||||
</div>
|
||||
{(!isSuperAdmin && logoUrl) ? null : (
|
||||
{!isSuperAdmin && logoUrl ? null : (
|
||||
<div
|
||||
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
|
||||
style={{
|
||||
color: !isSuperAdmin && theme?.accent_color ? theme.accent_color : '#084cc8',
|
||||
color:
|
||||
!isSuperAdmin && theme?.accent_color
|
||||
? theme.accent_color
|
||||
: "#084cc8",
|
||||
}}
|
||||
>
|
||||
{roleName}
|
||||
@ -350,7 +423,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
<div className="mt-auto w-full">
|
||||
<button className="w-full bg-white border border-[rgba(0,0,0,0.08)] rounded-md px-2 md:px-2.5 lg:px-[13px] py-[9px] flex gap-2 md:gap-2 lg:gap-2.5 items-center hover:bg-gray-50 transition-colors">
|
||||
<HelpCircle className="w-4 h-4 shrink-0 text-[#0f1724]" />
|
||||
<span className="text-xs md:text-xs lg:text-[13px] font-medium text-[#0f1724]">Support Center</span>
|
||||
<span className="text-xs md:text-xs lg:text-[13px] font-medium text-[#0f1724]">
|
||||
Support Center
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -33,6 +33,8 @@ interface MultiselectPaginatedSelectProps {
|
||||
initialOptions?: MultiselectPaginatedSelectOption[]; // Initial options to display before loading
|
||||
className?: string;
|
||||
id?: string;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const MultiselectPaginatedSelect = ({
|
||||
@ -47,6 +49,8 @@ export const MultiselectPaginatedSelect = ({
|
||||
initialOptions = [],
|
||||
className,
|
||||
id,
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
}: MultiselectPaginatedSelectProps): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>(
|
||||
@ -221,11 +225,16 @@ export const MultiselectPaginatedSelect = ({
|
||||
});
|
||||
|
||||
const handleToggle = (optionValue: string) => {
|
||||
if (multiple) {
|
||||
if (value.includes(optionValue)) {
|
||||
onValueChange(value.filter((v) => v !== optionValue));
|
||||
} else {
|
||||
onValueChange([...value, optionValue]);
|
||||
}
|
||||
} else {
|
||||
onValueChange([optionValue]);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (optionValue: string, e: React.MouseEvent) => {
|
||||
@ -247,10 +256,12 @@ export const MultiselectPaginatedSelect = ({
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
id={fieldId}
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"min-h-10 w-full px-3.5 py-2 bg-white border rounded-md text-sm transition-colors",
|
||||
"flex items-center justify-between gap-2",
|
||||
"disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-50",
|
||||
hasError
|
||||
? "border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20"
|
||||
: "border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20",
|
||||
|
||||
1089
src/components/shared/WorkflowDefinitionModal.tsx
Normal file
1089
src/components/shared/WorkflowDefinitionModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
405
src/components/shared/WorkflowDefinitionsTable.tsx
Normal file
405
src/components/shared/WorkflowDefinitionsTable.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
PrimaryButton,
|
||||
StatusBadge,
|
||||
DataTable,
|
||||
Pagination,
|
||||
FilterDropdown,
|
||||
DeleteConfirmationModal,
|
||||
WorkflowDefinitionModal,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import { Plus, GitBranch, Play, Power, Trash2, Copy, Edit } from "lucide-react";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
import type { WorkflowDefinition } from "@/types/workflow";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import type { RootState } from "@/store/store";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
|
||||
interface WorkflowDefinitionsTableProps {
|
||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||
compact?: boolean; // Compact mode for tabs
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
const WorkflowDefinitionsTable = ({
|
||||
tenantId: propsTenantId,
|
||||
compact = false,
|
||||
showHeader = true,
|
||||
}: WorkflowDefinitionsTableProps): ReactElement => {
|
||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||
const effectiveTenantId = propsTenantId || reduxTenantId || undefined;
|
||||
|
||||
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(compact ? 10 : 10);
|
||||
const [totalItems, setTotalItems] = useState<number>(0);
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState<string>("");
|
||||
|
||||
// Modal states
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDefinition, setSelectedDefinition] =
|
||||
useState<WorkflowDefinition | null>(null);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
const fetchDefinitions = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await workflowService.listDefinitions({
|
||||
tenantId: effectiveTenantId,
|
||||
status: statusFilter || undefined,
|
||||
limit,
|
||||
offset: (currentPage - 1) * limit,
|
||||
search: debouncedSearchQuery || undefined,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setDefinitions(response.data);
|
||||
setTotalItems(response.pagination?.total || response.data.length);
|
||||
} else {
|
||||
setError("Failed to load workflow definitions");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error?.message ||
|
||||
"Failed to load workflow definitions",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debouncing search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedSearchQuery, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefinitions();
|
||||
}, [
|
||||
effectiveTenantId,
|
||||
statusFilter,
|
||||
currentPage,
|
||||
limit,
|
||||
debouncedSearchQuery,
|
||||
]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedDefinition) return;
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await workflowService.deleteDefinition(
|
||||
selectedDefinition.id,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Workflow definition deleted successfully");
|
||||
setIsDeleteModalOpen(false);
|
||||
fetchDefinitions();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message ||
|
||||
"Failed to delete workflow definition",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (id: string) => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await workflowService.activateDefinition(
|
||||
id,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Workflow definition activated");
|
||||
fetchDefinitions();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to activate",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeprecate = async (id: string) => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await workflowService.deprecateDefinition(
|
||||
id,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Workflow definition deprecated");
|
||||
fetchDefinitions();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message || "Failed to deprecate",
|
||||
);
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClone = async (id: string, name: string) => {
|
||||
try {
|
||||
setIsActionLoading(true);
|
||||
const response = await workflowService.cloneDefinition(
|
||||
id,
|
||||
`${name} (Clone)`,
|
||||
effectiveTenantId,
|
||||
);
|
||||
if (response.success) {
|
||||
showToast.success("Workflow definition cloned");
|
||||
fetchDefinitions();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(err?.response?.data?.error?.message || "Failed to clone");
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<WorkflowDefinition>[] = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Workflow Component",
|
||||
render: (wf) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-[#0f1724]">{wf.name}</span>
|
||||
<span className="text-xs text-[#6b7280] font-mono">{wf.code}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "entity_type",
|
||||
label: "Entity Type",
|
||||
render: (wf) => (
|
||||
<span className="text-sm text-[#6b7280] capitalize">
|
||||
{wf.entity_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
label: "Version",
|
||||
render: (wf) => (
|
||||
<span className="text-sm text-[#6b7280]">v{wf.version}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (wf) => {
|
||||
let variant: "success" | "failure" | "info" | "process" = "info";
|
||||
if (wf.status === "active") variant = "success";
|
||||
if (wf.status === "deprecated") variant = "failure";
|
||||
if (wf.status === "draft") variant = "process";
|
||||
|
||||
return <StatusBadge variant={variant}>{wf.status}</StatusBadge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "source_module",
|
||||
label: "Module",
|
||||
render: (wf) => (
|
||||
<span className="text-sm text-[#6b7280]">{wf.source_module}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated_at",
|
||||
label: "Last Updated",
|
||||
render: (wf) => (
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{formatDate(wf.updated_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
align: "right",
|
||||
render: (wf) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleClone(wf.id, wf.name)}
|
||||
disabled={isActionLoading}
|
||||
className="p-1 hover:bg-gray-100 rounded-md transition-colors text-[#6b7280]"
|
||||
title="Clone"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDefinition(wf);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
disabled={isActionLoading}
|
||||
className="p-1 hover:bg-blue-50 rounded-md transition-colors text-blue-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{wf.status === "draft" && (
|
||||
<button
|
||||
onClick={() => handleActivate(wf.id)}
|
||||
disabled={isActionLoading}
|
||||
className="p-1 hover:bg-green-50 rounded-md transition-colors text-green-600"
|
||||
title="Activate"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{wf.status === "active" && (
|
||||
<button
|
||||
onClick={() => handleDeprecate(wf.id)}
|
||||
disabled={isActionLoading}
|
||||
className="p-1 hover:bg-orange-50 rounded-md transition-colors text-orange-600"
|
||||
title="Deprecate"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-4 ${!compact ? "bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm" : ""}`}
|
||||
>
|
||||
{showHeader && (
|
||||
<div className="p-4 border-b border-[rgba(0,0,0,0.08)] flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 sm:w-64">
|
||||
<GitBranch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search workflows..."
|
||||
className="w-full pl-9 pr-4 py-2 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-[#0052cc]"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
options={[
|
||||
{ value: "", label: "All Status" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "deprecated", label: "Deprecated" },
|
||||
]}
|
||||
value={statusFilter || ""}
|
||||
onChange={(value) =>
|
||||
setStatusFilter(
|
||||
value ? (Array.isArray(value) ? value[0] : value) : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
size="default"
|
||||
className="flex items-center gap-2 w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
setSelectedDefinition(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Workflow</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={definitions}
|
||||
columns={columns}
|
||||
keyExtractor={(wf) => wf.id}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
emptyMessage="No workflow definitions found"
|
||||
/>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={Math.ceil(totalItems / limit)}
|
||||
totalItems={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={setCurrentPage}
|
||||
onLimitChange={(newLimit) => {
|
||||
setLimit(newLimit);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedDefinition(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Workflow Definition"
|
||||
message="Are you sure you want to delete this workflow definition? This action cannot be undone."
|
||||
itemName={selectedDefinition?.name || ""}
|
||||
isLoading={isActionLoading}
|
||||
/>
|
||||
|
||||
<WorkflowDefinitionModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedDefinition(null);
|
||||
}}
|
||||
definition={selectedDefinition}
|
||||
tenantId={effectiveTenantId}
|
||||
onSuccess={fetchDefinitions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowDefinitionsTable;
|
||||
@ -25,3 +25,5 @@ export type { TabItem } from './PageHeader';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export * from './DepartmentModals';
|
||||
export * from './DesignationModals';
|
||||
export { default as WorkflowDefinitionsTable } from './WorkflowDefinitionsTable';
|
||||
export { WorkflowDefinitionModal } from './WorkflowDefinitionModal';
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo, type ReactElement } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Calendar,
|
||||
Globe,
|
||||
@ -15,52 +15,89 @@ import {
|
||||
Building2,
|
||||
BadgeCheck,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
StatusBadge,
|
||||
DataTable,
|
||||
Pagination,
|
||||
WorkflowDefinitionsTable,
|
||||
type Column,
|
||||
} from '@/components/shared';
|
||||
import { UsersTable, RolesTable } from '@/components/superadmin';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import { auditLogService } from '@/services/audit-log-service';
|
||||
import { moduleService } from '@/services/module-service';
|
||||
import type { Tenant } from '@/types/tenant';
|
||||
import type { AuditLog } from '@/types/audit-log';
|
||||
import type { MyModule } from '@/types/module';
|
||||
import { formatDate } from '@/utils/format-date';
|
||||
import DepartmentsTable from '@/components/superadmin/DepartmentsTable';
|
||||
import DesignationsTable from '@/components/superadmin/DesignationsTable';
|
||||
import UserCategoriesTable from '@/components/superadmin/UserCategoriesTable';
|
||||
} from "@/components/shared";
|
||||
import { UsersTable, RolesTable } from "@/components/superadmin";
|
||||
import { tenantService } from "@/services/tenant-service";
|
||||
import { auditLogService } from "@/services/audit-log-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import type { Tenant } from "@/types/tenant";
|
||||
import type { AuditLog } from "@/types/audit-log";
|
||||
import type { MyModule } from "@/types/module";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
|
||||
import DesignationsTable from "@/components/superadmin/DesignationsTable";
|
||||
import UserCategoriesTable from "@/components/superadmin/UserCategoriesTable";
|
||||
|
||||
type TabType = 'overview' | 'users' | 'roles' | 'departments' | 'designations' | 'user-categories' | 'modules' | 'settings' | 'license' | 'audit-logs' | 'billing';
|
||||
type TabType =
|
||||
| "overview"
|
||||
| "users"
|
||||
| "roles"
|
||||
| "departments"
|
||||
| "designations"
|
||||
| "user-categories"
|
||||
| "workflow-definitions"
|
||||
| "modules"
|
||||
| "settings"
|
||||
| "license"
|
||||
| "audit-logs"
|
||||
| "billing";
|
||||
|
||||
const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
|
||||
{ id: 'overview', label: 'Overview', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'users', label: 'Users', icon: <Users className="w-4 h-4" /> },
|
||||
{ id: 'roles', label: 'Roles', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'departments', label: 'Departments', icon: <Building2 className="w-4 h-4" /> },
|
||||
{ id: 'designations', label: 'Designations', icon: <BadgeCheck className="w-4 h-4" /> },
|
||||
{ id: 'user-categories', label: 'User Categories', icon: <UserCog className="w-4 h-4" /> },
|
||||
{ id: 'modules', label: 'Modules', icon: <Package className="w-4 h-4" /> },
|
||||
{ id: 'settings', label: 'Settings', icon: <Settings className="w-4 h-4" /> },
|
||||
{ id: 'license', label: 'License', icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: 'audit-logs', label: 'Audit Logs', icon: <History className="w-4 h-4" /> },
|
||||
{ id: 'billing', label: 'Billing', icon: <CreditCard className="w-4 h-4" /> },
|
||||
{ id: "overview", label: "Overview", icon: <FileText className="w-4 h-4" /> },
|
||||
{ id: "users", label: "Users", icon: <Users className="w-4 h-4" /> },
|
||||
{ id: "roles", label: "Roles", icon: <FileText className="w-4 h-4" /> },
|
||||
{
|
||||
id: "departments",
|
||||
label: "Departments",
|
||||
icon: <Building2 className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: "designations",
|
||||
label: "Designations",
|
||||
icon: <BadgeCheck className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: "user-categories",
|
||||
label: "User Categories",
|
||||
icon: <UserCog className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: "workflow-definitions",
|
||||
label: "Workflow Definitions",
|
||||
icon: <GitBranch className="w-4 h-4" />,
|
||||
},
|
||||
{ id: "modules", label: "Modules", icon: <Package className="w-4 h-4" /> },
|
||||
{ id: "settings", label: "Settings", icon: <Settings className="w-4 h-4" /> },
|
||||
{ id: "license", label: "License", icon: <FileText className="w-4 h-4" /> },
|
||||
{
|
||||
id: "audit-logs",
|
||||
label: "Audit Logs",
|
||||
icon: <History className="w-4 h-4" />,
|
||||
},
|
||||
{ id: "billing", label: "Billing", icon: <CreditCard className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const getStatusVariant = (status: string): 'success' | 'failure' | 'info' | 'process' => {
|
||||
const getStatusVariant = (
|
||||
status: string,
|
||||
): "success" | "failure" | "info" | "process" => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'suspended':
|
||||
return 'process';
|
||||
case 'deleted':
|
||||
return 'failure';
|
||||
case "active":
|
||||
return "success";
|
||||
case "suspended":
|
||||
return "process";
|
||||
case "deleted":
|
||||
return "failure";
|
||||
default:
|
||||
return 'success';
|
||||
return "success";
|
||||
}
|
||||
};
|
||||
|
||||
@ -75,12 +112,11 @@ const getTenantInitials = (name: string): string => {
|
||||
const TenantDetails = (): ReactElement => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
||||
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Modules tab state - using assignedModules from tenant response
|
||||
|
||||
// Audit logs tab state
|
||||
@ -113,10 +149,13 @@ const TenantDetails = (): ReactElement => {
|
||||
if (response.success) {
|
||||
setTenant(response.data);
|
||||
} else {
|
||||
setError('Failed to load tenant details');
|
||||
setError("Failed to load tenant details");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load tenant details');
|
||||
setError(
|
||||
err?.response?.data?.error?.message ||
|
||||
"Failed to load tenant details",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -130,13 +169,19 @@ const TenantDetails = (): ReactElement => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setAuditLogsLoading(true);
|
||||
const response = await auditLogService.getAll(auditLogsPage, auditLogsLimit, null, null, id);
|
||||
const response = await auditLogService.getAll(
|
||||
auditLogsPage,
|
||||
auditLogsLimit,
|
||||
null,
|
||||
null,
|
||||
id,
|
||||
);
|
||||
if (response.success) {
|
||||
setAuditLogs(response.data);
|
||||
setAuditLogsPagination(response.pagination);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load audit logs:', err);
|
||||
console.error("Failed to load audit logs:", err);
|
||||
} finally {
|
||||
setAuditLogsLoading(false);
|
||||
}
|
||||
@ -144,7 +189,7 @@ const TenantDetails = (): ReactElement => {
|
||||
|
||||
// Fetch data when tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === 'audit-logs' && id) {
|
||||
if (activeTab === "audit-logs" && id) {
|
||||
fetchAuditLogs();
|
||||
}
|
||||
}, [activeTab, id, auditLogsPage]);
|
||||
@ -155,8 +200,10 @@ const TenantDetails = (): ReactElement => {
|
||||
return {
|
||||
totalUsers: tenant.users?.length || 0,
|
||||
totalModules: tenant.assignedModules?.length || 0,
|
||||
activeModules: tenant.assignedModules?.filter((m) => m.status === 'running')?.length || 0,
|
||||
subscriptionTier: tenant.subscription_tier || 'N/A',
|
||||
activeModules:
|
||||
tenant.assignedModules?.filter((m) => m.status === "running")?.length ||
|
||||
0,
|
||||
subscriptionTier: tenant.subscription_tier || "N/A",
|
||||
};
|
||||
}, [tenant]);
|
||||
|
||||
@ -165,13 +212,15 @@ const TenantDetails = (): ReactElement => {
|
||||
<Layout
|
||||
currentPage="Tenant Details"
|
||||
breadcrumbs={[
|
||||
{ label: 'QAssure', path: '/dashboard' },
|
||||
{ label: 'Tenant Management', path: '/tenants' },
|
||||
{ label: 'Tenant Details' },
|
||||
{ label: "QAssure", path: "/dashboard" },
|
||||
{ label: "Tenant Management", path: "/tenants" },
|
||||
{ label: "Tenant Details" },
|
||||
]}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-sm text-[#6b7280]">Loading tenant details...</div>
|
||||
<div className="text-sm text-[#6b7280]">
|
||||
Loading tenant details...
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
@ -182,13 +231,15 @@ const TenantDetails = (): ReactElement => {
|
||||
<Layout
|
||||
currentPage="Tenant Details"
|
||||
breadcrumbs={[
|
||||
{ label: 'QAssure', path: '/dashboard' },
|
||||
{ label: 'Tenant Management', path: '/tenants' },
|
||||
{ label: 'Tenant Details' },
|
||||
{ label: "QAssure", path: "/dashboard" },
|
||||
{ label: "Tenant Management", path: "/tenants" },
|
||||
{ label: "Tenant Details" },
|
||||
]}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-sm text-red-600">{error || 'Tenant not found'}</div>
|
||||
<div className="text-sm text-red-600">
|
||||
{error || "Tenant not found"}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
@ -198,9 +249,9 @@ const TenantDetails = (): ReactElement => {
|
||||
<Layout
|
||||
currentPage="Tenant Details"
|
||||
breadcrumbs={[
|
||||
{ label: 'QAssure', path: '/dashboard' },
|
||||
{ label: 'Tenant Management', path: '/tenants' },
|
||||
{ label: 'Tenant Details' },
|
||||
{ label: "QAssure", path: "/dashboard" },
|
||||
{ label: "Tenant Management", path: "/tenants" },
|
||||
{ label: "Tenant Details" },
|
||||
]}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
@ -229,7 +280,11 @@ const TenantDetails = (): ReactElement => {
|
||||
</div>
|
||||
{tenant.domain && (
|
||||
<a
|
||||
href={tenant.domain.startsWith('http') ? tenant.domain : `https://${tenant.domain}`}
|
||||
href={
|
||||
tenant.domain.startsWith("http")
|
||||
? tenant.domain
|
||||
: `https://${tenant.domain}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-[#6b7280] hover:text-[#112868] transition-colors"
|
||||
@ -246,7 +301,7 @@ const TenantDetails = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/tenants')}
|
||||
onClick={() => navigate("/tenants")}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[#112868] bg-white border border-[rgba(0,0,0,0.08)] rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
@ -265,8 +320,8 @@ const TenantDetails = (): ReactElement => {
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-[#112868] text-[#112868]'
|
||||
: 'border-transparent text-[#6b7280] hover:text-[#0f1724] hover:border-gray-300'
|
||||
? "border-[#112868] text-[#112868]"
|
||||
: "border-transparent text-[#6b7280] hover:text-[#0f1724] hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
@ -278,32 +333,33 @@ const TenantDetails = (): ReactElement => {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-4 md:p-6">
|
||||
{activeTab === 'overview' && (
|
||||
{activeTab === "overview" && (
|
||||
<OverviewTab tenant={tenant} stats={stats} />
|
||||
)}
|
||||
{activeTab === 'users' && id && (
|
||||
{activeTab === "users" && id && (
|
||||
<UsersTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'roles' && id && (
|
||||
{activeTab === "roles" && id && (
|
||||
<RolesTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'departments' && id && (
|
||||
{activeTab === "departments" && id && (
|
||||
<DepartmentsTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'designations' && id && (
|
||||
{activeTab === "designations" && id && (
|
||||
<DesignationsTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'user-categories' && id && (
|
||||
{activeTab === "user-categories" && id && (
|
||||
<UserCategoriesTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'modules' && id && (
|
||||
<ModulesTab tenantId={id} />
|
||||
{activeTab === "workflow-definitions" && id && (
|
||||
<WorkflowDefinitionsTable tenantId={id} compact={true} />
|
||||
)}
|
||||
{activeTab === 'settings' && tenant && (
|
||||
{activeTab === "modules" && id && <ModulesTab tenantId={id} />}
|
||||
{activeTab === "settings" && tenant && (
|
||||
<SettingsTab tenant={tenant} />
|
||||
)}
|
||||
{activeTab === 'license' && <LicenseTab tenant={tenant} />}
|
||||
{activeTab === 'audit-logs' && (
|
||||
{activeTab === "license" && <LicenseTab tenant={tenant} />}
|
||||
{activeTab === "audit-logs" && (
|
||||
<AuditLogsTab
|
||||
auditLogs={auditLogs}
|
||||
isLoading={auditLogsLoading}
|
||||
@ -313,7 +369,7 @@ const TenantDetails = (): ReactElement => {
|
||||
onPageChange={setAuditLogsPage}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'billing' && <BillingTab tenant={tenant} />}
|
||||
{activeTab === "billing" && <BillingTab tenant={tenant} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -338,60 +394,106 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Total Users</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">{stats?.totalUsers || 0}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Total Users
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">
|
||||
{stats?.totalUsers || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Total Modules</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">{stats?.totalModules || 0}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Total Modules
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">
|
||||
{stats?.totalModules || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Active Modules</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">{stats?.activeModules || 0}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Active Modules
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724]">
|
||||
{stats?.activeModules || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Subscription Tier</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724] capitalize">{stats?.subscriptionTier || 'N/A'}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Subscription Tier
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#0f1724] capitalize">
|
||||
{stats?.subscriptionTier || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Information */}
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724] mb-4">General Information</h3>
|
||||
<h3 className="text-lg font-semibold text-[#0f1724] mb-4">
|
||||
General Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Tenant Name</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.name}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Tenant Name
|
||||
</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">
|
||||
{tenant.name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Slug</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.slug}</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">
|
||||
{tenant.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Status</div>
|
||||
<StatusBadge variant={getStatusVariant(tenant.status)}>{tenant.status}</StatusBadge>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Status
|
||||
</div>
|
||||
<StatusBadge variant={getStatusVariant(tenant.status)}>
|
||||
{tenant.status}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Subscription Tier</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Subscription Tier
|
||||
</div>
|
||||
<div className="text-sm font-normal text-[#0f1724] capitalize">
|
||||
{tenant.subscription_tier || 'N/A'}
|
||||
{tenant.subscription_tier || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Max Users</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.max_users || 'Unlimited'}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Max Users
|
||||
</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">
|
||||
{tenant.max_users || "Unlimited"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Max Modules</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{tenant.max_modules || 'Unlimited'}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Max Modules
|
||||
</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">
|
||||
{tenant.max_modules || "Unlimited"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Created At</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{formatDate(tenant.created_at)}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Created At
|
||||
</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">
|
||||
{formatDate(tenant.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">Updated At</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">{formatDate(tenant.updated_at)}</div>
|
||||
<div className="text-sm font-medium text-[#6b7280] mb-1">
|
||||
Updated At
|
||||
</div>
|
||||
<div className="text-sm font-normal text-[#0f1724]">
|
||||
{formatDate(tenant.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -399,7 +501,6 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Modules Tab Component
|
||||
interface ModulesTabProps {
|
||||
tenantId: string;
|
||||
@ -419,10 +520,10 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
|
||||
if (response.success && response.data) {
|
||||
setModules(response.data);
|
||||
} else {
|
||||
setError('Failed to load modules');
|
||||
setError("Failed to load modules");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to load modules');
|
||||
setError(err?.response?.data?.error?.message || "Failed to load modules");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -439,51 +540,59 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
|
||||
try {
|
||||
const response = await moduleService.launch(moduleId, tenantId);
|
||||
if (response.success && response.data.launch_url) {
|
||||
window.open(response.data.launch_url, '_blank', 'noopener,noreferrer');
|
||||
window.open(response.data.launch_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error?.message || 'Failed to launch module');
|
||||
setError(
|
||||
err?.response?.data?.error?.message || "Failed to launch module",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<MyModule>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Module Name',
|
||||
key: "name",
|
||||
label: "Module Name",
|
||||
render: (module) => (
|
||||
<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">
|
||||
<Package className="w-4 h-4 text-[#9aa6b2]" />
|
||||
</div>
|
||||
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span>
|
||||
<span className="text-sm font-normal text-[#0f1724]">
|
||||
{module.name}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'module_id',
|
||||
label: 'Module ID',
|
||||
key: "module_id",
|
||||
label: "Module ID",
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#6b7280] font-mono">{module.module_id}</span>
|
||||
<span className="text-sm font-normal text-[#6b7280] font-mono">
|
||||
{module.module_id}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'version',
|
||||
label: 'Version',
|
||||
key: "version",
|
||||
label: "Version",
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{module.version}</span>
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{module.version}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
key: "status",
|
||||
label: "Status",
|
||||
render: (module) => (
|
||||
<StatusBadge
|
||||
variant={
|
||||
module.status === 'running'
|
||||
? 'success'
|
||||
: module.status === 'degraded'
|
||||
? 'process'
|
||||
: 'info'
|
||||
module.status === "running"
|
||||
? "success"
|
||||
: module.status === "degraded"
|
||||
? "process"
|
||||
: "info"
|
||||
}
|
||||
>
|
||||
{module.status}
|
||||
@ -491,25 +600,27 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'base_url',
|
||||
label: 'Base URL',
|
||||
key: "base_url",
|
||||
label: "Base URL",
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]">
|
||||
{module.base_url || 'N/A'}
|
||||
{module.base_url || "N/A"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'assigned_at',
|
||||
label: 'Assigned At',
|
||||
key: "assigned_at",
|
||||
label: "Assigned At",
|
||||
render: (module) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(module.assigned_at)}</span>
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{formatDate(module.assigned_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
align: "right",
|
||||
render: (module) => (
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
@ -547,9 +658,13 @@ const LicenseTab = ({ tenant: _tenant }: LicenseTabProps): ReactElement => {
|
||||
// Placeholder for license data
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">License Information</h3>
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">
|
||||
License Information
|
||||
</h3>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6">
|
||||
<div className="text-sm text-[#6b7280]">License information will be displayed here.</div>
|
||||
<div className="text-sm text-[#6b7280]">
|
||||
License information will be displayed here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -581,57 +696,65 @@ const AuditLogsTab = ({
|
||||
}: AuditLogsTabProps): ReactElement => {
|
||||
const columns: Column<AuditLog>[] = [
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
key: "action",
|
||||
label: "Action",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-medium text-[#0f1724]">{log.action}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resource_type',
|
||||
label: 'Resource',
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{log.resource_type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'User',
|
||||
key: "resource_type",
|
||||
label: "Resource",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{log.user ? `${log.user.first_name} ${log.user.last_name}` : 'System'}
|
||||
{log.resource_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'request_method',
|
||||
label: 'Method',
|
||||
key: "user",
|
||||
label: "User",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{log.request_method || 'N/A'}</span>
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{log.user ? `${log.user.first_name} ${log.user.last_name}` : "System"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'response_status',
|
||||
label: 'Status',
|
||||
key: "request_method",
|
||||
label: "Method",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{log.request_method || "N/A"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "response_status",
|
||||
label: "Status",
|
||||
render: (log) => (
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
log.response_status && log.response_status >= 200 && log.response_status < 300
|
||||
? 'text-green-600'
|
||||
log.response_status &&
|
||||
log.response_status >= 200 &&
|
||||
log.response_status < 300
|
||||
? "text-green-600"
|
||||
: log.response_status && log.response_status >= 400
|
||||
? 'text-red-600'
|
||||
: 'text-gray-600'
|
||||
? "text-red-600"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{log.response_status || 'N/A'}
|
||||
{log.response_status || "N/A"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Date',
|
||||
key: "created_at",
|
||||
label: "Date",
|
||||
render: (log) => (
|
||||
<span className="text-sm font-normal text-[#6b7280]">{formatDate(log.created_at)}</span>
|
||||
<span className="text-sm font-normal text-[#6b7280]">
|
||||
{formatDate(log.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
@ -669,22 +792,27 @@ interface SettingsTabProps {
|
||||
const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||
const [primaryColor, setPrimaryColor] = useState<string>('#112868');
|
||||
const [secondaryColor, setSecondaryColor] = useState<string>('#23DCE1');
|
||||
const [accentColor, setAccentColor] = useState<string>('#084CC8');
|
||||
const [primaryColor, setPrimaryColor] = useState<string>("#112868");
|
||||
const [secondaryColor, setSecondaryColor] = useState<string>("#23DCE1");
|
||||
const [accentColor, setAccentColor] = useState<string>("#084CC8");
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (2MB max)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
alert('Logo file size must be less than 2MB');
|
||||
alert("Logo file size must be less than 2MB");
|
||||
return;
|
||||
}
|
||||
// Validate file type
|
||||
const validTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/jpg'];
|
||||
const validTypes = [
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Logo must be PNG, SVG, or JPG format');
|
||||
alert("Logo must be PNG, SVG, or JPG format");
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
@ -696,13 +824,17 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
if (file) {
|
||||
// Validate file size (500KB max)
|
||||
if (file.size > 500 * 1024) {
|
||||
alert('Favicon file size must be less than 500KB');
|
||||
alert("Favicon file size must be less than 500KB");
|
||||
return;
|
||||
}
|
||||
// Validate file type
|
||||
const validTypes = ['image/x-icon', 'image/png', 'image/vnd.microsoft.icon'];
|
||||
const validTypes = [
|
||||
"image/x-icon",
|
||||
"image/png",
|
||||
"image/vnd.microsoft.icon",
|
||||
];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
alert('Favicon must be ICO or PNG format');
|
||||
alert("Favicon must be ICO or PNG format");
|
||||
return;
|
||||
}
|
||||
setFaviconFile(file);
|
||||
@ -725,7 +857,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Company Logo */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Company Logo
|
||||
</label>
|
||||
<label
|
||||
htmlFor="logo-upload"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -734,8 +868,12 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-[#0f1724]">Upload Logo</span>
|
||||
<span className="text-xs font-normal text-[#9ca3af]">PNG, SVG, JPG up to 2MB.</span>
|
||||
<span className="text-sm font-medium text-[#0f1724]">
|
||||
Upload Logo
|
||||
</span>
|
||||
<span className="text-xs font-normal text-[#9ca3af]">
|
||||
PNG, SVG, JPG up to 2MB.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="logo-upload"
|
||||
@ -754,7 +892,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
|
||||
{/* Favicon */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Favicon
|
||||
</label>
|
||||
<label
|
||||
htmlFor="favicon-upload"
|
||||
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
|
||||
@ -763,8 +903,12 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-[#0f1724]">Upload Favicon</span>
|
||||
<span className="text-xs font-normal text-[#9ca3af]">ICO or PNG up to 500KB.</span>
|
||||
<span className="text-sm font-medium text-[#0f1724]">
|
||||
Upload Favicon
|
||||
</span>
|
||||
<span className="text-xs font-normal text-[#9ca3af]">
|
||||
ICO or PNG up to 500KB.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="favicon-upload"
|
||||
@ -784,7 +928,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
|
||||
{/* Primary Color */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Primary Color</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Primary Color
|
||||
</label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div
|
||||
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
|
||||
@ -815,7 +961,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Secondary Color */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Secondary Color</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Secondary Color
|
||||
</label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div
|
||||
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
|
||||
@ -844,7 +992,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
|
||||
|
||||
{/* Accent Color */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-[#0f1724]">Accent Color</label>
|
||||
<label className="text-sm font-medium text-[#0f1724]">
|
||||
Accent Color
|
||||
</label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div
|
||||
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
|
||||
@ -885,9 +1035,13 @@ const BillingTab = ({ tenant: _tenant }: BillingTabProps): ReactElement => {
|
||||
// Placeholder for billing data
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">Billing Information</h3>
|
||||
<h3 className="text-lg font-semibold text-[#0f1724]">
|
||||
Billing Information
|
||||
</h3>
|
||||
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6">
|
||||
<div className="text-sm text-[#6b7280]">Billing information will be displayed here.</div>
|
||||
<div className="text-sm text-[#6b7280]">
|
||||
Billing information will be displayed here.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
27
src/pages/tenant/WorkflowDefination.tsx
Normal file
27
src/pages/tenant/WorkflowDefination.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { type ReactElement } from "react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import { WorkflowDefinitionsTable } from "@/components/shared";
|
||||
|
||||
const WorkflowDefinationPage = (): ReactElement => {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Workflow Definitions"
|
||||
breadcrumbs={[
|
||||
{ label: "Platform", path: "/tenant" },
|
||||
{ label: "Workflow Definitions" },
|
||||
]}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-[#0f1724] tracking-[-0.48px]">
|
||||
Workflow Definitions
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<WorkflowDefinitionsTable compact={false} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowDefinationPage;
|
||||
@ -1,15 +1,18 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
// Lazy load route components for code splitting
|
||||
const Dashboard = lazy(() => import('@/pages/tenant/Dashboard'));
|
||||
const Roles = lazy(() => import('@/pages/tenant/Roles'));
|
||||
const Settings = lazy(() => import('@/pages/tenant/Settings'));
|
||||
const Users = lazy(() => import('@/pages/tenant/Users'));
|
||||
const AuditLogs = lazy(() => import('@/pages/tenant/AuditLogs'));
|
||||
const Modules = lazy(() => import('@/pages/tenant/Modules'));
|
||||
const Departments = lazy(() => import('@/pages/tenant/Departments'));
|
||||
const Designations = lazy(() => import('@/pages/tenant/Designations'));
|
||||
const Dashboard = lazy(() => import("@/pages/tenant/Dashboard"));
|
||||
const Roles = lazy(() => import("@/pages/tenant/Roles"));
|
||||
const Settings = lazy(() => import("@/pages/tenant/Settings"));
|
||||
const Users = lazy(() => import("@/pages/tenant/Users"));
|
||||
const AuditLogs = lazy(() => import("@/pages/tenant/AuditLogs"));
|
||||
const Modules = lazy(() => import("@/pages/tenant/Modules"));
|
||||
const Departments = lazy(() => import("@/pages/tenant/Departments"));
|
||||
const Designations = lazy(() => import("@/pages/tenant/Designations"));
|
||||
const WorkflowDefination = lazy(
|
||||
() => import("@/pages/tenant/WorkflowDefination"),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -19,7 +22,11 @@ const RouteLoader = (): ReactElement => (
|
||||
);
|
||||
|
||||
// Wrapper component with Suspense
|
||||
const LazyRoute = ({ component: Component }: { component: React.ComponentType }): ReactElement => (
|
||||
const LazyRoute = ({
|
||||
component: Component,
|
||||
}: {
|
||||
component: React.ComponentType;
|
||||
}): ReactElement => (
|
||||
<Suspense fallback={<RouteLoader />}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
@ -33,35 +40,39 @@ export interface RouteConfig {
|
||||
// Tenant Admin routes (requires authentication but NOT super_admin role)
|
||||
export const tenantAdminRoutes: RouteConfig[] = [
|
||||
{
|
||||
path: '/tenant',
|
||||
path: "/tenant",
|
||||
element: <LazyRoute component={Dashboard} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/roles',
|
||||
path: "/tenant/roles",
|
||||
element: <LazyRoute component={Roles} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/users',
|
||||
path: "/tenant/users",
|
||||
element: <LazyRoute component={Users} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/modules',
|
||||
path: "/tenant/modules",
|
||||
element: <LazyRoute component={Modules} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/audit-logs',
|
||||
path: "/tenant/audit-logs",
|
||||
element: <LazyRoute component={AuditLogs} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/settings',
|
||||
path: "/tenant/settings",
|
||||
element: <LazyRoute component={Settings} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/departments',
|
||||
path: "/tenant/departments",
|
||||
element: <LazyRoute component={Departments} />,
|
||||
},
|
||||
{
|
||||
path: '/tenant/designations',
|
||||
path: "/tenant/designations",
|
||||
element: <LazyRoute component={Designations} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/workflow-definitions",
|
||||
element: <LazyRoute component={WorkflowDefination} />,
|
||||
},
|
||||
];
|
||||
|
||||
75
src/services/workflow-service.ts
Normal file
75
src/services/workflow-service.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import apiClient from './api-client';
|
||||
import type {
|
||||
CreateWorkflowDefinitionData,
|
||||
UpdateWorkflowDefinitionData,
|
||||
WorkflowDefinitionsResponse,
|
||||
WorkflowDefinitionResponse,
|
||||
WorkflowDeleteResponse
|
||||
} from '@/types/workflow';
|
||||
|
||||
|
||||
class WorkflowService {
|
||||
private readonly baseUrl = '/workflows';
|
||||
|
||||
async listDefinitions(params?: {
|
||||
tenantId?: string;
|
||||
entity_type?: string;
|
||||
status?: string;
|
||||
source_module?: string;
|
||||
source_module_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
search?: string;
|
||||
}): Promise<WorkflowDefinitionsResponse> {
|
||||
const queryParams: any = { ...params };
|
||||
if (params?.tenantId) {
|
||||
queryParams.tenant_id = params.tenantId;
|
||||
}
|
||||
const response = await apiClient.get<WorkflowDefinitionsResponse>(`${this.baseUrl}/definitions`, { params: queryParams });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getDefinition(id: string, tenantId?: string): Promise<WorkflowDefinitionResponse> {
|
||||
const params = tenantId ? { tenant_id: tenantId } : {};
|
||||
const response = await apiClient.get<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createDefinition(data: CreateWorkflowDefinitionData, tenantId?: string): Promise<WorkflowDefinitionResponse> {
|
||||
const params = tenantId ? { tenant_id: tenantId } : {};
|
||||
const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions`, data, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateDefinition(id: string, data: UpdateWorkflowDefinitionData, tenantId?: string): Promise<WorkflowDefinitionResponse> {
|
||||
const params = tenantId ? { tenant_id: tenantId } : {};
|
||||
const response = await apiClient.put<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}`, data, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteDefinition(id: string, tenantId?: string): Promise<WorkflowDeleteResponse> {
|
||||
const params = tenantId ? { tenant_id: tenantId } : {};
|
||||
const response = await apiClient.delete<WorkflowDeleteResponse>(`${this.baseUrl}/definitions/${id}`, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async cloneDefinition(id: string, name: string, tenantId?: string): Promise<WorkflowDefinitionResponse> {
|
||||
const params = tenantId ? { tenant_id: tenantId } : {};
|
||||
const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}/clone`, { name }, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async activateDefinition(id: string, tenantId?: string): Promise<WorkflowDefinitionResponse> {
|
||||
const params = tenantId ? { tenant_id: tenantId } : {};
|
||||
const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}/activate`, {}, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deprecateDefinition(id: string, tenantId?: string): Promise<WorkflowDefinitionResponse> {
|
||||
const params = tenantId ? { tenant_id: tenantId } : {};
|
||||
const response = await apiClient.post<WorkflowDefinitionResponse>(`${this.baseUrl}/definitions/${id}/deprecate`, {}, { params });
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const workflowService = new WorkflowService();
|
||||
104
src/types/workflow.ts
Normal file
104
src/types/workflow.ts
Normal file
@ -0,0 +1,104 @@
|
||||
export interface WorkflowStep {
|
||||
id?: string;
|
||||
step_code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
sequence: number;
|
||||
step_type: 'initial' | 'task' | 'approval' | 'terminal';
|
||||
assignee: {
|
||||
type: 'role' | 'user' | 'originator';
|
||||
id?: string | null;
|
||||
role?: string[] | string | null;
|
||||
};
|
||||
available_actions: string[];
|
||||
requires_signature?: boolean;
|
||||
requires_comment?: boolean;
|
||||
requires_attachment?: boolean;
|
||||
sla?: {
|
||||
hours?: number;
|
||||
warning_hours?: number;
|
||||
escalation_hours?: number;
|
||||
};
|
||||
form_config?: any;
|
||||
}
|
||||
|
||||
export interface WorkflowTransition {
|
||||
id?: string;
|
||||
from_step_id?: string;
|
||||
from_step_code: string;
|
||||
from_step_name?: string;
|
||||
to_step_id?: string;
|
||||
to_step_code: string;
|
||||
to_step_name?: string;
|
||||
action: string;
|
||||
name: string;
|
||||
requires_signature?: boolean;
|
||||
requires_comment?: boolean;
|
||||
signature_meaning?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowDefinition {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
entity_type: string;
|
||||
status: 'active' | 'draft' | 'deprecated' | 'archived';
|
||||
version: number;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
source_module: string[];
|
||||
source_module_id: string[];
|
||||
steps: WorkflowStep[];
|
||||
transitions: WorkflowTransition[];
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkflowDefinitionData {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
entity_type: string;
|
||||
status?: 'active' | 'draft' | 'deprecated' | 'archived';
|
||||
source_module?: string[];
|
||||
source_module_id?: string[];
|
||||
steps: Partial<WorkflowStep>[];
|
||||
transitions: Partial<WorkflowTransition>[];
|
||||
tenant_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkflowDefinitionData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
entity_type?: string;
|
||||
status?: 'active' | 'draft' | 'deprecated' | 'archived';
|
||||
source_module?: string[];
|
||||
source_module_id?: string[];
|
||||
steps?: Partial<WorkflowStep>[];
|
||||
transitions?: Partial<WorkflowTransition>[];
|
||||
tenant_id?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowDefinitionsResponse {
|
||||
success: boolean;
|
||||
data: WorkflowDefinition[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowDefinitionResponse {
|
||||
success: boolean;
|
||||
data: WorkflowDefinition;
|
||||
}
|
||||
|
||||
export interface WorkflowDeleteResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user