feat: implement comprehensive workflow definition management, including UI components, services, and routing for creating, editing, and listing workflow definitions.

This commit is contained in:
Yashwin 2026-03-13 18:09:23 +05:30
parent 939bd4ddc9
commit f460a89201
10 changed files with 2217 additions and 264 deletions

View File

@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
Building2, Building2,
@ -9,12 +9,14 @@ import {
HelpCircle, HelpCircle,
X, X,
Shield, Shield,
BadgeCheck BadgeCheck,
} from 'lucide-react'; GitBranch,
import { cn } from '@/lib/utils'; } from "lucide-react";
import { useAppSelector } from '@/hooks/redux-hooks';
import { useTenantTheme } from '@/hooks/useTenantTheme'; import { cn } from "@/lib/utils";
import { AuthenticatedImage } from '@/components/shared'; import { useAppSelector } from "@/hooks/redux-hooks";
import { useTenantTheme } from "@/hooks/useTenantTheme";
import { AuthenticatedImage } from "@/components/shared";
interface MenuItem { interface MenuItem {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
@ -33,31 +35,67 @@ interface SidebarProps {
// Super Admin menu items // Super Admin menu items
const superAdminPlatformMenu: MenuItem[] = [ const superAdminPlatformMenu: MenuItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/dashboard' }, { icon: LayoutDashboard, label: "Dashboard", path: "/dashboard" },
{ icon: Building2, label: 'Tenants', path: '/tenants' }, { icon: Building2, label: "Tenants", path: "/tenants" },
// { icon: Users, label: 'User Management', path: '/users' }, // { icon: Users, label: 'User Management', path: '/users' },
// { icon: Shield, label: 'Roles', path: '/roles' }, // { icon: Shield, label: 'Roles', path: '/roles' },
{ icon: Package, label: 'Modules', path: '/modules' }, { icon: Package, label: "Modules", path: "/modules" },
]; ];
const superAdminSystemMenu: MenuItem[] = [ 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' }, // { icon: Settings, label: 'Settings', path: '/settings' },
]; ];
// Tenant Admin menu items // Tenant Admin menu items
const tenantAdminPlatformMenu: MenuItem[] = [ const tenantAdminPlatformMenu: MenuItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/tenant' }, { 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: Shield,
{ icon: Building2, label: 'Departments', path: '/tenant/departments', requiredPermission: { resource: 'departments' } }, label: "Roles",
{ icon: BadgeCheck, label: 'Designations', path: '/tenant/designations', requiredPermission: { resource: 'designations' } }, path: "/tenant/roles",
{ icon: Package, label: 'Modules', path: '/tenant/modules' }, 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[] = [ 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) => { export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
@ -70,14 +108,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
let rolesArray: string[] = []; let rolesArray: string[] = [];
if (Array.isArray(roles)) { if (Array.isArray(roles)) {
rolesArray = roles; rolesArray = roles;
} else if (typeof roles === 'string') { } else if (typeof roles === "string") {
try { try {
rolesArray = JSON.parse(roles); rolesArray = JSON.parse(roles);
} catch { } catch {
rolesArray = []; rolesArray = [];
} }
} }
return rolesArray.includes('super_admin'); return rolesArray.includes("super_admin");
}; };
const isSuperAdmin = isSuperAdminCheck(); const isSuperAdmin = isSuperAdminCheck();
@ -85,12 +123,12 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
// Get role name for display // Get role name for display
const getRoleName = (): string => { const getRoleName = (): string => {
if (isSuperAdmin) { if (isSuperAdmin) {
return 'Super Admin'; return "Super Admin";
} }
let rolesArray: string[] = []; let rolesArray: string[] = [];
if (Array.isArray(roles)) { if (Array.isArray(roles)) {
rolesArray = roles; rolesArray = roles;
} else if (typeof roles === 'string') { } else if (typeof roles === "string") {
try { try {
rolesArray = JSON.parse(roles); rolesArray = JSON.parse(roles);
} catch { } catch {
@ -102,11 +140,13 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const role = rolesArray[0]; const role = rolesArray[0];
// Convert snake_case to Title Case // Convert snake_case to Title Case
return role return role
.split('_') .split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .map(
.join(' '); (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join(" ");
} }
return 'User'; return "User";
}; };
const roleName = getRoleName(); const roleName = getRoleName();
@ -117,20 +157,24 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
} }
// Helper function to check if user has permission for a resource // 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) { if (isSuperAdmin) {
return true; // Super admin has all permissions return true; // Super admin has all permissions
} }
const allowedActions = requiredAction ? [requiredAction] : ['*', 'read']; const allowedActions = requiredAction ? [requiredAction] : ["*", "read"];
return permissions.some((perm) => { return permissions.some((perm) => {
// Check if resource matches (exact match or wildcard) // 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) // Check if action matches (exact match or wildcard)
const actionMatches = allowedActions.some( const actionMatches = allowedActions.some(
(allowedAction) => perm.action === allowedAction || perm.action === '*' (allowedAction) => perm.action === allowedAction || perm.action === "*",
); );
return resourceMatches && actionMatches; return resourceMatches && actionMatches;
@ -151,20 +195,26 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
return hasPermission( return hasPermission(
item.requiredPermission.resource, item.requiredPermission.resource,
item.requiredPermission.action item.requiredPermission.action,
); );
}); });
}; };
// Select and filter menu items based on role and permissions // Select and filter menu items based on role and permissions
const platformMenu = filterMenuItems( const platformMenu = filterMenuItems(
isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu isSuperAdmin ? superAdminPlatformMenu : tenantAdminPlatformMenu,
); );
const systemMenu = filterMenuItems( 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="w-full">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="pb-1 px-2 md:px-2 lg:px-3"> <div className="pb-1 px-2 md:px-2 lg:px-3">
@ -187,22 +237,30 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
} }
}} }}
className={cn( 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 isActive
? 'shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]' ? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]"
: 'text-[#0f1724] hover:bg-gray-50' : "text-[#0f1724] hover:bg-gray-50",
)} )}
style={ style={
isActive isActive
? { ? {
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868', backgroundColor:
color: !isSuperAdmin && theme?.secondary_color ? theme.secondary_color : '#23dce1', !isSuperAdmin && theme?.primary_color
} ? theme.primary_color
: "#112868",
color:
!isSuperAdmin && theme?.secondary_color
? theme.secondary_color
: "#23dce1",
}
: undefined : undefined
} }
> >
<Icon className="w-4 h-4 shrink-0" /> <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> </Link>
); );
})} })}
@ -216,10 +274,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
{/* Mobile Sidebar */} {/* Mobile Sidebar */}
<aside <aside
className={cn( 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', "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' isOpen ? "translate-x-0" : "-translate-x-full",
)} )}
style={{ width: '280px' }} style={{ width: "280px" }}
> >
{/* Mobile Header with Close Button */} {/* Mobile Header with Close Button */}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@ -234,21 +292,27 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div <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" 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={{ style={{
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex', display: !isSuperAdmin && logoUrl ? "none" : "flex",
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868', backgroundColor:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
}} }}
> >
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} /> <Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]"> <div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'} {!isSuperAdmin && logoUrl ? "" : "QAssure"}
</div> </div>
{(!isSuperAdmin && logoUrl) ? null : ( {!isSuperAdmin && logoUrl ? null : (
<div <div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2" className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{ style={{
color: !isSuperAdmin && theme?.accent_color ? theme.accent_color : '#084cc8', color:
!isSuperAdmin && theme?.accent_color
? theme.accent_color
: "#084cc8",
}} }}
> >
{roleName} {roleName}
@ -275,7 +339,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div className="mt-auto"> <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]"> <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]" /> <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> </button>
</div> </div>
@ -303,30 +369,37 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
alt="Logo" alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain" className="h-9 w-auto max-w-[180px] object-contain"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = "none";
const fallback = e.currentTarget.nextElementSibling as HTMLElement; const fallback = e.currentTarget
if (fallback) fallback.style.display = 'flex'; .nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = "flex";
}} }}
/> />
) : null} ) : null}
<div <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" 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={{ style={{
display: (!isSuperAdmin && logoUrl) ? 'none' : 'flex', display: !isSuperAdmin && logoUrl ? "none" : "flex",
backgroundColor: !isSuperAdmin && theme?.primary_color ? theme.primary_color : '#112868', backgroundColor:
!isSuperAdmin && theme?.primary_color
? theme.primary_color
: "#112868",
}} }}
> >
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} /> <Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div> </div>
<div className="flex flex-col"> <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]"> <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> </div>
{(!isSuperAdmin && logoUrl) ? null : ( {!isSuperAdmin && logoUrl ? null : (
<div <div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2" className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{ style={{
color: !isSuperAdmin && theme?.accent_color ? theme.accent_color : '#084cc8', color:
!isSuperAdmin && theme?.accent_color
? theme.accent_color
: "#084cc8",
}} }}
> >
{roleName} {roleName}
@ -350,7 +423,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div className="mt-auto w-full"> <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"> <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]" /> <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> </button>
</div> </div>

