Dealer_Onboard_Frontend/src/components/admin/ApprovalPoliciesPage.tsx

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