458 lines
20 KiB
TypeScript
458 lines
20 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
|
import { Button } from '../ui/button';
|
|
import { Input } from '../ui/input';
|
|
import { Label } from '../ui/label';
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '../ui/select';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
|
import { Badge } from '../ui/badge';
|
|
import { RefreshCw, Settings2, Edit2, Save, X, Plus } from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription
|
|
} from '../ui/dialog';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger
|
|
} from '../ui/dropdown-menu';
|
|
import { ROLES } from '../../lib/constants';
|
|
import { approvalPolicyService } from '../../services/approvalPolicy.service';
|
|
|
|
type ApprovalMode = 'ALL' | 'MIN_N' | 'ROLE_MANDATORY';
|
|
|
|
interface Policy {
|
|
stageCode: string;
|
|
minApprovals: number;
|
|
approvalMode: ApprovalMode;
|
|
requiredRoles: string[];
|
|
isActive: boolean;
|
|
}
|
|
|
|
const AVAILABLE_ROLES = Object.values(ROLES).sort();
|
|
|
|
const STAGE_OPTIONS = [
|
|
{
|
|
label: 'Onboarding',
|
|
stages: [
|
|
{ label: 'General Info', value: 'ONBOARDING_GENERAL' },
|
|
{ label: 'KYC Verification', value: 'ONBOARDING_KYC' },
|
|
{ label: 'Level 1 Interview', value: 'LEVEL_1_INTERVIEW' },
|
|
{ label: 'Level 2 Interview', value: 'LEVEL_2_INTERVIEW' },
|
|
{ label: 'Level 3 Interview', value: 'LEVEL_3_INTERVIEW' },
|
|
{ label: 'FDD Verification', value: 'FDD_VERIFICATION' },
|
|
{ label: 'LOI Approval', value: 'LOI_APPROVAL' },
|
|
{ label: 'LOA Approval', value: 'LOA_APPROVAL' },
|
|
{ label: 'Architecture Team Assigned', value: 'ARCHITECTURE_ASSIGNMENT' },
|
|
{ label: 'Architecture Doc Upload', value: 'ARCHITECTURE_DOCUMENT_UPLOAD' },
|
|
{ label: 'Statutory Verification', value: 'STATUTORY_CHECK' },
|
|
{ label: 'EOR Verification', value: 'EOR_VERIFICATION' },
|
|
]
|
|
},
|
|
{
|
|
label: 'Offboarding (Resignation)',
|
|
stages: [
|
|
{ label: 'Regional Review', value: 'RESIGNATION_REGIONAL_REVIEW' },
|
|
{ label: 'ZM Review', value: 'RESIGNATION_ZM_REVIEW' },
|
|
{ label: 'ZBH Review', value: 'RESIGNATION_ZBH_REVIEW' },
|
|
{ label: 'Finance Clearance', value: 'RESIGNATION_FINANCE_REVIEW' },
|
|
{ label: 'DDL Review', value: 'RESIGNATION_DDL_REVIEW' },
|
|
{ label: 'Final Approval', value: 'RESIGNATION_APPROVED' },
|
|
]
|
|
},
|
|
{
|
|
label: 'Termination',
|
|
stages: [
|
|
{ label: 'RBM Review', value: 'TERMINATION_HEARING' },
|
|
{ label: 'DDL Evaluation', value: 'TERMINATION_REVIEW' },
|
|
{ label: 'Legal Verification', value: 'TERMINATION_LEGAL_VERIFICATION' },
|
|
{ label: 'Final NBH Approval', value: 'TERMINATION_CLOSED' },
|
|
]
|
|
},
|
|
{
|
|
label: 'Relocation & CC',
|
|
stages: [
|
|
{ label: 'Relocation ASM Review', value: 'RELOCATION_ASM_REVIEW' },
|
|
{ label: 'Relocation Head Approval', value: 'RELOCATION_COMPLETED' },
|
|
{ label: 'CC Legal Review', value: 'CONSTITUTIONAL_LEGAL_REVIEW' },
|
|
{ label: 'CC Head Approval', value: 'CONSTITUTIONAL_APPROVED' },
|
|
]
|
|
}
|
|
];
|
|
|
|
export function ApprovalPoliciesPage() {
|
|
const [loading, setLoading] = useState(false);
|
|
const [policies, setPolicies] = useState<Policy[]>([]);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [isCustomStage, setIsCustomStage] = useState(false);
|
|
const [draft, setDraft] = useState<Policy>({
|
|
stageCode: '',
|
|
minApprovals: 1,
|
|
approvalMode: 'MIN_N',
|
|
requiredRoles: [],
|
|
isActive: true
|
|
});
|
|
|
|
const sortedPolicies = useMemo(
|
|
() => [...policies].sort((a, b) => a.stageCode.localeCompare(b.stageCode)),
|
|
[policies]
|
|
);
|
|
|
|
const fetchPolicies = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await approvalPolicyService.getPolicies();
|
|
if (res?.success) setPolicies(res.data || []);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchPolicies();
|
|
}, []);
|
|
|
|
const openCreateModal = () => {
|
|
setIsEditMode(false);
|
|
setIsCustomStage(false);
|
|
setDraft({
|
|
stageCode: '',
|
|
minApprovals: 1,
|
|
approvalMode: 'MIN_N',
|
|
requiredRoles: [],
|
|
isActive: true
|
|
});
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const openEditModal = (policy: Policy) => {
|
|
setIsEditMode(true);
|
|
setIsCustomStage(true); // Always treat existings as "custom entry" if not found in list, for UI consistency
|
|
setDraft({
|
|
stageCode: policy.stageCode,
|
|
minApprovals: policy.minApprovals || 1,
|
|
approvalMode: (policy.approvalMode as ApprovalMode) || 'MIN_N',
|
|
requiredRoles: Array.isArray(policy.requiredRoles) ? [...policy.requiredRoles] : [],
|
|
isActive: policy.isActive !== false
|
|
});
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const savePolicy = async () => {
|
|
if (!draft.stageCode.trim()) return;
|
|
|
|
if (draft.approvalMode === 'ROLE_MANDATORY' && draft.requiredRoles.length > 0 && draft.minApprovals > draft.requiredRoles.length) {
|
|
alert('In ROLE_MANDATORY mode, min approvals cannot exceed the number of required roles.');
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
minApprovals: Number(draft.minApprovals) || 1,
|
|
approvalMode: draft.approvalMode,
|
|
requiredRoles: draft.requiredRoles,
|
|
isActive: draft.isActive
|
|
};
|
|
|
|
const stageCode = draft.stageCode.trim().toUpperCase().replace(/\s+/g, '_');
|
|
const res = await approvalPolicyService.savePolicy(stageCode, payload);
|
|
|
|
if (res?.success) {
|
|
await fetchPolicies();
|
|
setIsModalOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-7xl mx-auto py-6 px-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
|
<Settings2 className="w-6 h-6 text-amber-600" />
|
|
Approval Policies
|
|
</h1>
|
|
<p className="text-slate-500">Configure stage-level approvers, mode, and minimum approvals.</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={fetchPolicies} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
<Button className="bg-amber-600 hover:bg-amber-700" onClick={openCreateModal}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add New Policy
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Card className="border-slate-200 overflow-hidden shadow-sm">
|
|
<CardHeader className="bg-slate-50 px-6 py-4 border-b">
|
|
<CardTitle className="text-lg font-semibold text-slate-800">Configured Stages</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader className="bg-slate-50/50">
|
|
<TableRow>
|
|
<TableHead className="w-[200px]">Stage Code</TableHead>
|
|
<TableHead>Approval Mode</TableHead>
|
|
<TableHead>Min Appr.</TableHead>
|
|
<TableHead className="min-w-[300px]">Required Roles</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sortedPolicies.map((policy) => (
|
|
<TableRow key={policy.stageCode} className="hover:bg-slate-50/50 transition-colors">
|
|
<TableCell className="font-mono text-xs font-semibold text-slate-700">{policy.stageCode}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="font-medium px-2 py-0.5 text-slate-600 border-slate-300 uppercase">
|
|
{policy.approvalMode}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="font-medium text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full text-xs">
|
|
{policy.minApprovals}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1.5 py-1">
|
|
{(policy.requiredRoles || []).map((role) => (
|
|
<Badge key={role} variant="secondary" className="bg-slate-100 text-slate-600 border-transparent hover:bg-slate-200 text-[11px] font-normal">
|
|
{role}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${policy.isActive ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]' : 'bg-slate-400'}`} />
|
|
<span className={`text-xs font-medium ${policy.isActive ? 'text-green-700' : 'text-slate-500'}`}>
|
|
{policy.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-amber-600 hover:text-amber-700 hover:bg-amber-50 h-8 px-2"
|
|
onClick={() => openEditModal(policy)}
|
|
>
|
|
<Edit2 className="w-4 h-4 mr-1.5" />
|
|
Edit
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Unified Edit/Create Modal */}
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
<DialogContent className="sm:max-w-[480px] overflow-visible">
|
|
<DialogHeader className="gap-1 pb-2 border-b">
|
|
<DialogTitle className="text-base flex items-center gap-2">
|
|
{isEditMode ? <Edit2 className="w-4 h-4 text-amber-600" /> : <Plus className="w-4 h-4 text-amber-600" />}
|
|
{isEditMode ? 'Edit Policy' : 'Create New Policy'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-[11px]">
|
|
{isEditMode
|
|
? `Update configuration for stage ${draft.stageCode}.`
|
|
: 'Define approval requirements for a workflow stage.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3 py-4">
|
|
<div className="grid grid-cols-4 items-start gap-4">
|
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight pt-2">Stage</Label>
|
|
<div className="col-span-3 space-y-2">
|
|
{!isCustomStage && !isEditMode ? (
|
|
<div className="space-y-2">
|
|
<Select
|
|
value={draft.stageCode}
|
|
onValueChange={(val) => {
|
|
if (val === 'CUSTOM') {
|
|
setIsCustomStage(true);
|
|
setDraft({ ...draft, stageCode: '' });
|
|
} else {
|
|
setDraft({ ...draft, stageCode: val });
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full h-8 text-[11px] font-medium border-slate-200">
|
|
<SelectValue placeholder="Select a workflow stage..." />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-[300px]">
|
|
{STAGE_OPTIONS.map((group) => (
|
|
<SelectGroup key={group.label}>
|
|
<SelectLabel className="text-[10px] uppercase text-slate-400 font-bold bg-slate-50 px-2 py-1">{group.label}</SelectLabel>
|
|
{group.stages.map((stage) => (
|
|
<SelectItem key={stage.value} value={stage.value} className="text-xs">
|
|
{stage.label} <span className="text-[10px] text-slate-400 font-mono ml-1">({stage.value})</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
))}
|
|
<SelectGroup>
|
|
<DropdownMenuSeparator />
|
|
<SelectItem value="CUSTOM" className="text-xs font-semibold text-amber-600 italic">
|
|
+ Enter Custom Stage Code
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
) : (
|
|
<div className="relative">
|
|
<Input
|
|
className="w-full font-mono text-xs h-8 pr-8"
|
|
placeholder="e.g. FNF_SETTLEMENT"
|
|
disabled={isEditMode}
|
|
value={draft.stageCode}
|
|
onChange={(e) => setDraft({ ...draft, stageCode: e.target.value.toUpperCase() })}
|
|
/>
|
|
{!isEditMode && (
|
|
<X
|
|
className="w-3 h-3 absolute right-2.5 top-2.5 text-slate-400 cursor-pointer hover:text-slate-600"
|
|
onClick={() => {
|
|
setIsCustomStage(false);
|
|
setDraft({ ...draft, stageCode: '' });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Mode</Label>
|
|
<div className="col-span-3">
|
|
<Select
|
|
value={draft.approvalMode}
|
|
onValueChange={(val: ApprovalMode) => setDraft({ ...draft, approvalMode: val })}
|
|
>
|
|
<SelectTrigger className="w-full h-8 text-[11px] font-medium border-slate-200">
|
|
<SelectValue placeholder="Select mode" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ALL" className="text-xs">ALL (Everyone must approve)</SelectItem>
|
|
<SelectItem value="MIN_N" className="text-xs">MIN_N (First N approvals count)</SelectItem>
|
|
<SelectItem value="ROLE_MANDATORY" className="text-xs">ROLE_MANDATORY (Hierarchy base)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Min Appr.</Label>
|
|
<div className="col-span-3">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={draft.minApprovals}
|
|
onChange={(e) => setDraft({ ...draft, minApprovals: Number(e.target.value || 1) })}
|
|
className="w-20 h-8 text-xs border-slate-200"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 items-start gap-4">
|
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight pt-2">Roles</Label>
|
|
<div className="col-span-3 space-y-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="w-full justify-between h-8 text-[11px] text-slate-500 font-normal border-slate-200">
|
|
<div className="flex items-center gap-1.5">
|
|
<Plus className="w-3 h-3 text-amber-600" />
|
|
<span>Add Roles...</span>
|
|
</div>
|
|
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-transparent text-[9px] px-1 h-4">
|
|
{draft.requiredRoles.length}
|
|
</Badge>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-[280px] max-h-[250px] overflow-y-auto shadow-xl" align="start">
|
|
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-slate-400 font-bold px-2 py-1 border-b">Available Roles</DropdownMenuLabel>
|
|
{AVAILABLE_ROLES.map((role) => (
|
|
<DropdownMenuCheckboxItem
|
|
key={role}
|
|
className="text-xs"
|
|
checked={draft.requiredRoles.includes(role)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setDraft({ ...draft, requiredRoles: [...draft.requiredRoles, role] });
|
|
} else {
|
|
setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) });
|
|
}
|
|
}}
|
|
>
|
|
{role}
|
|
</DropdownMenuCheckboxItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div className="flex flex-wrap gap-1 min-h-[32px] p-2 rounded-sm bg-slate-50/50 border border-slate-100">
|
|
{draft.requiredRoles.map((role) => (
|
|
<Badge key={role} variant="secondary" className="bg-white text-slate-600 border-slate-200 text-[10px] font-normal flex items-center gap-1 py-0 px-1.5 h-5 transition-all">
|
|
{role}
|
|
<X
|
|
className="w-2.5 h-2.5 cursor-pointer text-slate-400 hover:text-red-500"
|
|
onClick={() => setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) })}
|
|
/>
|
|
</Badge>
|
|
))}
|
|
{draft.requiredRoles.length === 0 && (
|
|
<span className="text-slate-400 text-[10px] italic px-1 py-0.5">No roles assigned.</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Status</Label>
|
|
<div className="col-span-3">
|
|
<Select
|
|
value={draft.isActive ? 'active' : 'inactive'}
|
|
onValueChange={(val) => setDraft({ ...draft, isActive: val === 'active' })}
|
|
>
|
|
<SelectTrigger className="w-24 h-8 text-[11px] font-medium border-slate-200">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active" className="text-xs">Active</SelectItem>
|
|
<SelectItem value="inactive" className="text-xs">Inactive</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 pt-3 border-t">
|
|
<Button variant="outline" size="sm" className="text-xs h-8" onClick={() => setIsModalOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button className="bg-amber-600 hover:bg-amber-700 h-8 text-xs font-semibold" onClick={savePolicy}>
|
|
<Save className="w-3 h-3 mr-1.5" />
|
|
{isEditMode ? 'Save Changes' : 'Create Policy'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|