View File

@ -33,6 +33,8 @@ interface MultiselectPaginatedSelectProps {
initialOptions?: MultiselectPaginatedSelectOption[]; // Initial options to display before loading initialOptions?: MultiselectPaginatedSelectOption[]; // Initial options to display before loading
className?: string; className?: string;
id?: string; id?: string;
multiple?: boolean;
disabled?: boolean;
} }
export const MultiselectPaginatedSelect = ({ export const MultiselectPaginatedSelect = ({
@ -47,6 +49,8 @@ export const MultiselectPaginatedSelect = ({
initialOptions = [], initialOptions = [],
className, className,
id, id,
multiple = true,
disabled = false,
}: MultiselectPaginatedSelectProps): ReactElement => { }: MultiselectPaginatedSelectProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>( const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>(
@ -221,10 +225,15 @@ export const MultiselectPaginatedSelect = ({
}); });
const handleToggle = (optionValue: string) => { const handleToggle = (optionValue: string) => {
if (value.includes(optionValue)) { if (multiple) {
onValueChange(value.filter((v) => v !== optionValue)); if (value.includes(optionValue)) {
onValueChange(value.filter((v) => v !== optionValue));
} else {
onValueChange([...value, optionValue]);
}
} else { } else {
onValueChange([...value, optionValue]); onValueChange([optionValue]);
setIsOpen(false);
} }
}; };
@ -247,10 +256,12 @@ export const MultiselectPaginatedSelect = ({
ref={buttonRef} ref={buttonRef}
type="button" type="button"
id={fieldId} id={fieldId}
disabled={disabled}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={cn( className={cn(
"min-h-10 w-full px-3.5 py-2 bg-white border rounded-md text-sm transition-colors", "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", "flex items-center justify-between gap-2",
"disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-50",
hasError hasError
? "border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20" ? "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", : "border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20",

File diff suppressed because it is too large Load Diff

View 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;

View File

@ -24,4 +24,6 @@ export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader'; export type { TabItem } from './PageHeader';
export { AuthenticatedImage } from './AuthenticatedImage'; export { AuthenticatedImage } from './AuthenticatedImage';
export * from './DepartmentModals'; export * from './DepartmentModals';
export * from './DesignationModals'; export * from './DesignationModals';
export { default as WorkflowDefinitionsTable } from './WorkflowDefinitionsTable';
export { WorkflowDefinitionModal } from './WorkflowDefinitionModal';

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, type ReactElement } from 'react'; import { useState, useEffect, useMemo, type ReactElement } from "react";
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from "react-router-dom";
import { import {
Calendar, Calendar,
Globe, Globe,
@ -15,52 +15,89 @@ import {
Building2, Building2,
BadgeCheck, BadgeCheck,
UserCog, UserCog,
} from 'lucide-react'; GitBranch,
import { Layout } from '@/components/layout/Layout'; } from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { import {
StatusBadge, StatusBadge,
DataTable, DataTable,
Pagination, Pagination,
WorkflowDefinitionsTable,
type Column, type Column,
} from '@/components/shared'; } from "@/components/shared";
import { UsersTable, RolesTable } from '@/components/superadmin'; import { UsersTable, RolesTable } from "@/components/superadmin";
import { tenantService } from '@/services/tenant-service'; import { tenantService } from "@/services/tenant-service";
import { auditLogService } from '@/services/audit-log-service'; import { auditLogService } from "@/services/audit-log-service";
import { moduleService } from '@/services/module-service'; import { moduleService } from "@/services/module-service";
import type { Tenant } from '@/types/tenant'; import type { Tenant } from "@/types/tenant";
import type { AuditLog } from '@/types/audit-log'; import type { AuditLog } from "@/types/audit-log";
import type { MyModule } from '@/types/module'; import type { MyModule } from "@/types/module";
import { formatDate } from '@/utils/format-date'; import { formatDate } from "@/utils/format-date";
import DepartmentsTable from '@/components/superadmin/DepartmentsTable'; import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
import DesignationsTable from '@/components/superadmin/DesignationsTable'; import DesignationsTable from "@/components/superadmin/DesignationsTable";
import UserCategoriesTable from '@/components/superadmin/UserCategoriesTable'; 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 }> = [ const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
{ id: 'overview', label: 'Overview', icon: <FileText 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: "users", label: "Users", icon: <Users className="w-4 h-4" /> },
{ id: 'roles', label: 'Roles', icon: <FileText 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: "departments",
{ id: 'user-categories', label: 'User Categories', icon: <UserCog className="w-4 h-4" /> }, label: "Departments",
{ id: 'modules', label: 'Modules', icon: <Package className="w-4 h-4" /> }, icon: <Building2 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: "designations",
{ id: 'billing', label: 'Billing', icon: <CreditCard className="w-4 h-4" /> }, 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()) { switch (status.toLowerCase()) {
case 'active': case "active":
return 'success'; return "success";
case 'suspended': case "suspended":
return 'process'; return "process";
case 'deleted': case "deleted":
return 'failure'; return "failure";
default: default:
return 'success'; return "success";
} }
}; };
@ -75,12 +112,11 @@ const getTenantInitials = (name: string): string => {
const TenantDetails = (): ReactElement => { const TenantDetails = (): ReactElement => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>("overview");
const [tenant, setTenant] = useState<Tenant | null>(null); const [tenant, setTenant] = useState<Tenant | null>(null);
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);
// Modules tab state - using assignedModules from tenant response // Modules tab state - using assignedModules from tenant response
// Audit logs tab state // Audit logs tab state
@ -113,10 +149,13 @@ const TenantDetails = (): ReactElement => {
if (response.success) { if (response.success) {
setTenant(response.data); setTenant(response.data);
} else { } else {
setError('Failed to load tenant details'); setError("Failed to load tenant details");
} }
} catch (err: any) { } 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -130,13 +169,19 @@ const TenantDetails = (): ReactElement => {
if (!id) return; if (!id) return;
try { try {
setAuditLogsLoading(true); 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) { if (response.success) {
setAuditLogs(response.data); setAuditLogs(response.data);
setAuditLogsPagination(response.pagination); setAuditLogsPagination(response.pagination);
} }
} catch (err: any) { } catch (err: any) {
console.error('Failed to load audit logs:', err); console.error("Failed to load audit logs:", err);
} finally { } finally {
setAuditLogsLoading(false); setAuditLogsLoading(false);
} }
@ -144,7 +189,7 @@ const TenantDetails = (): ReactElement => {
// Fetch data when tab changes // Fetch data when tab changes
useEffect(() => { useEffect(() => {
if (activeTab === 'audit-logs' && id) { if (activeTab === "audit-logs" && id) {
fetchAuditLogs(); fetchAuditLogs();
} }
}, [activeTab, id, auditLogsPage]); }, [activeTab, id, auditLogsPage]);
@ -155,8 +200,10 @@ const TenantDetails = (): ReactElement => {
return { return {
totalUsers: tenant.users?.length || 0, totalUsers: tenant.users?.length || 0,
totalModules: tenant.assignedModules?.length || 0, totalModules: tenant.assignedModules?.length || 0,
activeModules: tenant.assignedModules?.filter((m) => m.status === 'running')?.length || 0, activeModules:
subscriptionTier: tenant.subscription_tier || 'N/A', tenant.assignedModules?.filter((m) => m.status === "running")?.length ||
0,
subscriptionTier: tenant.subscription_tier || "N/A",
}; };
}, [tenant]); }, [tenant]);
@ -165,13 +212,15 @@ const TenantDetails = (): ReactElement => {
<Layout <Layout
currentPage="Tenant Details" currentPage="Tenant Details"
breadcrumbs={[ breadcrumbs={[
{ label: 'QAssure', path: '/dashboard' }, { label: "QAssure", path: "/dashboard" },
{ label: 'Tenant Management', path: '/tenants' }, { label: "Tenant Management", path: "/tenants" },
{ label: 'Tenant Details' }, { label: "Tenant Details" },
]} ]}
> >
<div className="flex items-center justify-center min-h-[400px]"> <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> </div>
</Layout> </Layout>
); );
@ -182,13 +231,15 @@ const TenantDetails = (): ReactElement => {
<Layout <Layout
currentPage="Tenant Details" currentPage="Tenant Details"
breadcrumbs={[ breadcrumbs={[
{ label: 'QAssure', path: '/dashboard' }, { label: "QAssure", path: "/dashboard" },
{ label: 'Tenant Management', path: '/tenants' }, { label: "Tenant Management", path: "/tenants" },
{ label: 'Tenant Details' }, { label: "Tenant Details" },
]} ]}
> >
<div className="flex items-center justify-center min-h-[400px]"> <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> </div>
</Layout> </Layout>
); );
@ -198,9 +249,9 @@ const TenantDetails = (): ReactElement => {
<Layout <Layout
currentPage="Tenant Details" currentPage="Tenant Details"
breadcrumbs={[ breadcrumbs={[
{ label: 'QAssure', path: '/dashboard' }, { label: "QAssure", path: "/dashboard" },
{ label: 'Tenant Management', path: '/tenants' }, { label: "Tenant Management", path: "/tenants" },
{ label: 'Tenant Details' }, { label: "Tenant Details" },
]} ]}
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@ -229,7 +280,11 @@ const TenantDetails = (): ReactElement => {
</div> </div>
{tenant.domain && ( {tenant.domain && (
<a <a
href={tenant.domain.startsWith('http') ? tenant.domain : `https://${tenant.domain}`} href={
tenant.domain.startsWith("http")
? tenant.domain
: `https://${tenant.domain}`
}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-[#6b7280] hover:text-[#112868] transition-colors" className="flex items-center gap-1.5 text-[#6b7280] hover:text-[#112868] transition-colors"
@ -246,7 +301,7 @@ const TenantDetails = (): ReactElement => {
</div> </div>
</div> </div>
<button <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" 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" /> <Edit className="w-4 h-4" />
@ -265,8 +320,8 @@ const TenantDetails = (): ReactElement => {
onClick={() => setActiveTab(tab.id)} 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 ${ className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
activeTab === tab.id activeTab === tab.id
? 'border-[#112868] text-[#112868]' ? "border-[#112868] text-[#112868]"
: 'border-transparent text-[#6b7280] hover:text-[#0f1724] hover:border-gray-300' : "border-transparent text-[#6b7280] hover:text-[#0f1724] hover:border-gray-300"
}`} }`}
> >
{tab.icon} {tab.icon}
@ -278,32 +333,33 @@ const TenantDetails = (): ReactElement => {
{/* Tab Content */} {/* Tab Content */}
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
{activeTab === 'overview' && ( {activeTab === "overview" && (
<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={true} />
)} )}
{activeTab === 'roles' && id && ( {activeTab === "roles" && id && (
<RolesTable tenantId={id} compact={true} /> <RolesTable tenantId={id} compact={true} />
)} )}
{activeTab === 'departments' && id && ( {activeTab === "departments" && id && (
<DepartmentsTable tenantId={id} compact={true} /> <DepartmentsTable tenantId={id} compact={true} />
)} )}
{activeTab === 'designations' && id && ( {activeTab === "designations" && id && (
<DesignationsTable tenantId={id} compact={true} /> <DesignationsTable tenantId={id} compact={true} />
)} )}
{activeTab === 'user-categories' && id && ( {activeTab === "user-categories" && id && (
<UserCategoriesTable tenantId={id} compact={true} /> <UserCategoriesTable tenantId={id} compact={true} />
)} )}
{activeTab === 'modules' && id && ( {activeTab === "workflow-definitions" && id && (
<ModulesTab tenantId={id} /> <WorkflowDefinitionsTable tenantId={id} compact={true} />
)} )}
{activeTab === 'settings' && tenant && ( {activeTab === "modules" && id && <ModulesTab tenantId={id} />}
{activeTab === "settings" && tenant && (
<SettingsTab tenant={tenant} /> <SettingsTab tenant={tenant} />
)} )}
{activeTab === 'license' && <LicenseTab tenant={tenant} />} {activeTab === "license" && <LicenseTab tenant={tenant} />}
{activeTab === 'audit-logs' && ( {activeTab === "audit-logs" && (
<AuditLogsTab <AuditLogsTab
auditLogs={auditLogs} auditLogs={auditLogs}
isLoading={auditLogsLoading} isLoading={auditLogsLoading}
@ -313,7 +369,7 @@ const TenantDetails = (): ReactElement => {
onPageChange={setAuditLogsPage} onPageChange={setAuditLogsPage}
/> />
)} )}
{activeTab === 'billing' && <BillingTab tenant={tenant} />} {activeTab === "billing" && <BillingTab tenant={tenant} />}
</div> </div>
</div> </div>
</div> </div>
@ -338,60 +394,106 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <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="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-sm font-medium text-[#6b7280] mb-1">
<div className="text-2xl font-bold text-[#0f1724]">{stats?.totalUsers || 0}</div> Total Users
</div>
<div className="text-2xl font-bold text-[#0f1724]">
{stats?.totalUsers || 0}
</div>
</div> </div>
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-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 Modules</div> <div className="text-sm font-medium text-[#6b7280] mb-1">
<div className="text-2xl font-bold text-[#0f1724]">{stats?.totalModules || 0}</div> Total Modules
</div>
<div className="text-2xl font-bold text-[#0f1724]">
{stats?.totalModules || 0}
</div>
</div> </div>
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-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">Active Modules</div> <div className="text-sm font-medium text-[#6b7280] mb-1">
<div className="text-2xl font-bold text-[#0f1724]">{stats?.activeModules || 0}</div> Active Modules
</div>
<div className="text-2xl font-bold text-[#0f1724]">
{stats?.activeModules || 0}
</div>
</div> </div>
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-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">Subscription Tier</div> <div className="text-sm font-medium text-[#6b7280] mb-1">
<div className="text-2xl font-bold text-[#0f1724] capitalize">{stats?.subscriptionTier || 'N/A'}</div> Subscription Tier
</div>
<div className="text-2xl font-bold text-[#0f1724] capitalize">
{stats?.subscriptionTier || "N/A"}
</div>
</div> </div>
</div> </div>
{/* General Information */} {/* General Information */}
<div className="bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6"> <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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<div className="text-sm font-medium text-[#6b7280] mb-1">Tenant Name</div> <div className="text-sm font-medium text-[#6b7280] mb-1">
<div className="text-sm font-normal text-[#0f1724]">{tenant.name}</div> Tenant Name
</div> </div>
<div> <div className="text-sm font-normal text-[#0f1724]">
<div className="text-sm font-medium text-[#6b7280] mb-1">Slug</div> {tenant.name}
<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>
<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'}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-[#6b7280] mb-1">Max Users</div> <div className="text-sm font-medium text-[#6b7280] mb-1">Slug</div>
<div className="text-sm font-normal text-[#0f1724]">{tenant.max_users || 'Unlimited'}</div> <div className="text-sm font-normal text-[#0f1724]">
{tenant.slug}
</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-[#6b7280] mb-1">Max Modules</div> <div className="text-sm font-medium text-[#6b7280] mb-1">
<div className="text-sm font-normal text-[#0f1724]">{tenant.max_modules || 'Unlimited'}</div> Status
</div>
<StatusBadge variant={getStatusVariant(tenant.status)}>
{tenant.status}
</StatusBadge>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-[#6b7280] mb-1">Created At</div> <div className="text-sm font-medium text-[#6b7280] mb-1">
<div className="text-sm font-normal text-[#0f1724]">{formatDate(tenant.created_at)}</div> Subscription Tier
</div>
<div className="text-sm font-normal text-[#0f1724] capitalize">
{tenant.subscription_tier || "N/A"}
</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-[#6b7280] mb-1">Updated At</div> <div className="text-sm font-medium text-[#6b7280] mb-1">
<div className="text-sm font-normal text-[#0f1724]">{formatDate(tenant.updated_at)}</div> 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>
<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> </div>
</div> </div>
</div> </div>
@ -399,7 +501,6 @@ const OverviewTab = ({ tenant, stats }: OverviewTabProps): ReactElement => {
); );
}; };
// Modules Tab Component // Modules Tab Component
interface ModulesTabProps { interface ModulesTabProps {
tenantId: string; tenantId: string;
@ -419,10 +520,10 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
if (response.success && response.data) { if (response.success && response.data) {
setModules(response.data); setModules(response.data);
} else { } else {
setError('Failed to load modules'); setError("Failed to load modules");
} }
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load modules'); setError(err?.response?.data?.error?.message || "Failed to load modules");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -439,51 +540,59 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
try { try {
const response = await moduleService.launch(moduleId, tenantId); const response = await moduleService.launch(moduleId, tenantId);
if (response.success && response.data.launch_url) { 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) { } 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>[] = [ const columns: Column<MyModule>[] = [
{ {
key: 'name', key: "name",
label: 'Module Name', label: "Module Name",
render: (module) => ( render: (module) => (
<div className="flex items-center gap-3"> <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"> <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]" /> <Package className="w-4 h-4 text-[#9aa6b2]" />
</div> </div>
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span> <span className="text-sm font-normal text-[#0f1724]">
{module.name}
</span>
</div> </div>
), ),
}, },
{ {
key: 'module_id', key: "module_id",
label: 'Module ID', label: "Module ID",
render: (module) => ( 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', key: "version",
label: 'Version', label: "Version",
render: (module) => ( 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', key: "status",
label: 'Status', label: "Status",
render: (module) => ( render: (module) => (
<StatusBadge <StatusBadge
variant={ variant={
module.status === 'running' module.status === "running"
? 'success' ? "success"
: module.status === 'degraded' : module.status === "degraded"
? 'process' ? "process"
: 'info' : "info"
} }
> >
{module.status} {module.status}
@ -491,25 +600,27 @@ const ModulesTab = ({ tenantId }: ModulesTabProps): ReactElement => {
), ),
}, },
{ {
key: 'base_url', key: "base_url",
label: 'Base URL', label: "Base URL",
render: (module) => ( render: (module) => (
<span className="text-sm font-normal text-[#6b7280] font-mono truncate max-w-[200px]"> <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> </span>
), ),
}, },
{ {
key: 'assigned_at', key: "assigned_at",
label: 'Assigned At', label: "Assigned At",
render: (module) => ( 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', key: "actions",
label: 'Actions', label: "Actions",
align: 'right', align: "right",
render: (module) => ( render: (module) => (
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -547,9 +658,13 @@ const LicenseTab = ({ tenant: _tenant }: LicenseTabProps): ReactElement => {
// Placeholder for license data // Placeholder for license data
return ( return (
<div className="flex flex-col gap-4"> <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="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>
</div> </div>
); );
@ -581,57 +696,65 @@ const AuditLogsTab = ({
}: AuditLogsTabProps): ReactElement => { }: AuditLogsTabProps): ReactElement => {
const columns: Column<AuditLog>[] = [ const columns: Column<AuditLog>[] = [
{ {
key: 'action', key: "action",
label: 'Action', label: "Action",
render: (log) => ( render: (log) => (
<span className="text-sm font-medium text-[#0f1724]">{log.action}</span> <span className="text-sm font-medium text-[#0f1724]">{log.action}</span>
), ),
}, },
{ {
key: 'resource_type', key: "resource_type",
label: 'Resource', label: "Resource",
render: (log) => (
<span className="text-sm font-normal text-[#6b7280]">{log.resource_type}</span>
),
},
{
key: 'user',
label: 'User',
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#6b7280]"> <span className="text-sm font-normal text-[#6b7280]">
{log.user ? `${log.user.first_name} ${log.user.last_name}` : 'System'} {log.resource_type}
</span> </span>
), ),
}, },
{ {
key: 'request_method', key: "user",
label: 'Method', label: "User",
render: (log) => ( 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', key: "request_method",
label: 'Status', 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) => ( render: (log) => (
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${
log.response_status && log.response_status >= 200 && log.response_status < 300 log.response_status &&
? 'text-green-600' log.response_status >= 200 &&
log.response_status < 300
? "text-green-600"
: log.response_status && log.response_status >= 400 : log.response_status && log.response_status >= 400
? 'text-red-600' ? "text-red-600"
: 'text-gray-600' : "text-gray-600"
}`} }`}
> >
{log.response_status || 'N/A'} {log.response_status || "N/A"}
</span> </span>
), ),
}, },
{ {
key: 'created_at', key: "created_at",
label: 'Date', label: "Date",
render: (log) => ( 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 SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null); const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [primaryColor, setPrimaryColor] = useState<string>('#112868'); const [primaryColor, setPrimaryColor] = useState<string>("#112868");
const [secondaryColor, setSecondaryColor] = useState<string>('#23DCE1'); const [secondaryColor, setSecondaryColor] = useState<string>("#23DCE1");
const [accentColor, setAccentColor] = useState<string>('#084CC8'); const [accentColor, setAccentColor] = useState<string>("#084CC8");
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Validate file size (2MB max) // Validate file size (2MB max)
if (file.size > 2 * 1024 * 1024) { 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; return;
} }
// Validate file type // 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)) { if (!validTypes.includes(file.type)) {
alert('Logo must be PNG, SVG, or JPG format'); alert("Logo must be PNG, SVG, or JPG format");
return; return;
} }
setLogoFile(file); setLogoFile(file);
@ -696,13 +824,17 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
if (file) { if (file) {
// Validate file size (500KB max) // Validate file size (500KB max)
if (file.size > 500 * 1024) { if (file.size > 500 * 1024) {
alert('Favicon file size must be less than 500KB'); alert("Favicon file size must be less than 500KB");
return; return;
} }
// Validate file type // 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)) { if (!validTypes.includes(file.type)) {
alert('Favicon must be ICO or PNG format'); alert("Favicon must be ICO or PNG format");
return; return;
} }
setFaviconFile(file); setFaviconFile(file);
@ -725,7 +857,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */} {/* Company Logo */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="logo-upload" 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" 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]" /> <ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div> </div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0"> <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-sm font-medium text-[#0f1724]">
<span className="text-xs font-normal text-[#9ca3af]">PNG, SVG, JPG up to 2MB.</span> Upload Logo
</span>
<span className="text-xs font-normal text-[#9ca3af]">
PNG, SVG, JPG up to 2MB.
</span>
</div> </div>
<input <input
id="logo-upload" id="logo-upload"
@ -754,7 +892,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
{/* Favicon */} {/* Favicon */}
<div className="flex flex-col gap-2"> <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 <label
htmlFor="favicon-upload" 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" 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]" /> <ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div> </div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0"> <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-sm font-medium text-[#0f1724]">
<span className="text-xs font-normal text-[#9ca3af]">ICO or PNG up to 500KB.</span> Upload Favicon
</span>
<span className="text-xs font-normal text-[#9ca3af]">
ICO or PNG up to 500KB.
</span>
</div> </div>
<input <input
id="favicon-upload" id="favicon-upload"
@ -784,7 +928,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
{/* Primary Color */} {/* Primary Color */}
<div className="flex flex-col gap-2"> <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="flex gap-3 items-center">
<div <div
className="border border-[#d1d5db] rounded-md size-10 shrink-0" 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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Secondary Color */} {/* Secondary Color */}
<div className="flex flex-col gap-2"> <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="flex gap-3 items-center">
<div <div
className="border border-[#d1d5db] rounded-md size-10 shrink-0" className="border border-[#d1d5db] rounded-md size-10 shrink-0"
@ -844,7 +992,9 @@ const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
{/* Accent Color */} {/* Accent Color */}
<div className="flex flex-col gap-2"> <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="flex gap-3 items-center">
<div <div
className="border border-[#d1d5db] rounded-md size-10 shrink-0" className="border border-[#d1d5db] rounded-md size-10 shrink-0"
@ -885,9 +1035,13 @@ const BillingTab = ({ tenant: _tenant }: BillingTabProps): ReactElement => {
// Placeholder for billing data // Placeholder for billing data
return ( return (
<div className="flex flex-col gap-4"> <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="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>
</div> </div>
); );

View 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;

View File

@ -1,15 +1,18 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from "react";
import type { ReactElement } from 'react'; import type { ReactElement } from "react";
// Lazy load route components for code splitting // Lazy load route components for code splitting
const Dashboard = lazy(() => import('@/pages/tenant/Dashboard')); const Dashboard = lazy(() => import("@/pages/tenant/Dashboard"));
const Roles = lazy(() => import('@/pages/tenant/Roles')); const Roles = lazy(() => import("@/pages/tenant/Roles"));
const Settings = lazy(() => import('@/pages/tenant/Settings')); const Settings = lazy(() => import("@/pages/tenant/Settings"));
const Users = lazy(() => import('@/pages/tenant/Users')); const Users = lazy(() => import("@/pages/tenant/Users"));
const AuditLogs = lazy(() => import('@/pages/tenant/AuditLogs')); const AuditLogs = lazy(() => import("@/pages/tenant/AuditLogs"));
const Modules = lazy(() => import('@/pages/tenant/Modules')); const Modules = lazy(() => import("@/pages/tenant/Modules"));
const Departments = lazy(() => import('@/pages/tenant/Departments')); const Departments = lazy(() => import("@/pages/tenant/Departments"));
const Designations = lazy(() => import('@/pages/tenant/Designations')); const Designations = lazy(() => import("@/pages/tenant/Designations"));
const WorkflowDefination = lazy(
() => import("@/pages/tenant/WorkflowDefination"),
);
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -19,7 +22,11 @@ const RouteLoader = (): ReactElement => (
); );
// Wrapper component with Suspense // Wrapper component with Suspense
const LazyRoute = ({ component: Component }: { component: React.ComponentType }): ReactElement => ( const LazyRoute = ({
component: Component,
}: {
component: React.ComponentType;
}): ReactElement => (
<Suspense fallback={<RouteLoader />}> <Suspense fallback={<RouteLoader />}>
<Component /> <Component />
</Suspense> </Suspense>
@ -33,35 +40,39 @@ export interface RouteConfig {
// Tenant Admin routes (requires authentication but NOT super_admin role) // Tenant Admin routes (requires authentication but NOT super_admin role)
export const tenantAdminRoutes: RouteConfig[] = [ export const tenantAdminRoutes: RouteConfig[] = [
{ {
path: '/tenant', path: "/tenant",
element: <LazyRoute component={Dashboard} />, element: <LazyRoute component={Dashboard} />,
}, },
{ {
path: '/tenant/roles', path: "/tenant/roles",
element: <LazyRoute component={Roles} />, element: <LazyRoute component={Roles} />,
}, },
{ {
path: '/tenant/users', path: "/tenant/users",
element: <LazyRoute component={Users} />, element: <LazyRoute component={Users} />,
}, },
{ {
path: '/tenant/modules', path: "/tenant/modules",
element: <LazyRoute component={Modules} />, element: <LazyRoute component={Modules} />,
}, },
{ {
path: '/tenant/audit-logs', path: "/tenant/audit-logs",
element: <LazyRoute component={AuditLogs} />, element: <LazyRoute component={AuditLogs} />,
}, },
{ {
path: '/tenant/settings', path: "/tenant/settings",
element: <LazyRoute component={Settings} />, element: <LazyRoute component={Settings} />,
}, },
{ {
path: '/tenant/departments', path: "/tenant/departments",
element: <LazyRoute component={Departments} />, element: <LazyRoute component={Departments} />,
}, },
{ {
path: '/tenant/designations', path: "/tenant/designations",
element: <LazyRoute component={Designations} />, element: <LazyRoute component={Designations} />,
}, },
{
path: "/tenant/workflow-definitions",
element: <LazyRoute component={WorkflowDefination} />,
},
]; ];

View 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
View 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;
}