Compare commits

..

7 Commits

Author SHA1 Message Date
c23593bb11 contitutional and relocation changes done based on document alignment 2026-05-06 10:45:55 +05:30
b357dbdcbb stage names modified and calendar addd in opportunity requests 2026-05-04 13:26:51 +05:30
2f82699572 notif
ication service enhanced even more detailed way added more templates documentented i splitted based on modulewise joint approval added for resignation flow, upload ppt document with new docment type add for DD Lead user
2026-04-30 18:52:17 +05:30
95032cf2a7 added joint approval in resignation for RBM DD-ZM approval stage 2026-04-29 16:37:06 +05:30
352c656a9e theme color changed to red i multiple places 2026-04-28 20:42:15 +05:30
2a289e433c stage transition bug resolved 2026-04-28 13:48:05 +05:30
f3927b4686 progress track isue for interview level 1 fixed 2026-04-28 08:17:23 +05:30
63 changed files with 1582 additions and 785 deletions

View File

@ -77,11 +77,15 @@ export default function App() {
const navigate = useNavigate();
const location = useLocation();
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => roles.map((r) => r.toLowerCase()).includes(normalizedRole);
const resignationRoles = ['DD Admin', 'ASM', 'RBM', 'DD Lead', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'Super Admin'];
const terminationRoles = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Legal', 'DD Admin', 'CCO', 'CEO', 'Super Admin'];
const fnfRoles = ['DD Admin', 'DD Lead', 'NBH', 'Finance', 'Finance Admin', 'Super Admin'];
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();
const userRoleCode = String(currentUser?.roleCode || '').toLowerCase();
return normalizedTargetRoles.includes(userRole) || normalizedTargetRoles.includes(userRoleCode);
};
const resignationRoles = ['DD Admin', 'DD_ADMIN', 'ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'LEGAL_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const terminationRoles = ['ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'ZBH', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Legal Admin', 'LEGAL_ADMIN', 'Legal', 'DD Admin', 'DD_ADMIN', 'CCO', 'CEO', 'Super Admin', 'SUPER_ADMIN'];
const fnfRoles = ['DD Admin', 'DD_ADMIN', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Finance', 'Finance Admin', 'FINANCE_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const financeRoles = ['Finance', 'Finance Admin'];
useEffect(() => {

View File

@ -43,6 +43,7 @@ export const API = {
exportApplicationResponses: (params: { applicationIds: string }) => client.get('/onboarding/applications/export-responses', params),
getApplications: (params?: any) => client.get('/onboarding/applications', params),
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data),
sendBulkReminders: (data: { applicationIds: string[] }) => client.post('/onboarding/applications/reminders', data),
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data),
getLatestQuestionnaire: () => client.get('/questionnaire/latest'),

View File

@ -175,7 +175,7 @@ export function ApprovalPoliciesPage() {
<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" />
<Settings2 className="w-6 h-6 text-re-red" />
Approval Policies
</h1>
<p className="text-slate-500">Configure stage-level approvers, mode, and minimum approvals.</p>
@ -185,7 +185,7 @@ export function ApprovalPoliciesPage() {
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button className="bg-amber-600 hover:bg-amber-700" onClick={openCreateModal}>
<Button className="bg-re-red hover:bg-re-red-hover" onClick={openCreateModal}>
<Plus className="w-4 h-4 mr-2" />
Add New Policy
</Button>
@ -244,7 +244,7 @@ export function ApprovalPoliciesPage() {
<Button
size="sm"
variant="ghost"
className="text-amber-600 hover:text-amber-700 hover:bg-amber-50 h-8 px-2"
className="text-re-red hover:text-re-red-hover hover:bg-red-50 h-8 px-2"
onClick={() => openEditModal(policy)}
>
<Edit2 className="w-4 h-4 mr-1.5" />
@ -264,7 +264,7 @@ export function ApprovalPoliciesPage() {
<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 ? <Edit2 className="w-4 h-4 text-re-red" /> : <Plus className="w-4 h-4 text-re-red" />}
{isEditMode ? 'Edit Policy' : 'Create New Policy'}
</DialogTitle>
<DialogDescription className="text-[11px]">
@ -307,7 +307,7 @@ export function ApprovalPoliciesPage() {
))}
<SelectGroup>
<DropdownMenuSeparator />
<SelectItem value="CUSTOM" className="text-xs font-semibold text-amber-600 italic">
<SelectItem value="CUSTOM" className="text-xs font-semibold text-re-red italic">
+ Enter Custom Stage Code
</SelectItem>
</SelectGroup>
@ -376,10 +376,10 @@ export function ApprovalPoliciesPage() {
<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" />
<Plus className="w-3 h-3 text-re-red" />
<span>Add Roles...</span>
</div>
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-transparent text-[9px] px-1 h-4">
<Badge variant="secondary" className="bg-red-50 text-re-red-hover border-transparent text-[9px] px-1 h-4">
{draft.requiredRoles.length}
</Badge>
</Button>
@ -445,7 +445,7 @@ export function ApprovalPoliciesPage() {
<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}>
<Button className="bg-re-red hover:bg-re-red-hover h-8 text-xs font-semibold" onClick={savePolicy}>
<Save className="w-3 h-3 mr-1.5" />
{isEditMode ? 'Save Changes' : 'Create Policy'}
</Button>

View File

@ -204,7 +204,7 @@ const QuestionnaireBuilder: React.FC = () => {
if (fetching) {
return (
<div className="flex justify-center p-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-re-red"></div>
</div>
);
}
@ -230,7 +230,7 @@ const QuestionnaireBuilder: React.FC = () => {
</div>
<div className="flex flex-wrap items-center gap-4 w-full md:w-auto">
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${totalWeight === 100 ? 'bg-green-50 border-green-200 text-green-700' : 'bg-amber-50 border-amber-200 text-amber-700'}`}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${totalWeight === 100 ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-re-red-hover'}`}>
{totalWeight !== 100 && <AlertCircle size={16} />}
<span className="text-sm font-semibold">Total Score: {totalWeight}/100</span>
</div>
@ -240,13 +240,13 @@ const QuestionnaireBuilder: React.FC = () => {
type="text"
value={version}
onChange={(e) => setVersion(e.target.value)}
className="border border-slate-300 p-2 rounded-lg w-full md:w-48 text-sm focus:ring-2 focus:ring-amber-500 outline-none"
className="border border-slate-300 p-2 rounded-lg w-full md:w-48 text-sm focus:ring-2 focus:ring-red-500 outline-none"
placeholder="Version Name (e.g. v2.0)"
/>
<button
onClick={handleSave}
disabled={loading}
className="bg-amber-600 text-white px-6 py-2 rounded-lg flex items-center gap-2 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed transition shadow-sm font-medium whitespace-nowrap"
className="bg-re-red text-white px-6 py-2 rounded-lg flex items-center gap-2 hover:bg-re-red-hover disabled:bg-slate-300 disabled:cursor-not-allowed transition shadow-sm font-medium whitespace-nowrap"
>
<Save size={18} /> {loading ? 'Saving...' : 'Publish'}
</button>
@ -271,7 +271,7 @@ const QuestionnaireBuilder: React.FC = () => {
type="text"
value={q.questionText}
onChange={(e) => updateQuestion(index, 'questionText', e.target.value)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none transition-shadow"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none transition-shadow"
placeholder="Enter your question here..."
/>
</div>
@ -281,7 +281,7 @@ const QuestionnaireBuilder: React.FC = () => {
<select
value={q.sectionName}
onChange={(e) => updateQuestion(index, 'sectionName', e.target.value)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none bg-white"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none bg-white"
>
{SECTIONS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
@ -292,7 +292,7 @@ const QuestionnaireBuilder: React.FC = () => {
<select
value={q.inputType}
onChange={(e) => updateQuestion(index, 'inputType', e.target.value as any)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none bg-white"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none bg-white"
>
<option value="text">Text Input</option>
<option value="email">Email Address</option>
@ -313,17 +313,17 @@ const QuestionnaireBuilder: React.FC = () => {
type="number"
value={isNaN(q.weight) ? 0 : q.weight}
onChange={(e) => updateQuestion(index, 'weight', parseFloat(e.target.value) || 0)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none pl-3 pr-8"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none pl-3 pr-8"
title="Weightage"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs font-bold">%</span>
</div>
</div>
{/* <div className="flex items-center gap-2 px-3 py-2.5 bg-white border border-slate-200 rounded-lg cursor-pointer hover:border-amber-300 transition-colors"
{/* <div className="flex items-center gap-2 px-3 py-2.5 bg-white border border-slate-200 rounded-lg cursor-pointer hover:border-red-300 transition-colors"
onClick={() => updateQuestion(index, 'isMandatory', !q.isMandatory)}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${q.isMandatory ? 'bg-amber-600 border-amber-600' : 'border-slate-300'}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${q.isMandatory ? 'bg-re-red border-re-red' : 'border-slate-300'}`}>
{q.isMandatory && <div className="w-2 h-2 bg-white rounded-sm" />}
</div>
<span className="text-xs font-medium text-slate-600 select-none">Req.</span>
@ -345,7 +345,7 @@ const QuestionnaireBuilder: React.FC = () => {
type="text"
value={opt.text}
onChange={(e) => updateOption(index, optIndex, 'text', e.target.value)}
className="flex-1 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none"
className="flex-1 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-red-500 outline-none"
placeholder={`Option ${optIndex + 1}`}
/>
<div className="flex items-center gap-2">
@ -356,7 +356,7 @@ const QuestionnaireBuilder: React.FC = () => {
max={isNaN(q.weight) ? 0 : q.weight}
min={0}
onChange={(e) => updateOption(index, optIndex, 'score', e.target.value)}
className={`w-20 border ${(opt.score > q.weight) || isNaN(opt.score) ? 'border-red-500 text-red-600' : 'border-slate-300'} p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none`}
className={`w-20 border ${(opt.score > q.weight) || isNaN(opt.score) ? 'border-red-500 text-red-600' : 'border-slate-300'} p-2 rounded-md text-sm focus:ring-1 focus:ring-red-500 outline-none`}
/>
</div>
<button
@ -371,7 +371,7 @@ const QuestionnaireBuilder: React.FC = () => {
</div>
<button
onClick={() => addOption(index)}
className="mt-3 text-sm flex items-center gap-1 text-amber-600 hover:text-amber-700 font-medium"
className="mt-3 text-sm flex items-center gap-1 text-re-red hover:text-re-red-hover font-medium"
>
<Plus size={16} /> Add Option
</button>
@ -392,7 +392,7 @@ const QuestionnaireBuilder: React.FC = () => {
<button
onClick={addQuestion}
className="mt-8 w-full border-2 border-dashed border-slate-300 p-4 rounded-xl text-slate-500 hover:border-amber-500 hover:text-amber-600 hover:bg-amber-50/30 flex justify-center items-center gap-2 transition-all font-medium"
className="mt-8 w-full border-2 border-dashed border-slate-300 p-4 rounded-xl text-slate-500 hover:border-red-500 hover:text-re-red hover:bg-red-50/30 flex justify-center items-center gap-2 transition-all font-medium"
>
<Plus size={20} /> Add Another Question
</button>

View File

@ -47,7 +47,7 @@ const QuestionnaireList: React.FC = () => {
</div>
<button
onClick={() => navigate('/questionnaire-builder')}
className="bg-amber-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-amber-700 transition"
className="bg-re-red text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-re-red-hover transition"
>
<Plus size={20} /> Create New Version
</button>
@ -55,14 +55,14 @@ const QuestionnaireList: React.FC = () => {
{loading ? (
<div className="flex justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-re-red"></div>
</div>
) : versions.length === 0 ? (
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-slate-200">
<p className="text-slate-500 mb-4">No questionnaire versions found.</p>
<button
onClick={() => navigate('/questionnaire-builder')}
className="text-amber-600 font-medium hover:underline"
className="text-re-red font-medium hover:underline"
>
Create your first version
</button>
@ -102,7 +102,7 @@ const QuestionnaireList: React.FC = () => {
<td className="p-4 text-right">
<button
onClick={() => navigate(`/questionnaire-builder/${v.id}`)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium text-slate-600 hover:text-amber-600 hover:bg-amber-50 transition border border-slate-200 hover:border-amber-200"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium text-slate-600 hover:text-re-red hover:bg-red-50 transition border border-slate-200 hover:border-red-200"
>
<Edit2 size={14} /> Edit / Clone
</button>

View File

@ -219,7 +219,7 @@ export function UserManagementPage() {
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Users className="w-6 h-6 text-amber-600" />
<Users className="w-6 h-6 text-re-red" />
User Management
</h1>
<p className="text-slate-500">Manage system users, roles, and access permissions.</p>
@ -232,7 +232,7 @@ export function UserManagementPage() {
zoneId: '', regionId: '', stateId: '', districtId: ''
}); setShowUserModal(true);
}}
className="bg-amber-600 hover:bg-amber-700 text-white shrink-0"
className="bg-re-red hover:bg-re-red-hover text-white shrink-0"
>
<UserPlus className="w-4 h-4 mr-2" />
Add New User
@ -318,7 +318,7 @@ export function UserManagementPage() {
<TableRow key={user.id} className="hover:bg-slate-50/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center font-bold">
<div className="w-10 h-10 rounded-full bg-red-100 text-re-red-hover flex items-center justify-center font-bold">
{user.fullName?.charAt(0) || user.email?.charAt(0)}
</div>
<div>
@ -384,7 +384,7 @@ export function UserManagementPage() {
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => handleEditUser(user)}>
<Edit2 className="w-4 h-4 text-slate-400 hover:text-amber-600" />
<Edit2 className="w-4 h-4 text-slate-400 hover:text-re-red" />
</Button>
<Button variant="ghost" size="icon">
<Trash2 className="w-4 h-4 text-slate-400 hover:text-red-600" />
@ -569,7 +569,7 @@ export function UserManagementPage() {
<DialogFooter className="bg-slate-50 -mx-6 -mb-6 p-4 rounded-b-lg">
<Button variant="outline" onClick={() => setShowUserModal(false)}>Cancel</Button>
<Button className="bg-amber-600 hover:bg-amber-700 text-white" onClick={handleSubmit}>
<Button className="bg-re-red hover:bg-re-red-hover text-white" onClick={handleSubmit}>
{editingUser ? 'Save Changes' : 'Create User'}
</Button>
</DialogFooter>

View File

@ -47,12 +47,16 @@ export function Sidebar({ onLogout }: SidebarProps) {
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => roles.map((r) => r.toLowerCase()).includes(normalizedRole);
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();
const userRoleCode = String(currentUser?.roleCode || '').toLowerCase();
return normalizedTargetRoles.includes(userRole) || normalizedTargetRoles.includes(userRoleCode);
};
const resignationRoles = ['DD Admin', 'ASM', 'RBM', 'DD Lead', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'Super Admin'];
const terminationRoles = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Legal', 'DD Admin', 'CCO', 'CEO', 'Super Admin'];
const fnfRoles = ['DD Admin', 'DD Lead', 'NBH', 'Finance', 'Finance Admin', 'Super Admin'];
const resignationRoles = ['DD Admin', 'DD_ADMIN', 'ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'LEGAL_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const terminationRoles = ['ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'ZBH', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Legal Admin', 'LEGAL_ADMIN', 'Legal', 'DD Admin', 'DD_ADMIN', 'CCO', 'CEO', 'Super Admin', 'SUPER_ADMIN'];
const fnfRoles = ['DD Admin', 'DD_ADMIN', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Finance', 'Finance Admin', 'FINANCE_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const canSeeResignation = hasRole(resignationRoles);
const canSeeTermination = hasRole(terminationRoles);
const canSeeFnF = hasRole(fnfRoles);

View File

@ -65,6 +65,20 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
}
};
const isEmailValid = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const isMobileValid = (mobile: string) => /^[0-9]{10}$/.test(mobile);
const isPincodeValid = (pincode: string) => /^[0-9]{6}$/.test(pincode);
const isFormValid = Boolean(
formData.country && formData.stateId && formData.districtId && formData.name &&
formData.interestedCity && formData.email && formData.pincode && formData.mobile &&
formData.ownRoyalEnfield && formData.age && formData.education &&
formData.companyName && formData.source && formData.existingDealer &&
formData.description && formData.address && formData.acceptTerms &&
otpVerified && isEmailValid(formData.email) && isMobileValid(formData.mobile) && isPincodeValid(formData.pincode) &&
(formData.ownRoyalEnfield === 'no' || (formData.ownRoyalEnfield === 'yes' && formData.royalEnfieldModel))
);
const handleVerifyMobile = () => {
if (!formData.mobile || formData.mobile.length < 10) {
toast.error('Please enter a valid mobile number');
@ -93,6 +107,11 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
return;
}
if (formData.ownRoyalEnfield === 'yes' && !formData.royalEnfieldModel) {
toast.error('Please select your motorcycle model');
return;
}
if (!formData.acceptTerms) {
toast.error('Please accept the terms and conditions');
return;
@ -145,10 +164,10 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
};
const reModels = [
"Classic 650", "Scram 440", "Goan Classic 350", "Bear 650", "Guerrilla 450",
"Shotgun 650", "Himalayan 450", "Bullet 350", "Super Meteor 650", "Hunter 350",
"Scram 411", "Meteor 350", "Interceptor INT 650", "Continental GT 650",
"Classic 350", "Other Royal Enfield motorcycle"
"Continental GT", "Interceptor 650", "Himalayan", "Classic 350",
"Classic 500", "Thunderbird 350", "Thunderbird 500", "Thunderbird X 350",
"Thunderbird X 500", "Bullet 350", "Bullet 500", "Bullet ES",
"Bullet Trials 350", "Bullet Trials 500", "Other Royal Enfield motorcycle"
];
const sourceOptions = [
@ -257,24 +276,35 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
onChange={(e) => setFormData({...formData, interestedCity: e.target.value})}
/>
<Input
type="email"
placeholder="Email Id*"
className="h-[44px] border-[#cccccc] rounded-none px-4 text-[14px] focus-visible:ring-1 focus-visible:ring-black placeholder:text-[#999999]"
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
/>
<Input
type="text"
maxLength={6}
placeholder="Pincode*"
className="h-[44px] border-[#cccccc] rounded-none px-4 text-[14px] focus-visible:ring-1 focus-visible:ring-black placeholder:text-[#999999]"
value={formData.pincode}
onChange={(e) => setFormData({...formData, pincode: e.target.value})}
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
setFormData({...formData, pincode: val});
}}
/>
<div className="relative">
<Input
type="text"
maxLength={10}
placeholder="Mobile No.*"
className="h-[44px] border-[#cccccc] rounded-none px-4 text-[14px] focus-visible:ring-1 focus-visible:ring-black placeholder:text-[#999999]"
value={formData.mobile}
onChange={(e) => setFormData({...formData, mobile: e.target.value})}
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
setFormData({...formData, mobile: val});
}}
/>
{!otpVerified ? (
<button
@ -301,7 +331,13 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
type="radio"
className="hidden"
checked={formData.ownRoyalEnfield === val}
onChange={() => setFormData({...formData, ownRoyalEnfield: val})}
onChange={() => {
setFormData({
...formData,
ownRoyalEnfield: val,
royalEnfieldModel: val === 'no' ? '' : formData.royalEnfieldModel
});
}}
/>
<span className="text-[14px] capitalize">{val}</span>
</label>
@ -316,18 +352,19 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
onChange={(e) => setFormData({...formData, age: e.target.value})}
/>
<div className="relative">
<select
className="w-full h-[44px] px-4 border border-[#cccccc] appearance-none bg-white text-[14px] outline-none disabled:bg-slate-50"
value={formData.royalEnfieldModel}
disabled={formData.ownRoyalEnfield !== 'yes'}
onChange={(e) => setFormData({...formData, royalEnfieldModel: e.target.value})}
>
<option value="">Motorcycle Owned</option>
{reModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
</div>
{formData.ownRoyalEnfield === 'yes' && (
<div className="relative">
<select
className="w-full h-[44px] px-4 border border-[#cccccc] appearance-none bg-white text-[14px] outline-none"
value={formData.royalEnfieldModel}
onChange={(e) => setFormData({...formData, royalEnfieldModel: e.target.value})}
>
<option value="">Select Motorcycle*</option>
{reModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
</div>
)}
<Input
placeholder="Education Qualification*"
@ -406,14 +443,15 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
onCheckedChange={(checked) => setFormData({...formData, acceptTerms: checked as boolean})}
/>
<label htmlFor="terms" className="text-[14px] font-medium cursor-pointer">
I accept the <b>terms and conditions</b> as well as <b>privacy policy</b>.
I accept the <b>terms and conditions</b> as well as <b>privacy policy</b>.<span className="text-red-500">*</span>
</label>
</div>
</div>
<button
type="submit"
className="h-12 px-10 bg-black text-white flex items-center gap-3 hover:bg-slate-900 transition-colors"
disabled={!isFormValid}
className="h-12 px-10 bg-black text-white flex items-center gap-3 hover:bg-slate-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="font-bold uppercase tracking-wider text-[14px]">Submit</span>
<ChevronRight className="w-4 h-4" />

View File

@ -1,6 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ConstitutionalChangePage } from "../pages/ConstitutionalChangePage"
import { toast } from "sonner"
jest.mock("sonner", () => ({
toast: {
@ -138,4 +139,54 @@ describe("ConstitutionalChangePage", () => {
expect(screen.getByText(/^Dealer \*$/i)).toBeInTheDocument()
})
it("shows backend duplicate-open message on create conflict", async () => {
const user = userEvent.setup()
const { API } = await import("@/api/API")
;(API.getDealers as jest.Mock).mockResolvedValueOnce({
data: {
success: true,
data: [
{
user: { id: "dealer-user-1" },
constitutionType: "Proprietorship",
businessName: "Dealer A",
legalName: "Dealer A Pvt",
dealerCode: { dealerCode: "DLR-1" },
},
],
},
})
;(API.createConstitutionalChange as jest.Mock).mockRejectedValueOnce({
response: {
data: {
message:
"Open constitutional request CCR-1 already exists at ASM Review. Complete it before creating a new one.",
},
},
})
setup()
await user.click(screen.getByRole("button", { name: /new request/i }))
await screen.findByRole("heading", {
name: /create constitutional change request/i,
})
await user.click(screen.getByRole("combobox", { name: /dealer/i }))
await user.click(await screen.findByText(/DLR-1 — Dealer A/i))
await user.click(screen.getByRole("combobox", { name: /proposed constitution/i }))
await user.click(await screen.findByText(/^Partnership$/i))
const reasonField = screen.getByLabelText(/reason for constitutional change/i)
await user.type(reasonField, "Need to onboard new partner")
await user.click(screen.getByRole("button", { name: /submit request/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringContaining("Open constitutional request CCR-1 already exists")
)
})
})
})

View File

@ -44,7 +44,7 @@ const formatStageRole = (role: string) =>
// Document requirements mapping (same as in ConstitutionalChangePage) — SRS §12.2.4 by target constitution
const documentRequirements: Record<string, number[]> = {
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16],
'LLP': [1, 2, 3, 7, 8, 10, 11, 16],
'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Proprietorship': [1, 2, 3, 10, 16]
@ -151,6 +151,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isActionLoading, setIsActionLoading] = useState(false);
/** Set when POST /action returns 4xx (apisauce does not throw — must check response.ok). */
const [actionDialogError, setActionDialogError] = useState<string | null>(null);
const [isUploadingDoc, setIsUploadingDoc] = useState(false);
const [rejectDocDialogOpen, setRejectDocDialogOpen] = useState(false);
const [rejectDocIndex, setRejectDocIndex] = useState<number | null>(null);
@ -415,6 +417,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const handleAction = (type: 'approve' | 'reject' | 'sendBack' | 'revoke') => {
setActionType(type);
setActionDialogError(null);
setIsActionDialogOpen(true);
};
@ -436,6 +439,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
try {
setIsActionLoading(true);
setActionDialogError(null);
const actionPayload =
actionType === 'approve'
? OFFBOARDING_ACTIONS.APPROVE
@ -448,7 +452,9 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
comments
}) as any;
if (response.data.success) {
const payload = response?.data as { success?: boolean; message?: string } | undefined;
/** apisauce returns { ok: false } on 4xx without throwing — must branch on this. */
if (response?.ok && payload?.success) {
const actionText =
actionType === 'approve' ? 'approved' :
actionType === 'reject' ? 'rejected' :
@ -457,12 +463,26 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
toast.success(`Request ${actionText} successfully`);
setIsActionDialogOpen(false);
setComments('');
setActionDialogError(null);
await fetchRequestDetails();
return;
}
const message =
payload?.message ||
(response as any)?.data?.error ||
'Failed to submit action';
setActionDialogError(message);
const docGate = /mandatory documents/i.test(message);
toast.error(message, { duration: docGate ? 14000 : 8000 });
} catch (error) {
console.error('Submit action error:', error);
const message = (error as any)?.response?.data?.message || 'Failed to submit action';
toast.error(message);
const message =
(error as any)?.response?.data?.message ||
(error as any)?.message ||
'Failed to submit action';
setActionDialogError(message);
toast.error(message, { duration: /mandatory documents/i.test(message) ? 14000 : 8000 });
} finally {
setIsActionLoading(false);
}
@ -1261,7 +1281,13 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</div>
{/* Action Dialog */}
<Dialog open={isActionDialogOpen} onOpenChange={setIsActionDialogOpen}>
<Dialog
open={isActionDialogOpen}
onOpenChange={(open) => {
setIsActionDialogOpen(open);
if (!open) setActionDialogError(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
@ -1278,6 +1304,24 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</DialogHeader>
<form onSubmit={handleSubmitAction} className="space-y-4">
{actionDialogError && (
<div
role="alert"
className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-900 flex gap-2"
>
<AlertCircle className="w-5 h-5 shrink-0 text-red-600" aria-hidden />
<div className="min-w-0">
<p className="font-medium">This action was not completed</p>
<p className="mt-1 whitespace-pre-wrap break-words">{actionDialogError}</p>
{/mandatory documents/i.test(actionDialogError) && (
<p className="mt-2 text-red-800">
Use the <strong>Documents</strong> tab to upload every required file for this constitution
type, then approve again.
</p>
)}
</div>
</div>
)}
<div>
<Label htmlFor="comments">
{actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks (required) *' : 'Comments *'}

View File

@ -32,7 +32,7 @@ interface ConstitutionalChangePageProps {
// Document requirements mapping (keys = DB `changeType` ENUM values)
const documentRequirements: Record<string, number[]> = {
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 10, 11, 16],
'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Proprietorship': [1, 2, 3, 10, 16]
};
@ -73,10 +73,8 @@ const getTypeColor = (type: string) => {
case 'Proprietorship': return 'bg-purple-100 text-purple-700 border-purple-300';
case 'Partnership': return 'bg-blue-100 text-blue-700 border-blue-300';
case 'LLP':
case 'LLP Conversion':
return 'bg-indigo-100 text-indigo-700 border-indigo-300';
case 'Private Limited':
case 'Pvt Ltd':
return 'bg-cyan-100 text-cyan-700 border-cyan-300';
default: return 'bg-slate-100 text-slate-700 border-slate-300';
}

View File

@ -270,12 +270,13 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
<ul className="text-blue-800 text-sm space-y-1">
<li> GST Registration Certificate</li>
<li> Firm PAN Copy</li>
<li> Partnership Deed (if applicable)</li>
<li> LLP Agreement (if applicable)</li>
<li> Certificate of Incorporation (if applicable)</li>
<li> MOA & AOA (if applicable)</li>
<li> Board Resolution</li>
<li> Aadhaar & PAN of all partners/directors</li>
<li> Self-attested KYC documents</li>
<li> Business Purchase Agreement (BPA)</li>
<li> Partnership Agreement / Firm Registration (if target is Partnership)</li>
<li> LLP Agreement / COI (if target is LLP)</li>
<li> MOA, AOA, COI (if target is Private Limited)</li>
<li> Cancelled Cheque</li>
<li> Declaration / Authorization Letter</li>
</ul>
</div>

View File

@ -181,7 +181,7 @@ export const ASMDialog: React.FC<ASMDialogProps> = ({
return (
<div key={state} className="mb-4 last:mb-0">
<h4 className="text-sm text-amber-700 mb-2 pb-1 border-b border-slate-200">{state}</h4>
<h4 className="text-sm text-re-red-hover mb-2 pb-1 border-b border-slate-200">{state}</h4>
<div className="space-y-2 ml-2">
{districts.map((district: any) => (
<div key={district.id}>
@ -280,7 +280,7 @@ export const ASMDialog: React.FC<ASMDialogProps> = ({
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save DD-AM</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save DD-AM</Button>
</div>
</div>
</DialogContent>

View File

@ -41,7 +41,7 @@ export const ASMManagement: React.FC<ASMManagementProps> = ({
<CardTitle>District Development Area Managers (DD-AM)</CardTitle>
<CardDescription>Manage DD-AM users across districts (multi-district)</CardDescription>
</div>
<Button onClick={onAddASM} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddASM} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add DD-AM
</Button>
@ -86,7 +86,7 @@ export const ASMManagement: React.FC<ASMManagementProps> = ({
<Badge
key={idx}
variant={isShared ? "outline" : "secondary"}
className={`text-xs ${isShared ? "border-amber-300 bg-amber-50 text-amber-700 font-medium" : ""}`}
className={`text-xs ${isShared ? "border-red-300 bg-red-50 text-re-red-hover font-medium" : ""}`}
title={isShared ? `Also managed by: ${otherManagers.join(', ')}` : undefined}
>
{areaName}
@ -112,7 +112,7 @@ export const ASMManagement: React.FC<ASMManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditASM(asm)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteASM(asm.id, asm.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteASM(asm.id, asm.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -83,7 +83,7 @@ export const AutoAssignmentSettings: React.FC = () => {
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-12 space-y-4">
<RefreshCcw className="w-8 h-8 animate-spin text-amber-600" />
<RefreshCcw className="w-8 h-8 animate-spin text-re-red" />
<p className="text-slate-500 font-medium">Loading governance controls...</p>
</div>
);
@ -94,8 +94,8 @@ export const AutoAssignmentSettings: React.FC = () => {
<Card className="border-none shadow-md overflow-hidden bg-white/50 backdrop-blur-sm">
<CardHeader className="bg-gradient-to-r from-slate-900 to-slate-800 text-white p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-500/20 rounded-lg">
<Settings2 className="w-6 h-6 text-amber-400" />
<div className="p-2 bg-red-500/20 rounded-lg">
<Settings2 className="w-6 h-6 text-red-400" />
</div>
<div>
<CardTitle className="text-xl font-bold">Auto-Assignment Governance</CardTitle>
@ -148,6 +148,7 @@ export const AutoAssignmentSettings: React.FC = () => {
<div className="flex items-center gap-4">
<Switch
className="data-[state=checked]:bg-re-red"
checked={isEnabled}
onCheckedChange={(val) => {
handleToggle(mod.key, val);
@ -164,9 +165,9 @@ export const AutoAssignmentSettings: React.FC = () => {
})}
</div>
<div className="mt-8 p-4 bg-amber-50 border border-amber-100 rounded-lg flex items-start gap-3">
<Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<div className="mt-8 p-4 bg-red-50 border border-red-100 rounded-lg flex items-start gap-3">
<Info className="w-5 h-5 text-re-red shrink-0 mt-0.5" />
<div className="text-sm text-red-800">
<p className="font-semibold mb-1">Impact of Manual Mode:</p>
<p>Turning OFF auto-assignment will ONLY affect new requests. Existing requests will retain their current participant mappings. You will need to use the "Add Participant" button in the worknotes or application details to grant access to stakeholders.</p>
</div>

View File

@ -127,7 +127,7 @@ export const DDLeadDialog: React.FC<DDLeadDialogProps> = ({
<div className="flex gap-3 pt-6">
<Button variant="outline" className="flex-1 border-slate-200" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700 shadow-sm" onClick={onSave}>Save DD Lead</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover shadow-sm" onClick={onSave}>Save DD Lead</Button>
</div>
</div>
</DialogContent>

View File

@ -36,7 +36,7 @@ export const DDLeadManagement: React.FC<DDLeadManagementProps> = ({
<CardTitle>DD-Leads (Dealer Development Lead)</CardTitle>
<CardDescription>Manage DD-Leads and their zonal assignments</CardDescription>
</div>
<Button onClick={onAddLead} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddLead} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add DD-Lead
</Button>
@ -60,7 +60,7 @@ export const DDLeadManagement: React.FC<DDLeadManagementProps> = ({
<TableRow key={lead.id}>
<TableCell>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-amber-600" />
<Users className="w-4 h-4 text-re-red" />
<span className="font-medium">{lead.leadCode || 'N/A'}</span>
</div>
</TableCell>
@ -95,7 +95,7 @@ export const DDLeadManagement: React.FC<DDLeadManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditLead(lead)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteLead(lead.id, lead.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteLead(lead.id, lead.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -193,7 +193,10 @@ export const DealerAsmAssignment: React.FC = () => {
</TableCell>
<TableCell>{dealer.dealerCode || 'N/A'}</TableCell>
<TableCell>
<Badge variant={String(dealer.status || '').toLowerCase() === 'active' ? 'default' : 'secondary'}>
<Badge
variant={String(dealer.status || '').toLowerCase() === 'active' ? 'default' : 'secondary'}
className={String(dealer.status || '').toLowerCase() === 'active' ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : ''}
>
{dealer.status || 'Unknown'}
</Badge>
</TableCell>
@ -215,7 +218,7 @@ export const DealerAsmAssignment: React.FC = () => {
onChange={(val) => setDraft((prev) => ({ ...prev, [dealer.dealerId]: val }))}
className="flex-1 min-w-[180px]"
/>
<Button size="sm" className="shrink-0" onClick={() => saveMapping(dealer.dealerId)}>
<Button size="sm" className="shrink-0 bg-re-red hover:bg-re-red-hover text-white" onClick={() => saveMapping(dealer.dealerId)}>
Assign
</Button>
</div>

View File

@ -179,7 +179,7 @@ export const DocumentConfigManagement: React.FC = () => {
if (metadataLoading) {
return (
<div className="h-96 flex flex-col items-center justify-center gap-4">
<Database className="w-10 h-10 text-amber-600 animate-bounce" />
<Database className="w-10 h-10 text-re-red animate-bounce" />
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs">Connecting to Governance Engine...</p>
</div>
);
@ -189,8 +189,8 @@ export const DocumentConfigManagement: React.FC = () => {
<Card className="border-slate-200 shadow-sm overflow-hidden bg-white">
<CardHeader className="bg-slate-50/80 border-b border-slate-200 py-4 relative">
{backgroundLoading && (
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-100 overflow-hidden">
<div className="h-full bg-amber-600 animate-[loading_1.5s_infinite_linear]" style={{width: '30%', transformOrigin: 'left'}} />
<div className="absolute top-0 left-0 right-0 h-1 bg-red-100 overflow-hidden">
<div className="h-full bg-re-red animate-[loading_1.5s_infinite_linear]" style={{width: '30%', transformOrigin: 'left'}} />
<style>{`
@keyframes loading {
0% { left: -30%; }
@ -202,7 +202,7 @@ export const DocumentConfigManagement: React.FC = () => {
<div className="flex flex-row items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-xl shadow-sm ring-1 ring-slate-200 text-amber-600">
<div className="p-2 bg-white rounded-xl shadow-sm ring-1 ring-slate-200 text-re-red">
<Layers className="w-5 h-5" />
</div>
<div>
@ -218,7 +218,7 @@ export const DocumentConfigManagement: React.FC = () => {
<div className="flex gap-4">
<div className="w-64">
<Select value={selectedModule} onValueChange={(val) => { setSelectedModule(val); setPage(1); }}>
<SelectTrigger className="h-10 rounded-xl bg-white border-slate-200 focus:ring-amber-500 font-bold text-slate-700 shadow-sm">
<SelectTrigger className="h-10 rounded-xl bg-white border-slate-200 focus:ring-red-500 font-bold text-slate-700 shadow-sm">
<SelectValue placeholder="Target Module" />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-2xl border-none">
@ -236,7 +236,7 @@ export const DocumentConfigManagement: React.FC = () => {
placeholder="Search policies, stages or documents..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-10 h-10 rounded-xl bg-white border-slate-200 focus:ring-amber-500 shadow-sm font-medium"
className="pl-10 h-10 rounded-xl bg-white border-slate-200 focus:ring-red-500 shadow-sm font-medium"
/>
</div>
</div>
@ -244,7 +244,7 @@ export const DocumentConfigManagement: React.FC = () => {
<CardContent className="p-0 min-h-[400px] relative">
{loading ? (
<div className="absolute inset-0 z-10 bg-white/60 backdrop-blur-[1px] flex flex-col items-center justify-center gap-3">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
<span className="text-slate-500 text-sm font-bold animate-pulse">Syncing Policies...</span>
</div>
) : null}
@ -274,7 +274,7 @@ export const DocumentConfigManagement: React.FC = () => {
) : configs.map((config) => (
<TableRow key={config.id} className="hover:bg-slate-50/80 transition-colors group h-14">
<TableCell>
<div className="font-bold text-slate-900 group-hover:text-amber-700 transition-colors uppercase text-[12px]">{config.documentType}</div>
<div className="font-bold text-slate-900 group-hover:text-re-red-hover transition-colors uppercase text-[12px]">{config.documentType}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50/50 border-blue-100 text-blue-700 font-bold px-2 py-0.5 whitespace-nowrap text-[10px] rounded-md uppercase">
@ -305,7 +305,7 @@ export const DocumentConfigManagement: React.FC = () => {
<TableCell>
<div className="flex gap-2">
{config.isMandatory && (
<Badge className="bg-red-600 text-white border-transparent text-[10px] font-bold h-5 px-1.5 rounded-sm uppercase tracking-tighter">BLOCKING</Badge>
<Badge className="bg-re-red text-white border-transparent text-[10px] font-bold h-5 px-1.5 rounded-sm uppercase tracking-tighter">BLOCKING</Badge>
)}
{!config.isActive && (
<Badge className="bg-slate-200 text-slate-500 border-transparent text-[10px] h-5 px-1.5 rounded-sm uppercase tracking-tighter">DORMANT</Badge>
@ -320,7 +320,7 @@ export const DocumentConfigManagement: React.FC = () => {
<Button variant="ghost" size="icon" onClick={() => openEdit(config)} className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-transform active:scale-90">
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(config.id)} className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-transform active:scale-90">
<Button variant="ghost" size="icon" onClick={() => handleDelete(config.id)} className="h-8 w-8 text-slate-400 hover:text-re-red hover:bg-red-50 rounded-lg transition-transform active:scale-90">
<Trash2 className="w-4 h-4" />
</Button>
</div>
@ -333,7 +333,7 @@ export const DocumentConfigManagement: React.FC = () => {
{/* Pagination Controls */}
<div className="flex items-center justify-between px-6 py-4 bg-slate-50/50 border-t border-slate-200 mt-auto">
<div className="text-[11px] text-slate-500 font-bold uppercase tracking-tight">
Dataset Index <span className="text-slate-900 border-b border-slate-300 mx-1">{configs.length > 0 ? (page - 1) * limit + 1 : 0} - {Math.min(page * limit, pagination.total)}</span> Total Found <span className="text-amber-700 font-extrabold ml-1">{pagination.total}</span>
Dataset Index <span className="text-slate-900 border-b border-slate-300 mx-1">{configs.length > 0 ? (page - 1) * limit + 1 : 0} - {Math.min(page * limit, pagination.total)}</span> Total Found <span className="text-re-red-hover font-extrabold ml-1">{pagination.total}</span>
</div>
<div className="flex gap-3 items-center">
<Button
@ -346,7 +346,7 @@ export const DocumentConfigManagement: React.FC = () => {
<ChevronLeft className="w-4 h-4 mr-1 text-slate-600" /> Prev
</Button>
<div className="flex items-center px-4 h-9 bg-white border border-slate-200 rounded-xl text-xs font-extrabold text-slate-800 shadow-inner">
<span className="text-amber-600">{page}</span> <span className="mx-2 text-slate-300">/</span> {pagination.pages}
<span className="text-re-red">{page}</span> <span className="mx-2 text-slate-300">/</span> {pagination.pages}
</div>
<Button
variant="outline"
@ -366,7 +366,7 @@ export const DocumentConfigManagement: React.FC = () => {
<DialogHeader className="bg-slate-900 text-white p-7">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/10 rounded-2xl backdrop-blur-md ring-1 ring-white/20">
<Settings2 className="w-7 h-7 text-amber-400" />
<Settings2 className="w-7 h-7 text-red-400" />
</div>
<div>
<DialogTitle className="text-2xl font-black tracking-tight uppercase">
@ -385,12 +385,12 @@ export const DocumentConfigManagement: React.FC = () => {
value={formData.module}
onValueChange={(val) => setFormData(prev => ({ ...prev, module: val, stageCode: 'General' }))}
>
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-amber-500 shadow-sm bg-slate-50 font-black text-xs uppercase">
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-red-500 shadow-sm bg-slate-50 font-black text-xs uppercase">
<SelectValue placeholder="Module" />
</SelectTrigger>
<SelectContent className="rounded-xl border-none shadow-2xl">
{modules.map(m => (
<SelectItem key={m} value={m} className="py-3 px-4 rounded-lg focus:bg-amber-50 font-black text-[10px] uppercase">
<SelectItem key={m} value={m} className="py-3 px-4 rounded-lg focus:bg-red-50 font-black text-[10px] uppercase">
{m.replace(/_/g, ' ')}
</SelectItem>
))}
@ -403,12 +403,12 @@ export const DocumentConfigManagement: React.FC = () => {
value={formData.stageCode}
onValueChange={(val) => setFormData(prev => ({ ...prev, stageCode: val }))}
>
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-amber-500 shadow-sm bg-white font-black text-xs uppercase">
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-red-500 shadow-sm bg-white font-black text-xs uppercase">
<SelectValue placeholder="Select Stage" />
</SelectTrigger>
<SelectContent className="rounded-xl border-none shadow-2xl">
{(stagesMap[formData.module] || ['General']).map(stage => (
<SelectItem key={stage} value={stage} className="py-3 px-4 rounded-lg focus:bg-amber-50 font-black text-[10px] uppercase">{stage}</SelectItem>
<SelectItem key={stage} value={stage} className="py-3 px-4 rounded-lg focus:bg-red-50 font-black text-[10px] uppercase">{stage}</SelectItem>
))}
</SelectContent>
</Select>
@ -421,28 +421,28 @@ export const DocumentConfigManagement: React.FC = () => {
value={formData.documentType}
onChange={(e) => setFormData(prev => ({ ...prev, documentType: e.target.value }))}
placeholder="e.g., PAN Card, Blueprint"
className="h-12 rounded-xl border-slate-200 focus:ring-amber-500 shadow-sm font-black text-sm uppercase placeholder:font-bold placeholder:text-slate-300"
className="h-12 rounded-xl border-slate-200 focus:ring-red-500 shadow-sm font-black text-sm uppercase placeholder:font-bold placeholder:text-slate-300"
/>
</div>
<div className="space-y-4 p-5 bg-slate-50 rounded-2xl border border-slate-100 shadow-inner">
<Label className="text-slate-900 font-black flex items-center gap-2 mb-2 text-[11px] uppercase tracking-wider">
<ShieldCheck className="w-4 h-4 text-amber-600" /> Visibility Matrix
<ShieldCheck className="w-4 h-4 text-re-red" /> Visibility Matrix
</Label>
<div className="grid grid-cols-3 gap-3">
{ROLE_LIST.map((role: string) => (
<div
key={role}
className={`flex items-center space-x-2 p-3 rounded-xl border transition-all cursor-pointer group active:scale-95 ${formData.allowedRoles.includes(role) ? 'bg-amber-50 border-amber-300 shadow-sm' : 'bg-white border-slate-200 hover:border-amber-200 hover:shadow-sm'}`}
className={`flex items-center space-x-2 p-3 rounded-xl border transition-all cursor-pointer group active:scale-95 ${formData.allowedRoles.includes(role) ? 'bg-red-50 border-red-300 shadow-sm' : 'bg-white border-slate-200 hover:border-red-200 hover:shadow-sm'}`}
onClick={() => toggleRole(role)}
>
<Checkbox
id={`role-${role}`}
checked={formData.allowedRoles.includes(role)}
onCheckedChange={() => toggleRole(role)}
className="w-4 h-4 data-[state=checked]:bg-amber-600 data-[state=checked]:border-amber-600 rounded"
className="w-4 h-4 data-[state=checked]:bg-re-red data-[state=checked]:border-re-red rounded"
/>
<Label htmlFor={`role-${role}`} className={`text-[10px] font-black cursor-pointer uppercase truncate ${formData.allowedRoles.includes(role) ? 'text-amber-800' : 'text-slate-500 group-hover:text-amber-700'}`}>{role}</Label>
<Label htmlFor={`role-${role}`} className={`text-[10px] font-black cursor-pointer uppercase truncate ${formData.allowedRoles.includes(role) ? 'text-red-800' : 'text-slate-500 group-hover:text-re-red-hover'}`}>{role}</Label>
</div>
))}
</div>
@ -457,7 +457,7 @@ export const DocumentConfigManagement: React.FC = () => {
id="mandatory"
checked={formData.isMandatory}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isMandatory: !!checked }))}
className="w-5 h-5 border-slate-300 data-[state=checked]:bg-red-600 data-[state=checked]:border-red-600 rounded-md"
className="w-5 h-5 border-slate-300 data-[state=checked]:bg-re-red data-[state=checked]:border-re-red rounded-md"
/>
<div className="space-y-0.5">
<Label htmlFor="mandatory" className="text-xs font-black text-slate-800 cursor-pointer group-hover:text-red-900 transition-colors uppercase tracking-tight">Mandatory Policy</Label>

View File

@ -89,9 +89,9 @@ export const EmailTemplateBodyEditor = React.forwardRef<EmailTemplateBodyEditorH
</TabsList>
{advanced && (
<Alert className="border-amber-200 bg-amber-50 py-2">
<Info className="h-4 w-4 text-amber-700" />
<AlertDescription className="text-[11px] text-amber-900">
<Alert className="border-red-200 bg-red-50 py-2">
<Info className="h-4 w-4 text-re-red-hover" />
<AlertDescription className="text-[11px] text-red-900">
This template uses layout partials (<code className="font-mono">{'{{> ...}}'}</code>), block helpers, or a full HTML
document. Edit it in <strong>HTML source</strong> so nothing is stripped. Use placeholders on the left to insert
fields safely.

View File

@ -45,8 +45,8 @@ export const EmailTemplates: React.FC<EmailTemplatesProps> = ({
<TableRow key={template.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-amber-50 rounded-lg flex items-center justify-center">
<Mail className="w-4 h-4 text-amber-600" />
<div className="w-8 h-8 bg-red-50 rounded-lg flex items-center justify-center">
<Mail className="w-4 h-4 text-re-red" />
</div>
<span className="font-medium text-slate-900">{template.name || template.templateCode}</span>
</div>
@ -68,7 +68,7 @@ export const EmailTemplates: React.FC<EmailTemplatesProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditTemplate(template)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteTemplate(template.id)} className="text-red-500 hover:text-red-600 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteTemplate(template.id)} className="text-red-500 hover:text-re-red hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -316,7 +316,7 @@ const InterviewConfigManagement: React.FC = () => {
</div>
</div>
) : (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
No active configuration found. Click "Publish New Version" to create one, or "Reset to Defaults" to initialize system defaults.
</div>
)}
@ -338,7 +338,7 @@ const InterviewConfigManagement: React.FC = () => {
<Button variant="ghost" size="sm" onClick={() => cfg.id && handleEdit(cfg.id)}>
<Edit3 size={14} />
</Button>
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700" onClick={() => cfg.id && handleDelete(cfg.id)}>
<Button variant="ghost" size="sm" className="text-re-red hover:text-re-red-hover" onClick={() => cfg.id && handleDelete(cfg.id)}>
<Trash2 size={14} />
</Button>
</div>
@ -366,7 +366,7 @@ const InterviewConfigManagement: React.FC = () => {
</DialogDescription>
</div>
{editingConfig?.configType === 'KT_MATRIX' && (
<div className={`px-3 py-1.5 rounded-md text-[10px] font-black uppercase tracking-tight ${totalWeight === 100 ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' : 'bg-amber-50 text-amber-700 border border-amber-100'}`}>
<div className={`px-3 py-1.5 rounded-md text-[10px] font-black uppercase tracking-tight ${totalWeight === 100 ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' : 'bg-red-50 text-re-red-hover border border-red-100'}`}>
Weight: {totalWeight}% / 100%
</div>
)}
@ -535,7 +535,7 @@ const InterviewConfigManagement: React.FC = () => {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between border-b border-slate-200/50 pb-2 mb-2">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<div className="w-1 h-3 bg-amber-400 rounded-full" /> Selection Choices Profile
<div className="w-1 h-3 bg-red-400 rounded-full" /> Selection Choices Profile
</p>
<Button variant="ghost" size="sm" className="h-7 px-3 text-[10px] font-bold uppercase text-slate-600 hover:bg-white border border-transparent hover:border-slate-200" onClick={() => addOption(index)}>
<Plus className="w-3 h-3 mr-1.5" /> Append Option

View File

@ -55,7 +55,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
setLocationDistrict('');
}}
>
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-red-500/30 focus:border-red-500">
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
@ -71,7 +71,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<Label className="flex items-center gap-2 text-sm leading-none font-medium text-slate-700">City</Label>
<Input
placeholder="Enter city name"
className="mt-2 text-slate-900 border-slate-200 focus-visible:ring-amber-500/30 focus-visible:border-amber-500"
className="mt-2 text-slate-900 border-slate-200 focus-visible:ring-red-500/30 focus-visible:border-red-500"
value={locationCity}
onChange={(e) => setLocationCity(e.target.value)}
/>
@ -79,7 +79,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<div>
<Label className="flex items-center gap-2 text-sm leading-none font-medium text-slate-700">District</Label>
<Select value={locationDistrict} onValueChange={setLocationDistrict} disabled={!locationState}>
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-red-500/30 focus:border-red-500">
<SelectValue placeholder={locationState ? 'Select district' : 'Select state first'} />
</SelectTrigger>
<SelectContent>
@ -120,7 +120,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<div>
<Label className="flex items-center gap-2 text-sm leading-none font-medium text-slate-700">Opportunity</Label>
<Select value={locationStatus} onValueChange={setLocationStatus}>
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-red-500/30 focus:border-red-500">
<SelectValue placeholder="Is this an active Opportunity?" />
</SelectTrigger>
<SelectContent>
@ -132,7 +132,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700 text-white shadow-md hover:shadow-lg transition-all" onClick={onSave}>Save Location</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover text-white shadow-md hover:shadow-lg transition-all" onClick={onSave}>Save Location</Button>
</div>
</div>
</DialogContent>

View File

@ -53,7 +53,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
placeholder="Search locations..."
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
className="pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-amber-500 w-64 transition-all"
className="pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-red-500 w-64 transition-all"
/>
</div>
@ -80,7 +80,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
</SelectContent>
</Select>
<Button onClick={onAddLocation} className="bg-amber-600 hover:bg-amber-700 whitespace-nowrap">
<Button onClick={onAddLocation} className="bg-re-red hover:bg-re-red-hover whitespace-nowrap">
<Plus className="w-4 h-4 mr-2" />
Add Location
</Button>
@ -112,7 +112,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
<TableRow key={district.id}>
<TableCell>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-amber-600" />
<MapPin className="w-4 h-4 text-re-red" />
<span className="font-medium">{district.stateName || 'N/A'}</span>
</div>
</TableCell>
@ -151,7 +151,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
<Button variant="outline" size="sm" onClick={() => onEditLocation(district)} className="h-8 w-8 p-0">
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => onDeleteLocation(district.id)} className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700">
<Button variant="outline" size="sm" onClick={() => onDeleteLocation(district.id)} className="h-8 w-8 p-0 text-re-red hover:bg-red-50 hover:text-re-red-hover">
<Trash2 className="w-4 h-4" />
</Button>
</div>
@ -163,7 +163,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
</Table>
{isAreasLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/10 backdrop-blur-[1px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-re-red"></div>
</div>
)}
</div>

View File

@ -189,7 +189,7 @@ export const RegionDialog: React.FC<RegionDialogProps> = ({
<div>
<Label>States Covered</Label>
{!selectedRegionZone && (
<p className="text-xs text-amber-600 mt-1">Select a zone first to see available states</p>
<p className="text-xs text-re-red mt-1">Select a zone first to see available states</p>
)}
<div className="mt-2 border rounded-lg p-3 max-h-40 overflow-y-auto bg-slate-50">
{statesForZone.length === 0 ? (
@ -234,7 +234,7 @@ export const RegionDialog: React.FC<RegionDialogProps> = ({
<TooltipProvider>
{districtsByState.map(({ stateName, districts }) => (
<div key={stateName} className="mb-4 last:mb-0">
<h4 className="text-xs font-semibold text-amber-700 uppercase tracking-wide mb-2 pb-1 border-b border-slate-200">
<h4 className="text-xs font-semibold text-re-red-hover uppercase tracking-wide mb-2 pb-1 border-b border-slate-200">
{stateName}
</h4>
<div className="space-y-2 ml-1">
@ -302,7 +302,7 @@ export const RegionDialog: React.FC<RegionDialogProps> = ({
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save Regional Office</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save Regional Office</Button>
</div>
</div>
</DialogContent>

View File

@ -29,7 +29,7 @@ export const RegionalManagement: React.FC<RegionalManagementProps> = ({
<CardTitle>Regional Offices</CardTitle>
<CardDescription>Manage regional offices within zones</CardDescription>
</div>
<Button onClick={onAddRegion} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddRegion} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add Regional Office
</Button>
@ -114,7 +114,7 @@ export const RegionalManagement: React.FC<RegionalManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditRegion(region)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteRegion(region.id, region.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteRegion(region.id, region.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -79,8 +79,8 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
},
{
title: "Application Stage Access",
color: "from-amber-50 to-orange-50 border-amber-200",
textColor: "text-amber-900",
color: "from-red-50 to-orange-50 border-red-200",
textColor: "text-red-900",
permissions: [
{ id: "stage:initial_review", label: "Initial Review" },
{ id: "stage:field_verification", label: "Field Verification" },
@ -126,7 +126,7 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
<div className="space-y-5">
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-amber-500 rounded-full"></span>
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
Configure Default Permissions
</h4>

View File

@ -22,7 +22,7 @@ export const RolePermissions: React.FC<RolePermissionsProps> = ({ onAddRole, onE
<CardTitle className="leading-none text-xl font-bold">Role Definitions</CardTitle>
<CardDescription className="text-muted-foreground mt-1.5">Overview of available roles and their access levels</CardDescription>
</div>
<Button onClick={onAddRole} className="bg-amber-600 hover:bg-amber-700 h-9">
<Button onClick={onAddRole} className="bg-re-red hover:bg-re-red-hover h-9">
<Plus className="w-4 h-4 mr-2" />
Add Role
</Button>
@ -34,7 +34,7 @@ export const RolePermissions: React.FC<RolePermissionsProps> = ({ onAddRole, onE
<div key={role.id} className="border rounded-lg p-4 space-y-3 bg-gradient-to-br from-white to-slate-50 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-600" />
<Shield className="w-5 h-5 text-re-red" />
<h3 className="text-slate-900 font-bold">{role.name}</h3>
</div>
<Badge variant="secondary" className="border-transparent bg-secondary text-secondary-foreground text-xs font-medium">

View File

@ -29,7 +29,7 @@ export const SLAConfiguration: React.FC<SLAConfigurationProps> = ({ onConfigureS
<div key={sla.id} className="border rounded-lg p-4 bg-gradient-to-br from-white to-slate-50 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-amber-600" />
<Clock className="w-5 h-5 text-re-red" />
<div>
<h4 className="text-slate-900 font-medium">{sla.activityName}</h4>
<p className="text-xs text-slate-600">TAT: {sla.tatHours} {sla.tatUnit}</p>
@ -73,7 +73,7 @@ export const SLAConfiguration: React.FC<SLAConfigurationProps> = ({ onConfigureS
<div className="border-l-2 border-red-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<AlertTriangle className="w-4 h-4 text-re-red" />
<span className="text-xs text-slate-700">Escalations ({sla.escalationConfigs?.length || 0})</span>
</div>
<div className="space-y-1">

View File

@ -184,6 +184,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
<div className="flex items-center space-x-2 pt-8">
<Switch
id="isActive"
className="data-[state=checked]:bg-re-red"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
@ -252,7 +253,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
<div className="space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<AlertTriangle className="w-4 h-4 text-re-red" />
<h4 className="font-medium text-sm">Escalation Levels</h4>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddEscalation} className="h-7 text-xs">
@ -264,7 +265,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
{formData.escalationConfigs.map((esc: any, idx: number) => (
<div key={idx} className="bg-slate-50 p-3 rounded-lg border border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-100">
<Badge variant="outline" className="bg-red-50 text-re-red-hover border-red-100">
Level {esc.level}
</Badge>
<Button
@ -345,7 +346,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
<Button type="submit" disabled={loading} className="bg-re-red hover:bg-re-red-hover text-white">
{loading ? 'Saving...' : 'Save Configuration'}
</Button>
</DialogFooter>

View File

@ -54,7 +54,7 @@ export const SecurityDepositMaster: React.FC = () => {
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-20 space-y-4">
<div className="w-10 h-10 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<div className="w-10 h-10 border-4 border-re-red border-t-transparent rounded-full animate-spin"></div>
<p className="text-slate-600 animate-pulse">Loading settings...</p>
</div>
);
@ -65,8 +65,8 @@ export const SecurityDepositMaster: React.FC = () => {
<Card className="border-none shadow-lg bg-white/80 backdrop-blur-md">
<CardHeader className="py-4 border-b bg-slate-50/50">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center">
<Settings className="w-5 h-5 text-amber-600" />
<div className="w-8 h-8 rounded-lg bg-red-100 flex items-center justify-center">
<Settings className="w-5 h-5 text-re-red" />
</div>
<div>
<CardTitle className="text-lg font-bold text-slate-900">
@ -98,7 +98,7 @@ export const SecurityDepositMaster: React.FC = () => {
<Input
id={`amount-${config.id}`}
type="number"
className="pl-8 h-9 text-base font-bold bg-slate-50/50 border-slate-200 focus:ring-amber-500 focus:border-amber-500 rounded-lg"
className="pl-8 h-9 text-base font-bold bg-slate-50/50 border-slate-200 focus:ring-red-500 focus:border-red-500 rounded-lg"
value={config.value?.amount || ''}
onChange={(e) => handleUpdateAmount(config.id, e.target.value)}
/>
@ -109,7 +109,7 @@ export const SecurityDepositMaster: React.FC = () => {
<Button
onClick={() => handleSave(config)}
disabled={isSaving === config.id}
className="h-9 px-4 bg-amber-600 hover:bg-amber-700 text-white rounded-lg shadow-md shadow-amber-600/10 active:scale-95 transition-all flex items-center gap-1.5 group"
className="h-9 px-4 bg-re-red hover:bg-re-red-hover text-white rounded-lg shadow-md shadow-re-red/10 active:scale-95 transition-all flex items-center gap-1.5 group"
>
{isSaving === config.id ? (
<RefreshCw className="w-4 h-4 animate-spin" />
@ -140,13 +140,13 @@ export const SecurityDepositMaster: React.FC = () => {
</CardContent>
</Card>
<div className="bg-amber-50/50 rounded-xl p-4 border border-amber-100/50 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<Settings className="w-4 h-4 text-amber-700" />
<div className="bg-red-50/50 rounded-xl p-4 border border-red-100/50 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<Settings className="w-4 h-4 text-re-red-hover" />
</div>
<div>
<h5 className="font-bold text-amber-900 text-sm">Super Admin Notice</h5>
<p className="text-[11px] text-amber-800/80 leading-snug">
<h5 className="font-bold text-red-900 text-sm">Super Admin Notice</h5>
<p className="text-[11px] text-red-800/80 leading-snug">
Updates made here take immediate effect. These values define the default expected amounts for all current and future onboarding payments.
</p>
</div>

View File

@ -96,7 +96,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 space-y-4">
<h3 className="text-sm font-semibold text-slate-800 flex items-center gap-2">
<Settings className="w-4 h-4 text-amber-600" />
<Settings className="w-4 h-4 text-re-red" />
General Settings
</h3>
<div>
@ -149,12 +149,12 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</div>
</div>
<div className="bg-amber-50 p-4 rounded-lg border border-amber-100">
<h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2 mb-3">
<div className="bg-red-50 p-4 rounded-lg border border-red-100">
<h3 className="text-sm font-semibold text-red-900 flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Available Placeholders
</h3>
<p className="text-[10px] text-amber-700 mb-4 leading-relaxed">
<p className="text-[10px] text-re-red-hover mb-4 leading-relaxed">
Click a placeholder to insert it at the cursor.
</p>
<div className="flex flex-wrap gap-2">
@ -164,14 +164,14 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
key={p}
type="button"
onClick={() => insertPlaceholder(p)}
className="px-2 py-1 bg-white border border-amber-200 rounded text-[11px] font-mono text-amber-800 hover:bg-amber-600 hover:text-white hover:border-amber-600 transition-all flex items-center gap-1 shadow-sm"
className="px-2 py-1 bg-white border border-red-200 rounded text-[11px] font-mono text-red-800 hover:bg-re-red hover:text-white hover:border-re-red transition-all flex items-center gap-1 shadow-sm"
>
{`{{${p}}}`}
</button>
))
) : (
<div className="w-full py-4 text-center border-2 border-dashed border-amber-200 rounded-lg">
<p className="text-[10px] text-amber-600">No placeholders defined for this trigger</p>
<div className="w-full py-4 text-center border-2 border-dashed border-red-200 rounded-lg">
<p className="text-[10px] text-re-red">No placeholders defined for this trigger</p>
</div>
)}
</div>
@ -182,7 +182,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<div className="lg:col-span-4 space-y-4 flex flex-col h-full min-h-[600px]">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Edit2 className="w-4 h-4 text-amber-600" />
<Edit2 className="w-4 h-4 text-re-red" />
Template Designer
</h3>
</div>
@ -230,7 +230,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
'{"applicantName": "Rajesh Kumar", "location": "Mumbai South", "applicationId": "APP-2026-X12", "link": "https://example.com/app", "portalLink": "https://example.com/app"}'
)
}
className="text-[10px] text-amber-600 hover:underline"
className="text-[10px] text-re-red hover:underline"
>
Reset to Sample
</button>
@ -302,7 +302,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
Cancel
</Button>
<Button
className="flex-1 bg-amber-600 hover:bg-amber-700"
className="flex-1 bg-re-red hover:bg-re-red-hover"
type="button"
onClick={() => handleSaveTemplate(composeFullBody())}
disabled={!editingTemplate?.id || !editingTemplate?.templateCode?.trim()}

View File

@ -44,7 +44,7 @@ export const UserManagementTable: React.FC<UserManagementTableProps> = ({ userAs
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Shield className="w-3 h-3 text-amber-600" />
<Shield className="w-3 h-3 text-re-red" />
<span className="text-sm font-medium">{user.role}</span>
</div>
</TableCell>

View File

@ -146,7 +146,7 @@ export const ZMDialog: React.FC<ZMDialogProps> = ({
<div className="flex gap-3 pt-6">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save Zonal Manager</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save Zonal Manager</Button>
</div>
</div>
</DialogContent>

View File

@ -33,7 +33,7 @@ export const ZMManagement: React.FC<ZMManagementProps> = ({
<CardTitle>Zonal Managers (DD-ZM)</CardTitle>
<CardDescription>Manage Zonal Managers and their region assignments</CardDescription>
</div>
<Button onClick={onAddZM} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddZM} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add ZM
</Button>
@ -99,7 +99,7 @@ export const ZMManagement: React.FC<ZMManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditZM(zm)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteZM(zm.id, zm.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteZM(zm.id, zm.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -27,7 +27,7 @@ export const ZoneDetails: React.FC<ZoneDetailsProps> = ({ selectedZone, onAddZon
<CardTitle>Zone Details</CardTitle>
<CardDescription>Geographical coverage and state mappings for each zone</CardDescription>
</div>
<Button onClick={onAddZone} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddZone} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add Zone
</Button>
@ -40,7 +40,7 @@ export const ZoneDetails: React.FC<ZoneDetailsProps> = ({ selectedZone, onAddZon
<div key={zone.id} className="border rounded-lg p-5 space-y-4 bg-gradient-to-br from-white to-slate-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 rounded-lg flex items-center justify-center shadow-md">
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-re-red rounded-lg flex items-center justify-center shadow-md">
<Globe className="w-6 h-6 text-white" />
</div>
<div>
@ -86,11 +86,11 @@ export const ZoneDetails: React.FC<ZoneDetailsProps> = ({ selectedZone, onAddZon
<Label className="text-xs text-slate-600 mb-2 block">
Zonal Business Head (ZBH)
</Label>
<div className="bg-amber-50 border border-amber-100 rounded-lg p-3 space-y-2">
<div className="bg-red-50 border border-red-100 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-amber-600" />
<Shield className="w-4 h-4 text-re-red" />
<span className="text-sm font-semibold text-slate-900">{zone.zonalBusinessHead.name}</span>
<Badge className="bg-amber-600 text-white text-[10px] ml-auto">ZBH</Badge>
<Badge className="bg-re-red text-white text-[10px] ml-auto">ZBH</Badge>
</div>
<div className="flex items-center gap-2 ml-6 text-slate-600">

View File

@ -106,7 +106,7 @@ export const ZoneDialog: React.FC<ZoneDialogProps> = ({
</div>
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save Zone</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save Zone</Button>
</div>
</div>
</DialogContent>

View File

@ -19,14 +19,14 @@ export const ZonesOverview: React.FC<ZonesOverviewProps> = ({ selectedZone, onZo
return (
<Card
key={zone.id}
className={`border-2 transition-all cursor-pointer ${selectedZone === zone.id ? 'border-amber-600 shadow-lg' : 'hover:border-amber-400'
className={`border-2 transition-all cursor-pointer ${selectedZone === zone.id ? 'border-re-red shadow-lg' : 'hover:border-red-400'
}`}
onClick={() => onZoneClick(zone.id)}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-amber-600" />
<Globe className="w-5 h-5 text-re-red" />
<CardTitle className="text-lg">{zone.name.toUpperCase().endsWith('ZONE') ? zone.name : `${zone.name} Zone`}</CardTitle>
</div>
<Badge variant="outline" className="text-xs">{zone.code}</Badge>

View File

@ -1,10 +1,9 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
Tabs, TabsContent, TabsList, TabsTrigger
import {
Tabs, TabsContent, TabsList, TabsTrigger
} from '@/components/ui/tabs';
import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText, Settings2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
// Services & Hooks
@ -38,13 +37,13 @@ import { RootState } from '@/store';
export const MasterPage: React.FC = () => {
const { fetchInitialData, fetchAreas } = useMasterData();
const {
const {
asms, zonalManagerMappings,
allStates,
allDistricts,
allDistricts,
users,
roles,
loading
loading
} = useSelector((state: RootState) => state.master);
// Tab & Selection State
@ -67,7 +66,7 @@ export const MasterPage: React.FC = () => {
const [selectedASMStates, setSelectedASMStates] = useState<string[]>([]);
const [selectedASMDistricts, setSelectedASMDistricts] = useState<string[]>([]);
const [asmRoleCode, setAsmRoleCode] = useState<'ASM' | 'DD-AM'>('DD-AM');
// ZM Management State
const [showZMDialog, setShowZMDialog] = useState(false);
const [editingZMId, setEditingZMId] = useState<string | null>(null);
@ -161,11 +160,11 @@ export const MasterPage: React.FC = () => {
return;
}
try {
const payload = {
userId: asmManagerId,
const payload = {
userId: asmManagerId,
roleCode: asmRoleCode,
districts: selectedASMDistricts,
status: asmStatus
districts: selectedASMDistricts,
status: asmStatus
};
const res = await masterService.saveASM(payload) as any;
if (res.success) {
@ -175,9 +174,9 @@ export const MasterPage: React.FC = () => {
} else {
toast.error(res.message || 'Failed to save ASM');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save ASM';
toast.error(msg);
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save ASM';
toast.error(msg);
}
};
@ -209,13 +208,13 @@ export const MasterPage: React.FC = () => {
return;
}
try {
const payload = {
userId: zmManagerId,
const payload = {
userId: zmManagerId,
zoneId: selectedZMZone,
regionIds: selectedZMRegions,
regionIds: selectedZMRegions,
status: zmStatus
};
const res = await (masterService as any).saveZonalManager(payload) as any;
if (res.success) {
toast.success(`Zonal Manager ${editingZMId ? 'updated' : 'assigned'} successfully`);
@ -224,9 +223,9 @@ export const MasterPage: React.FC = () => {
} else {
toast.error(res.message || 'Failed to save Zonal Manager');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save Zonal Manager';
toast.error(msg);
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Failed to save Zonal Manager';
toast.error(msg);
}
};
@ -234,99 +233,99 @@ export const MasterPage: React.FC = () => {
const handleSaveZone = async () => {
try {
const payload = {
id: editingZoneId,
name: zoneName,
code: zoneCode,
description: zoneDescription,
managerId: zonalBusinessHeadId === 'none' ? null : zonalBusinessHeadId
};
const res = await masterService.saveZone(payload) as any;
if (res.success) {
toast.success('Zone saved successfully');
setShowZoneDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving zone');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving zone';
toast.error(msg);
const payload = {
id: editingZoneId,
name: zoneName,
code: zoneCode,
description: zoneDescription,
managerId: zonalBusinessHeadId === 'none' ? null : zonalBusinessHeadId
};
const res = await masterService.saveZone(payload) as any;
if (res.success) {
toast.success('Zone saved successfully');
setShowZoneDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving zone');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving zone';
toast.error(msg);
}
};
const handleSaveRegion = async () => {
try {
const payload = {
...(editingRegionId ? { id: editingRegionId } : {}),
name: regionName,
description: regionDescription,
parentId: selectedRegionZone,
managerId: regionalManagerId,
districts: selectedRegionDistricts,
status: 'Active'
};
const res = await masterService.saveRegion(payload) as any;
if (res.success) {
toast.success('Region saved successfully');
setShowRegionDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving region');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving region';
toast.error(msg);
const payload = {
...(editingRegionId ? { id: editingRegionId } : {}),
name: regionName,
description: regionDescription,
parentId: selectedRegionZone,
managerId: regionalManagerId,
districts: selectedRegionDistricts,
status: 'Active'
};
const res = await masterService.saveRegion(payload) as any;
if (res.success) {
toast.success('Region saved successfully');
setShowRegionDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving region');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving region';
toast.error(msg);
}
};
const handleSaveTemplate = async (body: string) => {
try {
if (!editingTemplate?.id) {
toast.error('Open a template from the list to edit.');
return;
}
const res = await masterService.updateEmailTemplate(editingTemplate.id, {
...editingTemplate,
body
}) as any;
if (res.success) {
toast.success('Template saved');
setShowTemplateDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving template');
}
if (!editingTemplate?.id) {
toast.error('Open a template from the list to edit.');
return;
}
const res = await masterService.updateEmailTemplate(editingTemplate.id, {
...editingTemplate,
body
}) as any;
if (res.success) {
toast.success('Template saved');
setShowTemplateDialog(false);
fetchInitialData();
} else {
toast.error(res.message || 'Error saving template');
}
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving template';
toast.error(msg);
const msg = error?.response?.data?.message || error?.message || 'Error saving template';
toast.error(msg);
}
};
const handlePreviewTemplate = async (body: string) => {
setPreviewLoading(true);
try {
let data: Record<string, unknown>;
try {
data = JSON.parse(testDataInput) as Record<string, unknown>;
} catch {
toast.error('Mock test data must be valid JSON');
return;
}
const res = await masterService.previewEmailTemplate({
subject: editingTemplate?.subject,
body,
data
}) as any;
if (res.success) {
setPreviewContent(res.data);
} else {
toast.error(res.message || 'Preview failed');
}
let data: Record<string, unknown>;
try {
data = JSON.parse(testDataInput) as Record<string, unknown>;
} catch {
toast.error('Mock test data must be valid JSON');
return;
}
const res = await masterService.previewEmailTemplate({
subject: editingTemplate?.subject,
body,
data
}) as any;
if (res.success) {
setPreviewContent(res.data);
} else {
toast.error(res.message || 'Preview failed');
}
} catch (error: any) {
const d = error?.response?.data;
const detail = d?.error || d?.message;
toast.error(detail || error?.message || 'Preview failed');
const d = error?.response?.data;
const detail = d?.error || d?.message;
toast.error(detail || error?.message || 'Preview failed');
} finally { setPreviewLoading(false); }
};
@ -340,9 +339,9 @@ export const MasterPage: React.FC = () => {
} else {
toast.error(res.message || 'Error saving role permissions');
}
} catch (error: any) {
} catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving role permissions';
toast.error(msg);
toast.error(msg);
}
};
@ -385,48 +384,48 @@ export const MasterPage: React.FC = () => {
const handleSaveLocation = async () => {
try {
if (!locationState) {
toast.error('Please select a state');
return;
}
if (!locationDistrict) {
toast.error('Please select a district');
return;
}
if (!locationState) {
toast.error('Please select a state');
return;
}
if (!locationDistrict) {
toast.error('Please select a district');
return;
}
const selectedState = allStates.find((s: any) => s.id === locationState);
const selectedDistrict = allDistricts.find((d: any) => d.id === locationDistrict);
const payload = {
id: editingLocationId,
stateId: locationState,
stateName: (selectedState as any)?.name || (selectedState as any)?.stateName || '',
districtId: locationDistrict,
name: locationCity || selectedDistrict?.name || 'New Location',
city: locationCity,
status: locationStatus,
openFrom: locationActiveFrom,
openTo: locationActiveTo,
isOpportunity: locationStatus === 'active'
};
const res = await (editingLocationId
? masterService.updateArea(editingLocationId, payload)
: masterService.createArea(payload)) as any;
if (res.success) {
toast.success('Location saved');
setShowLocationDialog(false);
fetchAreas({ search: districtsSearch, page: districtsPage });
}
const selectedState = allStates.find((s: any) => s.id === locationState);
const selectedDistrict = allDistricts.find((d: any) => d.id === locationDistrict);
const payload = {
id: editingLocationId,
stateId: locationState,
stateName: (selectedState as any)?.name || (selectedState as any)?.stateName || '',
districtId: locationDistrict,
name: locationCity || selectedDistrict?.name || 'New Location',
city: locationCity,
status: locationStatus,
openFrom: locationActiveFrom,
openTo: locationActiveTo,
isOpportunity: locationStatus === 'active'
};
const res = await (editingLocationId
? masterService.updateArea(editingLocationId, payload)
: masterService.createArea(payload)) as any;
if (res.success) {
toast.success('Location saved');
setShowLocationDialog(false);
fetchAreas({ search: districtsSearch, page: districtsPage });
}
} catch (error) { toast.error('Error saving location'); }
};
useEffect(() => {
const handler = setTimeout(() => {
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter,
isOpportunity: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
});
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter,
isOpportunity: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
});
}, 500);
return () => clearTimeout(handler);
}, [districtsSearch, districtsPage, locationStateFilter, locationStatusFilter, fetchAreas]);
@ -438,67 +437,66 @@ export const MasterPage: React.FC = () => {
<h1 className="text-slate-900 mb-2 font-bold text-2xl">Master Configuration</h1>
<p className="text-slate-600">Centralized governance for locations, roles, and operational policies</p>
</div>
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-1">Admin Control Panel</Badge>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center p-20 space-y-4">
<div className="w-12 h-12 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<div className="w-12 h-12 border-4 border-re-red border-t-transparent rounded-full animate-spin"></div>
<p className="text-slate-600 font-medium animate-pulse">Synchronizing Global Settings...</p>
</div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-8 h-auto sticky top-0 z-10 bg-white/80 backdrop-blur-sm border shadow-sm rounded-xl p-1">
<TabsTrigger value="hierarchy" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="hierarchy" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<Globe className="w-4 h-4" /> Organisation
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="roles" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<Shield className="w-4 h-4" /> Roles
</TabsTrigger>
<TabsTrigger value="templates" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="templates" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<Mail className="w-4 h-4" /> Emails
</TabsTrigger>
<TabsTrigger value="locations" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="locations" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<MapPin className="w-4 h-4" /> Locations
</TabsTrigger>
<TabsTrigger value="approvals" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="approvals" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<SlidersHorizontal className="w-4 h-4" /> Approvals
</TabsTrigger>
<TabsTrigger value="documents" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="documents" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<FileText className="w-4 h-4" /> Docs Config
</TabsTrigger>
<TabsTrigger value="governance" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="governance" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<Settings2 className="w-4 h-4" /> Governance
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="settings" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<Settings className="w-4 h-4" /> App Settings
</TabsTrigger>
</TabsList>
<TabsContent value="hierarchy" className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300">
<ZonesOverview selectedZone={selectedZone} onZoneClick={(id) => setSelectedZone(selectedZone === id ? 'all' : id)} />
<ZoneDetails selectedZone={selectedZone} onAddZone={() => { setEditingZoneId(null); setZoneName(''); setZoneCode(''); setZoneDescription(''); setZonalBusinessHeadId('none'); setShowZoneDialog(true); }}
<ZoneDetails selectedZone={selectedZone} onAddZone={() => { setEditingZoneId(null); setZoneName(''); setZoneCode(''); setZoneDescription(''); setZonalBusinessHeadId('none'); setShowZoneDialog(true); }}
onEditZone={(z) => { setEditingZoneId(z.id); setZoneName(z.name); setZoneCode(z.code); setZoneDescription(z.description); setZonalBusinessHeadId(z.zonalBusinessHead?.id || 'none'); setShowZoneDialog(true); }} />
<RegionalManagement selectedZone={selectedZone} onAddRegion={() => { setEditingRegionId(null); setRegionName(''); setSelectedRegionZone(selectedZone === 'all' ? '' : selectedZone); setRegionalManagerId(''); setSelectedRegionDistricts([]); setShowRegionDialog(true); }}
onEditRegion={(r) => {
setEditingRegionId(r.id);
setRegionName(r.name);
setSelectedRegionZone(r.zoneId);
setRegionalManagerId(r.regionalManager?.id || '');
setSelectedRegionDistricts(r.districts?.map((d: any) => d.id) || []);
setShowRegionDialog(true);
onEditRegion={(r) => {
setEditingRegionId(r.id);
setRegionName(r.name);
setSelectedRegionZone(r.zoneId);
setRegionalManagerId(r.regionalManager?.id || '');
setSelectedRegionDistricts(r.districts?.map((d: any) => d.id) || []);
setShowRegionDialog(true);
}}
onDeleteRegion={() => toast.error('Regional office deletion is restricted via portal')} />
<ZMManagement selectedZone={selectedZone}
onAddZM={() => {
<ZMManagement selectedZone={selectedZone}
onAddZM={() => {
setEditingZMId(null); setZmManagerId('');
setZmStatus('active'); setSelectedZMZone(selectedZone === 'all' ? '' : selectedZone);
setSelectedZMRegions([]);
setShowZMDialog(true);
}}
onEditZM={handleEditZM}
setShowZMDialog(true);
}}
onEditZM={handleEditZM}
onDeleteZM={() => toast.error('ZM deletion restricted')} />
<ASMManagement selectedZone={selectedZone} onAddASM={() => { setEditingASMId(null); setAsmManagerId(''); setSelectedASMZone(selectedZone === 'all' ? '' : selectedZone); setSelectedASMRegion(''); setSelectedASMStates([]); setSelectedASMDistricts([]); setAsmRoleCode('DD-AM'); setShowASMDialog(true); }}
@ -529,13 +527,13 @@ export const MasterPage: React.FC = () => {
setTestDataInput('{}');
}
setShowTemplateDialog(true);
}}
onDeleteTemplate={() => toast.error('Delete Template restricted')}
}}
onDeleteTemplate={() => toast.error('Delete Template restricted')}
/>
</TabsContent>
<TabsContent value="locations" className="animate-in fade-in duration-300">
<LocationManagement
<LocationManagement
states={allStates}
stateFilter={locationStateFilter}
onStateFilterChange={(val: string) => {
@ -557,21 +555,21 @@ export const MasterPage: React.FC = () => {
setLocationStatus('active');
setShowLocationDialog(true);
}}
onEditLocation={handleEditLocation}
onEditLocation={handleEditLocation}
onDeleteLocation={(id) => {
if (window.confirm('Are you sure you want to delete this location?')) {
(masterService as any).deleteArea(id).then((res: any) => {
if (res.success) {
toast.success('Location deleted');
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter
});
toast.success('Location deleted');
fetchAreas({
search: districtsSearch,
page: districtsPage,
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter
});
}
});
}
}}
}}
onSearch={(term) => {
setDistrictsSearch(term);
setDistrictsPage(1); // Reset to first page on search
@ -582,41 +580,41 @@ export const MasterPage: React.FC = () => {
</TabsContent>
<TabsContent value="approvals" className="animate-in fade-in duration-300">
<ApprovalPoliciesPage />
<ApprovalPoliciesPage />
</TabsContent>
<TabsContent value="documents" className="animate-in fade-in duration-300">
<DocumentConfigManagement />
<DocumentConfigManagement />
</TabsContent>
<TabsContent value="governance" className="animate-in fade-in duration-300">
<AutoAssignmentSettings />
<AutoAssignmentSettings />
</TabsContent>
<TabsContent value="settings" className="animate-in fade-in duration-300">
<SecurityDepositMaster />
<SecurityDepositMaster />
</TabsContent>
</Tabs>
)}
{/* Main Dialogs */}
<ZoneDialog isOpen={showZoneDialog} onOpenChange={setShowZoneDialog} editingZoneId={editingZoneId} zoneName={zoneName} setZoneName={setZoneName} zoneCode={zoneCode} setZoneCode={setZoneCode} zoneDescription={zoneDescription} setZoneDescription={setZoneDescription} zonalBusinessHeadId={zonalBusinessHeadId} setZonalBusinessHeadId={setZonalBusinessHeadId} onSave={handleSaveZone} userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms} />
<RegionDialog isOpen={showRegionDialog} onOpenChange={setShowRegionDialog} editingRegionId={editingRegionId} regionName={regionName} setRegionName={setRegionName} regionDescription={regionDescription} setRegionDescription={setRegionDescription} selectedRegionZone={selectedRegionZone} setSelectedRegionZone={setSelectedRegionZone} regionalManagerId={regionalManagerId} setRegionalManagerId={setRegionalManagerId} selectedRegionStates={selectedRegionDistricts} setSelectedRegionStates={setSelectedRegionDistricts} onSave={handleSaveRegion} userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms} />
<ASMDialog isOpen={showASMDialog} onOpenChange={setShowASMDialog} editingASMId={editingASMId} asmManagerId={asmManagerId} setAsmManagerId={setAsmManagerId} asmStatus={asmStatus} setAsmStatus={setAsmStatus} selectedASMZone={selectedASMZone} setSelectedASMZone={setSelectedASMZone} selectedASMRegion={selectedASMRegion} setSelectedASMRegion={setSelectedASMRegion} selectedASMStates={selectedASMStates} setSelectedASMStates={setSelectedASMStates} selectedASMDistricts={selectedASMDistricts} setSelectedASMDistricts={setSelectedASMDistricts} onSave={handleSaveASM} asmRoleCode={asmRoleCode} setAsmRoleCode={setAsmRoleCode} userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms} districtsAssignedToOthers={districtsAssignedToOthers} getDistrictsForSelectedState={(state) => getDistrictsForSelectedState(state, selectedASMRegion || undefined)} />
<ZMDialog
isOpen={showZMDialog}
onOpenChange={setShowZMDialog}
editingZMId={editingZMId}
zmManagerId={zmManagerId}
setZmManagerId={setZmManagerId}
zmStatus={zmStatus}
setZmStatus={setZmStatus}
selectedZone={selectedZMZone}
setSelectedZone={setSelectedZMZone}
selectedRegions={selectedZMRegions}
setSelectedRegions={setSelectedZMRegions}
onSave={handleSaveZM}
userAssignedData={users.length > 0 ? users.map(u => ({...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles})) : asms}
<ZoneDialog isOpen={showZoneDialog} onOpenChange={setShowZoneDialog} editingZoneId={editingZoneId} zoneName={zoneName} setZoneName={setZoneName} zoneCode={zoneCode} setZoneCode={setZoneCode} zoneDescription={zoneDescription} setZoneDescription={setZoneDescription} zonalBusinessHeadId={zonalBusinessHeadId} setZonalBusinessHeadId={setZonalBusinessHeadId} onSave={handleSaveZone} userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms} />
<RegionDialog isOpen={showRegionDialog} onOpenChange={setShowRegionDialog} editingRegionId={editingRegionId} regionName={regionName} setRegionName={setRegionName} regionDescription={regionDescription} setRegionDescription={setRegionDescription} selectedRegionZone={selectedRegionZone} setSelectedRegionZone={setSelectedRegionZone} regionalManagerId={regionalManagerId} setRegionalManagerId={setRegionalManagerId} selectedRegionStates={selectedRegionDistricts} setSelectedRegionStates={setSelectedRegionDistricts} onSave={handleSaveRegion} userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms} />
<ASMDialog isOpen={showASMDialog} onOpenChange={setShowASMDialog} editingASMId={editingASMId} asmManagerId={asmManagerId} setAsmManagerId={setAsmManagerId} asmStatus={asmStatus} setAsmStatus={setAsmStatus} selectedASMZone={selectedASMZone} setSelectedASMZone={setSelectedASMZone} selectedASMRegion={selectedASMRegion} setSelectedASMRegion={setSelectedASMRegion} selectedASMStates={selectedASMStates} setSelectedASMStates={setSelectedASMStates} selectedASMDistricts={selectedASMDistricts} setSelectedASMDistricts={setSelectedASMDistricts} onSave={handleSaveASM} asmRoleCode={asmRoleCode} setAsmRoleCode={setAsmRoleCode} userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms} districtsAssignedToOthers={districtsAssignedToOthers} getDistrictsForSelectedState={(state) => getDistrictsForSelectedState(state, selectedASMRegion || undefined)} />
<ZMDialog
isOpen={showZMDialog}
onOpenChange={setShowZMDialog}
editingZMId={editingZMId}
zmManagerId={zmManagerId}
setZmManagerId={setZmManagerId}
zmStatus={zmStatus}
setZmStatus={setZmStatus}
selectedZone={selectedZMZone}
setSelectedZone={setSelectedZMZone}
selectedRegions={selectedZMRegions}
setSelectedRegions={setSelectedZMRegions}
onSave={handleSaveZM}
userAssignedData={users.length > 0 ? users.map(u => ({ ...u, name: u.fullName || u.name, role: u.role?.roleName, roleCode: u.role?.roleCode, allRoles: u.allRoles })) : asms}
/>
<TemplateDialog isOpen={showTemplateDialog} onOpenChange={setShowTemplateDialog} editingTemplate={editingTemplate} setEditingTemplate={setEditingTemplate} testDataInput={testDataInput} setTestDataInput={setTestDataInput} previewLoading={previewLoading} handlePreviewTemplate={handlePreviewTemplate} previewContent={previewContent} handleSaveTemplate={handleSaveTemplate} />
<LocationDialog isOpen={showLocationDialog} onOpenChange={setShowLocationDialog} editingLocationId={editingLocationId} locationState={locationState} setLocationState={setLocationState} locationDistrict={locationDistrict} setLocationDistrict={setLocationDistrict} locationCity={locationCity} setLocationCity={setLocationCity} locationActiveFrom={locationActiveFrom} setLocationActiveFrom={setLocationActiveFrom} locationActiveTo={locationActiveTo} setLocationActiveTo={setLocationActiveTo} locationStatus={locationStatus} setLocationStatus={setLocationStatus} allStates={allStates} allDistricts={allDistricts} onSave={handleSaveLocation} />

View File

@ -73,7 +73,7 @@ export const SLAConfigPage: React.FC = () => {
<RefreshCw className={`w-4 h-4 mr-2 ${loadingMore ? 'animate-spin' : ''}`} />
Initialize Defaults
</Button>
<Button onClick={handleAddSLA} disabled={loading}>
<Button onClick={handleAddSLA} disabled={loading} className="bg-re-red hover:bg-re-red-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Manual SLA
</Button>
@ -136,14 +136,14 @@ export const SLAConfigPage: React.FC = () => {
<div className="border-l-2 border-red-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<AlertTriangle className="w-4 h-4 text-re-red" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">Escalations ({sla.escalationConfigs?.length || 0})</span>
</div>
<div className="space-y-2">
{(sla.escalationConfigs || []).map((esc: any, idx: number) => (
<div key={idx} className="text-[11px]">
<div className="flex items-center gap-1.5 text-slate-900 font-medium">
<Badge variant="outline" className="text-[9px] h-4 px-1 bg-red-50 border-red-200 text-red-700">
<Badge variant="outline" className="text-[9px] h-4 px-1 bg-red-50 border-red-200 text-re-red-hover">
L{esc.level}
</Badge>
<span>after {esc.timeValue} {esc.timeUnit}</span>

View File

@ -35,6 +35,8 @@ interface ApplicationDetailsActionModalsProps {
setInterviewIdToCancel: (value: string) => void;
isCancellingInterview: boolean;
handleConfirmCancelInterview: () => void;
interviewToReschedule: any;
setInterviewToReschedule: (value: any) => void;
interviewType: string;
setInterviewType: (value: string) => void;
interviewMode: string;
@ -99,6 +101,8 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
setInterviewIdToCancel,
isCancellingInterview,
handleConfirmCancelInterview,
interviewToReschedule,
setInterviewToReschedule,
interviewType,
setInterviewType,
interviewMode,
@ -252,10 +256,13 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</DialogContent>
</Dialog>
<Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal}>
<Dialog open={showScheduleModal} onOpenChange={(open) => {
setShowScheduleModal(open);
if (!open) setInterviewToReschedule(null);
}}>
<DialogContent data-testid="onboarding-schedule-modal">
<DialogHeader>
<DialogTitle>Schedule Interview</DialogTitle>
<DialogTitle>{interviewToReschedule ? 'Reschedule Interview' : 'Schedule Interview'}</DialogTitle>
<DialogDescription>Set up an interview session with the applicant and relevant team members.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@ -304,10 +311,15 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</div>
)}
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowScheduleModal(false)} disabled={isScheduling} data-testid="onboarding-schedule-cancel-button">Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={handleScheduleInterview} disabled={isScheduling} data-testid="onboarding-schedule-submit-button">{isScheduling ? 'Scheduling...' : 'Schedule'}</Button>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => {
setShowScheduleModal(false);
setInterviewToReschedule(null);
}} disabled={isScheduling} data-testid="onboarding-schedule-cancel-button">Cancel</Button>
<Button className="flex-1 bg-primary-600 hover:bg-primary-700" onClick={handleScheduleInterview} disabled={isScheduling} data-testid="onboarding-schedule-submit-button">
{isScheduling ? (interviewToReschedule ? 'Rescheduling...' : 'Scheduling...') : (interviewToReschedule ? 'Reschedule' : 'Schedule')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>

View File

@ -157,10 +157,10 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
</div>
))}
<div className="space-y-2 border-t border-border pt-6">
<Label htmlFor="kt-matrix-remarks" className="text-sm font-medium">Notes <span className="font-normal text-muted-foreground">(optional)</span></Label>
<Label htmlFor="kt-matrix-remarks" className="text-sm font-medium">Remarks <span className="text-red-500">*</span></Label>
<Textarea
id="kt-matrix-remarks"
placeholder="Optional remarks…"
placeholder="Enter remarks..."
className="min-h-[96px] resize-y text-sm leading-relaxed"
value={ktMatrixRemarks}
onChange={(e) => setKtMatrixRemarks(e.target.value)}
@ -186,7 +186,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<p className="text-sm text-muted-foreground">Weighted total <span className="font-semibold tabular-nums text-foreground" data-testid="onboarding-kt-matrix-total-score">{calculateKTScore()}</span><span className="text-muted-foreground"> / 100</span></p>
<div className="flex gap-2 sm:shrink-0">
<Button variant="outline" onClick={() => setShowKTMatrixModal(false)} data-testid="onboarding-kt-matrix-cancel">Cancel</Button>
<Button onClick={handleSubmitKTMatrix} disabled={isSubmittingKT || ktCriteria.length === 0 || Object.keys(ktMatrixSelectedValues).length < ktCriteria.length} data-testid="onboarding-kt-matrix-submit">{isSubmittingKT ? 'Saving…' : 'Submit'}</Button>
<Button onClick={handleSubmitKTMatrix} disabled={isSubmittingKT || ktCriteria.length === 0 || Object.keys(ktMatrixSelectedValues).length < ktCriteria.length || !ktMatrixRemarks?.trim()} data-testid="onboarding-kt-matrix-submit">{isSubmittingKT ? 'Saving…' : 'Submit'}</Button>
</div>
</div>
</DialogContent>
@ -208,19 +208,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<SelectContent><SelectItem value="10">Outstanding (9-10)</SelectItem><SelectItem value="8">Excellent (7-8)</SelectItem><SelectItem value="6">Good (5-6)</SelectItem><SelectItem value="4">Average (3-4)</SelectItem><SelectItem value="2">Below Average (1-2)</SelectItem></SelectContent>
</Select>
</div>
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level2Recommendation} onValueChange={setLevel2Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level2-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{l2Fields.length === 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
@ -255,6 +243,19 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
)}
</div>
))}
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level2Recommendation} onValueChange={setLevel2Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level2-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowLevel2FeedbackModal(false)} data-testid="onboarding-level2-feedback-cancel">Cancel</Button>
<Button className="flex-1 bg-black hover:bg-zinc-800 text-white" onClick={handleSubmitLevel2Feedback} disabled={isSubmittingLevel2 || l2Fields.length === 0} data-testid="onboarding-level2-feedback-submit">{isSubmittingLevel2 ? 'Submitting...' : 'Submit Feedback'}</Button>
@ -311,19 +312,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<SelectContent><SelectItem value="10">Outstanding (9-10)</SelectItem><SelectItem value="8">Excellent (7-8)</SelectItem><SelectItem value="6">Good (5-6)</SelectItem><SelectItem value="4">Average (3-4)</SelectItem><SelectItem value="2">Below Average (1-2)</SelectItem></SelectContent>
</Select>
</div>
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level3Recommendation} onValueChange={setLevel3Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level3-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{l3Fields.length === 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
@ -358,6 +347,19 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
)}
</div>
))}
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level3Recommendation} onValueChange={setLevel3Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level3-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowLevel3FeedbackModal(false)} data-testid="onboarding-level3-feedback-cancel">Cancel</Button>
<Button className="flex-1 bg-black hover:bg-zinc-800 text-white" onClick={handleSubmitLevel3Feedback} disabled={isSubmittingLevel3 || l3Fields.length === 0} data-testid="onboarding-level3-feedback-submit">{isSubmittingLevel3 ? 'Submitting...' : 'Submit Feedback'}</Button>
@ -375,7 +377,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
{!showUploadForm ? (
<div className="flex-1 flex flex-col min-h-0 space-y-4">
{getDocumentsForStage(selectedStage || '').length > 0 ? (
<div className="flex-1 overflow-auto border rounded-lg border-slate-200" data-testid="onboarding-documents-table-container">
<div className="custom-scrollbar-x-slim flex-1 overflow-auto border rounded-lg border-slate-200" data-testid="onboarding-documents-table-container">
<Table className="w-full table-auto">
<TableHeader className="bg-slate-50/80 sticky top-0 z-10">
<TableRow className="hover:bg-transparent border-b">

View File

@ -9,6 +9,7 @@ import {
Clock,
ClipboardList,
Download,
Eye,
FileText,
GitBranch,
Lock,
@ -49,7 +50,7 @@ interface ApplicationDetailsTabsProps {
setShowDocumentsModal: (value: boolean) => void;
setShowUploadForm: (value: boolean) => void;
handleRetriggerEvaluators: () => void;
handleCancelInterview: (interviewId: any) => void;
handleRescheduleInterview: (interview: any) => void;
setSelectedEvaluationForView: (value: any) => void;
setShowFeedbackDetailsModal: (value: boolean) => void;
renderFddAuditContent: () => React.ReactNode;
@ -84,7 +85,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
setShowDocumentsModal,
setShowUploadForm,
handleRetriggerEvaluators,
handleCancelInterview,
handleRescheduleInterview,
setSelectedEvaluationForView,
setShowFeedbackDetailsModal,
renderFddAuditContent,
@ -488,7 +489,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</button>
</div>
<p className="text-slate-400 text-[10px] mt-1" data-testid={`onboarding-progress-branch-stage-status-${branchKey}-${bsIdx}`}>
{isDone && branchStage.date ? `Done: ${formatDateTime(branchStage.date)}` : isDone && stageDocs.length > 0 ? `Uploaded: ${formatDateTime(stageDocs[0].updatedAt || stageDocs[0].createdAt)}` : branchStage.status === 'active' ? 'Evaluating' : 'Pending'}
{isDone && branchStage.date ? `Done: ${formatDateTime(branchStage.date)}` : isDone && stageDocs.length > 0 ? `Uploaded: ${formatDateTime(stageDocs[0].updatedAt || stageDocs[0].createdAt)}` : 'Pending'}
</p>
</div>
</>
@ -556,11 +557,17 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" data-testid={`onboarding-document-download-${idx}`} onClick={() => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
window.open(`${baseUrl}/${doc.filePath}`, '_blank');
<Button size="sm" variant="outline" data-testid={`onboarding-document-preview-${idx}`} onClick={() => {
setPreviewDoc(doc);
setShowPreviewModal(true);
}}>
<Download className="w-3 h-3" />
<Eye className="w-3 h-3 text-slate-500" />
</Button>
<Button size="sm" variant="outline" data-testid={`onboarding-document-download-${idx}`} onClick={() => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
window.open(`${baseUrl}/${doc.filePath}`, '_blank');
}}>
<Download className="w-3 h-3 text-slate-500" />
</Button>
</div>
</TableCell>
@ -620,11 +627,11 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-8 px-2"
data-testid={`onboarding-interview-cancel-${idx}`}
onClick={() => handleCancelInterview(interview.id)}
className="text-primary-600 hover:text-primary-700 hover:bg-primary-50 h-8 px-2"
data-testid={`onboarding-interview-reschedule-${idx}`}
onClick={() => handleRescheduleInterview(interview)}
>
Cancel
Reschedule
</Button>
)}
</TableCell>

View File

@ -17,10 +17,15 @@ interface UseApplicationDetailsAdminActionsParams {
participantType: string;
users: any[];
interviewDate: string;
setInterviewDate: Dispatch<SetStateAction<string>>;
interviewType: string;
setInterviewType: Dispatch<SetStateAction<string>>;
interviewMode: string;
setInterviewMode: Dispatch<SetStateAction<string>>;
meetingLink: string;
setMeetingLink: Dispatch<SetStateAction<string>>;
location: string;
setLocation: Dispatch<SetStateAction<string>>;
scheduledInterviewParticipants: any[];
uploadFile: File | null;
uploadDocType: string;
@ -45,6 +50,8 @@ interface UseApplicationDetailsAdminActionsParams {
setShowCancelInterviewModal: Dispatch<SetStateAction<boolean>>;
interviewIdToCancel: string;
setInterviewIdToCancel: Dispatch<SetStateAction<string>>;
interviewToReschedule: any;
setInterviewToReschedule: Dispatch<SetStateAction<any>>;
setIsCancellingInterview: Dispatch<SetStateAction<boolean>>;
setIsUploading: Dispatch<SetStateAction<boolean>>;
setShowUploadForm: Dispatch<SetStateAction<boolean>>;
@ -79,10 +86,15 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
participantType,
users,
interviewDate,
setInterviewDate,
interviewType,
setInterviewType,
interviewMode,
setInterviewMode,
meetingLink,
setMeetingLink,
location,
setLocation,
scheduledInterviewParticipants,
uploadFile,
uploadDocType,
@ -107,6 +119,8 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
setShowCancelInterviewModal,
interviewIdToCancel,
setInterviewIdToCancel,
interviewToReschedule,
setInterviewToReschedule,
setIsCancellingInterview,
setIsUploading,
setShowUploadForm,
@ -176,7 +190,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}, [currentUser, application, setUsers]);
const prefillInterviewParticipants = useCallback(() => {
if (!showScheduleModal || !application) return;
if (!showScheduleModal || !application || interviewToReschedule) return;
const levelNum = parseInt(interviewType.replace('level', '')) || 1;
const requiredRolesByLevel: Record<number, string[]> = {
1: ['DD-ZM', 'RBM'],
@ -233,7 +247,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}
});
setScheduledInterviewParticipants(unique);
}, [showScheduleModal, application, interviewType, setScheduledInterviewParticipants]);
}, [showScheduleModal, application, interviewType, interviewToReschedule, setScheduledInterviewParticipants]);
const handleScheduleInterview = async () => {
if (!interviewDate) {
@ -242,20 +256,32 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}
try {
setIsScheduling(true);
await onboardingService.scheduleInterview({
const payload = {
applicationId: application?.id,
level: interviewType,
scheduledAt: interviewDate,
type: interviewMode === 'virtual' ? 'Virtual Interview' : 'Physical Interview',
location: interviewMode === 'virtual' ? meetingLink : location,
participants: scheduledInterviewParticipants.map((p) => p.id),
});
toast.success('Interview scheduled successfully');
};
if (interviewToReschedule) {
await onboardingService.updateInterview(interviewToReschedule.id, {
...payload,
status: 'Scheduled',
});
toast.success('Interview rescheduled successfully');
} else {
await onboardingService.scheduleInterview(payload);
toast.success('Interview scheduled successfully');
}
setShowScheduleModal(false);
setInterviewToReschedule(null);
await fetchInterviews();
await fetchApplication();
} catch {
toast.error('Failed to schedule interview');
toast.error(interviewToReschedule ? 'Failed to reschedule interview' : 'Failed to schedule interview');
} finally {
setIsScheduling(false);
}
@ -266,6 +292,24 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
setShowCancelInterviewModal(true);
};
const handleRescheduleInterview = async (interview: any) => {
setInterviewToReschedule(interview);
setInterviewType(`level${interview.level}`);
setInterviewMode(interview.interviewType?.toLowerCase().includes('virtual') ? 'virtual' : 'physical');
setInterviewDate(interview.scheduleDate ? (() => {
const d = new Date(interview.scheduleDate);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
})() : '');
if (interview.interviewType?.toLowerCase().includes('virtual')) {
setMeetingLink(interview.linkOrLocation || '');
} else {
setLocation(interview.linkOrLocation || '');
}
const participants = (interview.participants || []).map((p: any) => p.user || p).filter(Boolean);
setScheduledInterviewParticipants(participants);
setShowScheduleModal(true);
};
const handleConfirmCancelInterview = async () => {
if (!interviewIdToCancel) return;
try {
@ -606,6 +650,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
fetchUsers,
maybeFetchUsersForModal,
handleScheduleInterview,
handleRescheduleInterview,
handleCancelInterview,
handleConfirmCancelInterview,
handleUpload,

View File

@ -6,6 +6,106 @@ interface UseApplicationDetailsPermissionsParams {
eorProgress: number;
}
function sameUserId(a: unknown, b: unknown): boolean {
if (a == null || b == null) return false;
return String(a).trim() === String(b).trim();
}
/** Backend / DB may use different casing (e.g. default `scheduled` vs created `Scheduled`). */
function normalizeInterviewStatus(status: unknown): string {
return String(status ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
function isActiveInterviewStatus(status: unknown): boolean {
const n = normalizeInterviewStatus(status);
if (!n) return false;
return (
n === 'scheduled' ||
n === 'rescheduled' ||
n === 'pending' ||
n === 'in progress' ||
n === 'inprogress'
);
}
function isCompletedInterviewStatus(status: unknown): boolean {
return normalizeInterviewStatus(status) === 'completed';
}
function userIsInterviewParticipant(interview: any, userId: unknown): boolean {
if (!userId || !interview?.participants?.length) return false;
return interview.participants.some(
(p: any) => sameUserId(p.userId, userId) || sameUserId(p.user?.id, userId),
);
}
/** Which interview level the application is currently in (for feedback UI). */
function inferInterviewLevelFromApplicationStatus(status: unknown): number | undefined {
const s = String(status ?? '').trim();
const map: Record<string, number> = {
'Level 1 Interview Pending': 1,
'Level 1 Recommended': 1,
'Level 2 Interview Pending': 2,
'Level 2 Recommended': 2,
'Level 3 Interview Pending': 3,
'Level 3 Recommended': 3,
};
return map[s];
}
function normalizeRoleToken(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[_\s-]+/g, '');
}
/**
* Allow feedback when scheduling forgot to attach this user as InterviewParticipant but they are a
* designated evaluator for this level (matches backend interview policy / schedule prefill roles).
*/
function userRoleEligibleForInterviewLevel(user: any, level: number): boolean {
if (!user) return false;
const privilegedRoles = ['Super Admin', 'DD Admin'];
if (
privilegedRoles.includes(String(user.role ?? '')) ||
privilegedRoles.includes(String(user.roleCode ?? ''))
) {
return true;
}
const rolesByLevel: Record<number, string[]> = {
1: ['DD-ZM', 'DD ZM', 'RBM'],
2: ['DD Lead', 'ZBH'],
3: ['NBH', 'DD Head'],
};
const allowedRaw = rolesByLevel[level];
if (!allowedRaw?.length) return false;
const allowed = allowedRaw.map(normalizeRoleToken);
const candidates = [
user.role,
user.roleCode,
user.roleName,
user.role?.roleCode,
user.role?.roleName,
]
.filter(Boolean)
.map(normalizeRoleToken);
for (const c of candidates) {
if (!c) continue;
for (const a of allowed) {
if (!a) continue;
if (c === a || c.includes(a) || a.includes(c)) return true;
}
}
return false;
}
export function useApplicationDetailsPermissions({
application,
interviews,
@ -15,24 +115,46 @@ export function useApplicationDetailsPermissions({
}: UseApplicationDetailsPermissionsParams) {
const interviewsList = Array.isArray(interviews) ? interviews : [];
const activeInterviewForUser = interviewsList.find((i: any) =>
['Scheduled', 'Rescheduled', 'Pending', 'In Progress'].includes(i.status) &&
i.participants?.some((p: any) => p.userId === currentUser?.id)
);
const stageInterviewLevel = inferInterviewLevelFromApplicationStatus(application?.status);
const lastInterviewForUser = [...interviewsList].reverse().find((i: any) =>
i.participants?.some((p: any) => p.userId === currentUser?.id)
);
/** Prefer participant match on the interview row that matches current application stage when possible. */
const participantActiveInterview =
(stageInterviewLevel != null
? interviewsList.find(
(i: any) =>
isActiveInterviewStatus(i.status) &&
userIsInterviewParticipant(i, currentUser?.id) &&
Number(i.level) === stageInterviewLevel,
)
: undefined) ??
interviewsList.find(
(i: any) => isActiveInterviewStatus(i.status) && userIsInterviewParticipant(i, currentUser?.id),
);
const currentUserEvaluation = (activeInterviewForUser || lastInterviewForUser)?.evaluations?.find(
(e: any) => e.evaluatorId === currentUser?.id
);
/** Same stage + active interview + evaluator role — covers missing / partial participant rows. */
const roleFallbackActiveInterview =
stageInterviewLevel != null &&
currentUser &&
userRoleEligibleForInterviewLevel(currentUser, stageInterviewLevel)
? interviewsList.find(
(i: any) =>
Number(i.level) === stageInterviewLevel && isActiveInterviewStatus(i.status),
)
: undefined;
const activeInterviewForUser = participantActiveInterview ?? roleFallbackActiveInterview;
const lastInterviewForUser = interviewsList.find((i: any) => userIsInterviewParticipant(i, currentUser?.id));
const currentUserEvaluation =
activeInterviewForUser?.evaluations?.find((e: any) => sameUserId(e.evaluatorId, currentUser?.id)) ??
lastInterviewForUser?.evaluations?.find((e: any) => sameUserId(e.evaluatorId, currentUser?.id));
const isInterviewCompleted = (level: number) =>
interviewsList.some((i: any) => Number(i.level) === level && i.status === 'Completed');
interviewsList.some((i: any) => Number(i.level) === level && isCompletedInterviewStatus(i.status));
const isInterviewActive = (level: number) =>
interviewsList.some((i: any) => Number(i.level) === level && i.status === 'Scheduled');
interviewsList.some((i: any) => Number(i.level) === level && isActiveInterviewStatus(i.status));
const hasSubmittedFeedback = !!currentUserEvaluation;

View File

@ -6,14 +6,16 @@ interface UseApplicationDetailsStageDataParams {
interviews: any[];
eorData: any;
getDeposit: (type: string) => any;
documentConfigs?: any[];
}
export function useApplicationDetailsStageData({
application,
documents,
interviews,
interviews: _interviews,
eorData,
getDeposit,
documentConfigs = [],
}: UseApplicationDetailsStageDataParams) {
const normalizeRole = (value: unknown): string =>
String(value || '')
@ -38,39 +40,31 @@ export function useApplicationDetailsStageData({
return (documents || []).some((d) => d.documentType === docType);
};
const isInterviewScheduled = (level: number | string) => {
return (interviews || []).some((i) => (i.level === level || i.level === level.toString()) && i.status?.toLowerCase() === 'scheduled');
};
const getStageStatus = (stageName: string, fallbackLogic: () => ProcessStage['status']): ProcessStage['status'] => {
const getStageStatus = (stageName: string, fallbackStatus: ProcessStage['status'] = 'pending'): ProcessStage['status'] => {
const backendStage = (application.progressTracking || []).find((ps: any) => ps.stageName === stageName);
if (backendStage && (backendStage.status === 'completed' || backendStage.status === 'active')) {
return backendStage.status as any;
}
return fallbackLogic();
return backendStage?.status ? (backendStage.status as any) : fallbackStatus;
};
const processStages: ProcessStage[] = [
{ id: 1, name: 'Submitted', status: 'completed', date: application.submissionDate, description: 'Application submitted', documentsUploaded: 3 },
{
id: 2, name: 'Questionnaire', status: getStageStatus('Questionnaire', () =>
['Questionnaire Completed', 'Shortlisted', 'Level 1 Interview Pending', 'Level 1 Approved', 'Level 2 Interview Pending', 'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Questionnaire Pending' ? 'active' : 'pending'),
id: 2, name: 'Questionnaire', status: getStageStatus('Questionnaire'),
date: application.questionnaireDate, description: 'Questionnaire completed', documentsUploaded: 0
},
{
id: 3, name: 'Shortlist', status: getStageStatus('Shortlist', () => ['Shortlisted', 'Level 1 Interview Pending', 'Level 1 Approved', 'Level 2 Interview Pending', 'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Rejected', 'Onboarded'].includes(application.status) ? 'completed' : 'pending'),
id: 3, name: 'Shortlist', status: getStageStatus('Shortlist'),
date: application.shortlistDate, description: 'Application shortlisted by DD',
evaluators: Array.from(new Set((application.participants || []).filter((p: any) => p.participantType === 'assignee').map((p: any) => `${p.user?.fullName || p.user?.name || 'User'} (${p.user?.roleCode || p.participantType})`))),
documentsUploaded: 2
},
{
id: 4, name: '1st Level Interview', status: getStageStatus('1st Level Interview', () => ['Level 1 Approved', 'Level 2 Interview Pending', 'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : (application.status === 'Level 1 Interview Pending' && isInterviewScheduled(1)) ? 'active' : 'pending'),
id: 4, name: '1st Level Interview', status: getStageStatus('1st Level Interview'),
date: application.level1InterviewDate, description: 'DD-ZM + RBM evaluation',
evaluators: Array.from(new Set(
(application.participants || [])
.filter((p: any) =>
p.metadata?.interviewLevel === 1 ||
p.metadata?.interviewLevel === '1' ||
.filter((p: any) =>
p.metadata?.interviewLevel === 1 ||
p.metadata?.interviewLevel === '1' ||
p.metadata?.allAssignments?.includes(1) ||
p.metadata?.allAssignments?.includes('1') ||
hasAnyRole(p, ['DD-ZM', 'RBM'])
@ -80,13 +74,13 @@ export function useApplicationDetailsStageData({
documentsUploaded: 1
},
{
id: 5, name: '2nd Level Interview', status: getStageStatus('2nd Level Interview', () => ['Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : (application.status === 'Level 2 Interview Pending' && isInterviewScheduled(2)) ? 'active' : 'pending'),
id: 5, name: '2nd Level Interview', status: getStageStatus('2nd Level Interview'),
date: application.level2InterviewDate, description: 'DD Lead + ZBH evaluation',
evaluators: Array.from(new Set(
(application.participants || [])
.filter((p: any) =>
p.metadata?.interviewLevel === 2 ||
p.metadata?.interviewLevel === '2' ||
.filter((p: any) =>
p.metadata?.interviewLevel === 2 ||
p.metadata?.interviewLevel === '2' ||
p.metadata?.allAssignments?.includes(2) ||
p.metadata?.allAssignments?.includes('2') ||
hasAnyRole(p, ['DD Lead', 'ZBH'])
@ -96,13 +90,13 @@ export function useApplicationDetailsStageData({
documentsUploaded: 1
},
{
id: 6, name: '3rd Level Interview', status: getStageStatus('3rd Level Interview', () => ['Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : (application.status === 'Level 3 Interview Pending' && isInterviewScheduled(3)) ? 'active' : 'pending'),
id: 6, name: '3rd Level Interview', status: getStageStatus('3rd Level Interview'),
date: application.level3InterviewDate, description: 'NBH + DD Head evaluation',
evaluators: Array.from(new Set(
(application.participants || [])
.filter((p: any) =>
p.metadata?.interviewLevel === 3 ||
p.metadata?.interviewLevel === '3' ||
.filter((p: any) =>
p.metadata?.interviewLevel === 3 ||
p.metadata?.interviewLevel === '3' ||
p.metadata?.allAssignments?.includes(3) ||
p.metadata?.allAssignments?.includes('3') ||
hasAnyRole(p, ['NBH', 'DD Head'])
@ -111,55 +105,75 @@ export function useApplicationDetailsStageData({
)),
documentsUploaded: 2
},
{ id: 7, name: 'FDD', status: getStageStatus('FDD', () => ['LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'FDD Verification' ? 'active' : 'pending'), date: application.fddDate, description: 'Financial Due Diligence', documentsUploaded: 5 },
{ id: 7, name: 'FDD', status: getStageStatus('FDD'), date: application.fddDate, description: 'Financial Due Diligence', documentsUploaded: 5 },
{
id: 8, name: 'LOI Approval', status: getStageStatus('LOI Approval', () => ['Security Details', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOI In Progress' ? 'active' : 'pending'),
id: 8, name: 'LOI Approval', status: getStageStatus('LOI Approval'),
date: application.loiApprovalDate, description: 'Letter of Intent approval',
evaluators: Array.from(new Set((application.participants || []).filter((p: any) => p.metadata?.stageCode === 'LOI_APPROVAL' || p.metadata?.allAssignments?.includes('LOI_APPROVAL')).map(participantLabel))),
documentsUploaded: 1
},
{
id: 9, name: 'Security Details', status: getStageStatus('Security Details', () => ['LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Security Details' || application.status === 'Payment Pending' ? 'active' : 'pending'),
id: 9, name: 'Security Details', status: getStageStatus('Security Details'),
date: application.securityDetailsDate, description: 'Security verification', documentsUploaded: 3
},
{
id: 10, name: 'LOI Issue', status: getStageStatus('LOI Issue', () => ['Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOI Issued' ? 'active' : 'pending'),
date: application.loiIssueDate, description: 'Letter of Intent issued', documentsUploaded: 1
},
{
id: 11, name: 'Dealer Code Generation', status: getStageStatus('Dealer Code Generation', () => (application.dealerCode || ['Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status)) ? 'completed' : 'pending'),
date: application.dealerCodeDate, description: 'Dealer code generated and assigned', isParallel: true,
id: 10, name: 'LOI Issue', status: getStageStatus('LOI Issue'),
date: application.loiIssueDate, description: 'Letter of Intent issued', isParallel: true,
branches: [
{ name: 'Architectural Work', color: 'green', stages: [
{ id: '11a-1', name: 'Architecture Assignment', status: application.architectureAssignedTo ? 'completed' : application.status === 'Architecture Team Assigned' ? 'active' : 'pending', description: 'Assigned to architecture team' },
{ id: '11a-2', name: 'Site Plan Blueprint', status: isDocumentUploaded('Architecture Blueprint') ? 'completed' : application.architectureAssignedTo ? 'active' : 'pending', description: 'Blueprints and site plans' },
{ id: '11a-3', name: 'Architecture Work', status: application.architectureStatus === 'COMPLETED' ? 'completed' : (application.architectureStatus === 'IN_PROGRESS' || isDocumentUploaded('Architecture Blueprint')) ? 'active' : 'pending', description: 'Final architecture approval' },
]},
{ name: 'Statutory Documents', color: 'green', stages: [
{ id: '11b-1', name: 'GST', status: isDocumentUploaded('GST Certificate') || isDocumentUploaded('GST') ? 'completed' : 'active', description: 'GST certificate' },
{ id: '11b-2', name: 'PAN', status: isDocumentUploaded('PAN Card') || isDocumentUploaded('PAN') ? 'completed' : 'active', description: 'PAN card' },
{ id: '11b-3', name: 'Nodal Agreement', status: isDocumentUploaded('Nodal Agreement') ? 'completed' : 'active', description: 'Nodal agreement document' },
{ id: '11b-4', name: 'Cancelled Check', status: isDocumentUploaded('Cancelled Check') ? 'completed' : 'active', description: 'Cancelled check copy' },
{ id: '11b-5', name: 'Partnership Deed/LLP/MOA/AOA/COI', status: isDocumentUploaded('Partnership Deed/LLP/MOA/AOA/COI') || isDocumentUploaded('Partnership Deed') ? 'completed' : 'active', description: 'Business entity documents' },
{ id: '11b-6', name: 'Firm Registration Certificate', status: isDocumentUploaded('Firm Registration Certificate') || isDocumentUploaded('Firm Registration') ? 'completed' : 'active', description: 'Firm registration certificate' },
{ id: '11b-7', name: 'Rental agreement/ Lease agreement / Own/ Land agreement', status: isDocumentUploaded('Rental agreement/ Lease agreement / Own/ Land agreement') || isDocumentUploaded('Property Document') ? 'completed' : 'active', description: 'Property agreement document' },
{ id: '11b-8', name: 'Virtual Code', status: isDocumentUploaded('Virtual Code') || isDocumentUploaded('Virtual Code Confirmation') ? 'completed' : 'active', description: 'Virtual code availability' },
{ id: '11b-9', name: 'Domain ID', status: isDocumentUploaded('Domain ID') || isDocumentUploaded('Domain ID Setup') ? 'completed' : 'active', description: 'Domain ID setup' },
{ id: '11b-10', name: 'MSD Configuration', status: isDocumentUploaded('MSD Configuration') ? 'completed' : 'active', description: 'Microsoft Dynamics configuration' },
{ id: '11b-11', name: 'LOI Acknowledgement Copy', status: isDocumentUploaded('LOI Acknowledgement Copy') || isDocumentUploaded('LOI Acknowledgement') ? 'completed' : 'active', description: 'LOI acknowledgement copy' },
]},
{
name: 'LOI Documents', color: 'green', stages:
documentConfigs.some((c: any) => c.stageCode === 'LOI Issue')
? documentConfigs.filter((c: any) => c.stageCode === 'LOI Issue').map((c: any, i: number) => ({
id: `10a-${i}`,
name: c.documentType,
status: isDocumentUploaded(c.documentType) ? 'completed' : 'active',
description: c.isMandatory ? `Upload ${c.documentType} (Mandatory)` : `Upload ${c.documentType}`
}))
: [
{ id: '10a-1', name: 'Letter of Intent', status: isDocumentUploaded('Letter of Intent') || isDocumentUploaded('LOI') ? 'completed' : 'active', description: 'Letter of Intent document' },
{ id: '10a-2', name: 'Signed LOI', status: isDocumentUploaded('Signed LOI') || isDocumentUploaded('LOI Signed Copy') ? 'completed' : 'active', description: 'Signed Letter of Intent' },
]
}
]
},
{
id: 12, name: 'LOA', status: getStageStatus('LOA', () => ['EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOA Pending' ? 'active' : 'pending'),
id: 11, name: 'Dealer Code Generation', status: getStageStatus('Dealer Code Generation'),
date: application.dealerCodeDate, description: 'Dealer code generated and assigned', isParallel: true,
branches: [
{
name: 'Architectural Work', color: 'green', stages: [
{ id: '11a-1', name: 'Architecture Assignment', status: application.architectureAssignedTo ? 'completed' : application.status === 'Architecture Team Assigned' ? 'active' : 'pending', description: 'Assigned to architecture team' },
{ id: '11a-2', name: 'Site Plan Blueprint', status: isDocumentUploaded('Architecture Blueprint') ? 'completed' : application.architectureAssignedTo ? 'active' : 'pending', description: 'Blueprints and site plans' },
{ id: '11a-3', name: 'Architecture Work', status: application.architectureStatus === 'COMPLETED' ? 'completed' : (application.architectureStatus === 'IN_PROGRESS' || isDocumentUploaded('Architecture Blueprint')) ? 'active' : 'pending', description: 'Final architecture approval' },
]
},
{
name: 'Statutory Documents', color: 'green', stages: [
{ id: '11b-1', name: 'GST', status: isDocumentUploaded('GST Certificate') || isDocumentUploaded('GST') ? 'completed' : 'active', description: 'GST certificate' },
{ id: '11b-2', name: 'PAN', status: isDocumentUploaded('PAN Card') || isDocumentUploaded('PAN') ? 'completed' : 'active', description: 'PAN card' },
{ id: '11b-3', name: 'Nodal Agreement', status: isDocumentUploaded('Nodal Agreement') ? 'completed' : 'active', description: 'Nodal agreement document' },
{ id: '11b-4', name: 'Cancelled Check', status: isDocumentUploaded('Cancelled Check') ? 'completed' : 'active', description: 'Cancelled check copy' },
{ id: '11b-5', name: 'Partnership Deed/LLP/MOA/AOA/COI', status: isDocumentUploaded('Partnership Deed/LLP/MOA/AOA/COI') || isDocumentUploaded('Partnership Deed') ? 'completed' : 'active', description: 'Business entity documents' },
{ id: '11b-6', name: 'Firm Registration Certificate', status: isDocumentUploaded('Firm Registration Certificate') || isDocumentUploaded('Firm Registration') ? 'completed' : 'active', description: 'Firm registration certificate' },
{ id: '11b-7', name: 'Rental agreement/ Lease agreement / Own/ Land agreement', status: isDocumentUploaded('Rental agreement/ Lease agreement / Own/ Land agreement') || isDocumentUploaded('Property Document') ? 'completed' : 'active', description: 'Property agreement document' },
{ id: '11b-8', name: 'Virtual Code', status: isDocumentUploaded('Virtual Code') || isDocumentUploaded('Virtual Code Confirmation') ? 'completed' : 'active', description: 'Virtual code availability' },
{ id: '11b-9', name: 'Domain ID', status: isDocumentUploaded('Domain ID') || isDocumentUploaded('Domain ID Setup') ? 'completed' : 'active', description: 'Domain ID setup' },
{ id: '11b-10', name: 'MSD Configuration', status: isDocumentUploaded('MSD Configuration') ? 'completed' : 'active', description: 'Microsoft Dynamics configuration' },
{ id: '11b-11', name: 'LOI Acknowledgement Copy', status: isDocumentUploaded('LOI Acknowledgement Copy') || isDocumentUploaded('LOI Acknowledgement') ? 'completed' : 'active', description: 'LOI acknowledgement copy' },
]
},
]
},
{
id: 12, name: 'LOA', status: getStageStatus('LOA'),
isLocked: application.status === 'LOA Pending' && getDeposit('FIRST_FILL')?.status !== 'Verified',
lockMessage: 'First Fill (₹15L) must be verified by Finance before LOA Approval.',
evaluators: Array.from(new Set((application.participants || []).filter((p: any) => p.metadata?.stageCode === 'LOA_APPROVAL' || p.metadata?.allAssignments?.includes('LOA_APPROVAL')).map(participantLabel))),
description: 'Letter of Authorization'
},
{ id: 13, name: 'EOR Complete', status: getStageStatus('EOR Complete', () => ['Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'EOR Complete' ? 'active' : 'pending'), description: 'Essential Operating Requirements' },
{ id: 14, name: 'Inauguration', status: getStageStatus('Inauguration', () => ['Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Inauguration' ? 'active' : 'pending'), description: 'Dealership inauguration' },
{ id: 15, name: 'Dealership Active', status: getStageStatus('Onboarded', () => application.status === 'Onboarded' ? 'completed' : ['Inauguration', 'Approved'].includes(application.status) ? 'active' : 'pending'), description: 'Dealer profile active' },
{ id: 13, name: 'EOR Complete', status: getStageStatus('EOR Complete'), description: 'Essential Operating Requirements' },
{ id: 14, name: 'Inauguration', status: getStageStatus('Inauguration'), description: 'Dealership inauguration' },
{ id: 15, name: 'Dealership Active', status: getStageStatus('Onboarded'), description: 'Dealer profile active' },
];
const eorChecklist = [

View File

@ -19,6 +19,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [showCancelInterviewModal, setShowCancelInterviewModal] = useState(false);
const [interviewIdToCancel, setInterviewIdToCancel] = useState('');
const [interviewToReschedule, setInterviewToReschedule] = useState<any>(null);
const [showKTMatrixModal, setShowKTMatrixModal] = useState(false);
const [showLevel2FeedbackModal, setShowLevel2FeedbackModal] = useState(false);
const [showLevel3FeedbackModal, setShowLevel3FeedbackModal] = useState(false);
@ -98,6 +99,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
showScheduleModal, setShowScheduleModal,
showCancelInterviewModal, setShowCancelInterviewModal,
interviewIdToCancel, setInterviewIdToCancel,
interviewToReschedule, setInterviewToReschedule,
showKTMatrixModal, setShowKTMatrixModal,
showLevel2FeedbackModal, setShowLevel2FeedbackModal,
showLevel3FeedbackModal, setShowLevel3FeedbackModal,

View File

@ -26,7 +26,6 @@ import {
Download,
Grid3x3,
List,
Mail,
CheckCircle,
AlertCircle,
Loader2
@ -193,9 +192,6 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
}
};
const handleBulkReminders = () => {
toast.success(`Reminder emails sent to ${selectedIds.length} applicant(s)`);
};
// For DD's All Applications page, only show initial statuses
const statusOptions: ApplicationStatus[] = [
@ -356,16 +352,6 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
{selectedIds.length > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={handleBulkReminders}
data-testid="onboarding-all-apps-reminders-btn"
>
<Mail className="w-4 h-4 mr-2" />
Send Reminders ({selectedIds.length})
</Button>
<Button
size="sm"
onClick={handleShortlist}

View File

@ -62,6 +62,7 @@ export const ApplicationDetails = () => {
showScheduleModal, setShowScheduleModal,
showCancelInterviewModal, setShowCancelInterviewModal,
interviewIdToCancel, setInterviewIdToCancel,
interviewToReschedule, setInterviewToReschedule,
showKTMatrixModal, setShowKTMatrixModal,
showLevel2FeedbackModal, setShowLevel2FeedbackModal,
showLevel3FeedbackModal, setShowLevel3FeedbackModal,
@ -266,6 +267,7 @@ export const ApplicationDetails = () => {
handleRemoveInterviewer,
maybeFetchUsersForModal,
handleScheduleInterview,
handleRescheduleInterview,
handleCancelInterview,
handleConfirmCancelInterview,
handleUpload,
@ -291,10 +293,15 @@ export const ApplicationDetails = () => {
participantType,
users,
interviewDate,
setInterviewDate,
interviewType,
setInterviewType,
interviewMode,
setInterviewMode,
meetingLink,
setMeetingLink,
location,
setLocation,
scheduledInterviewParticipants,
uploadFile,
uploadDocType,
@ -319,6 +326,8 @@ export const ApplicationDetails = () => {
setShowCancelInterviewModal,
interviewIdToCancel,
setInterviewIdToCancel,
interviewToReschedule,
setInterviewToReschedule,
setIsCancellingInterview,
setIsUploading,
setShowUploadForm,
@ -359,6 +368,7 @@ export const ApplicationDetails = () => {
interviews,
eorData,
getDeposit,
documentConfigs,
});
const {
@ -444,6 +454,7 @@ export const ApplicationDetails = () => {
setShowUploadForm={setShowUploadForm}
handleRetriggerEvaluators={handleRetriggerEvaluators}
handleCancelInterview={handleCancelInterview}
handleRescheduleInterview={handleRescheduleInterview}
setSelectedEvaluationForView={setSelectedEvaluationForView}
setShowFeedbackDetailsModal={setShowFeedbackDetailsModal}
renderFddAuditContent={renderFddAuditContent}
@ -532,6 +543,8 @@ export const ApplicationDetails = () => {
setInterviewIdToCancel={setInterviewIdToCancel}
isCancellingInterview={isCancellingInterview}
handleConfirmCancelInterview={handleConfirmCancelInterview}
interviewToReschedule={interviewToReschedule}
setInterviewToReschedule={setInterviewToReschedule}
interviewType={interviewType}
setInterviewType={setInterviewType}
interviewMode={interviewMode}

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { ApplicationStatus, Application } from '@/lib/mock-data';
import { formatDateTime } from '@/components/ui/utils';
import { onboardingService } from '@/services/onboarding.service';
@ -14,7 +15,8 @@ import {
import {
Search,
Download,
Mail
Mail,
Loader2
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
@ -53,6 +55,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
const [statusFilter, setStatusFilter] = useState<string>(initialFilter || 'all');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [sortBy, setSortBy] = useState<'date'>('date');
const [showNewApplicationModal, setShowNewApplicationModal] = useState(false);
const [showMyAssignments, setShowMyAssignments] = useState(false);
@ -153,9 +156,22 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
}
};
const handleBulkReminders = () => {
alert(`Sending reminders to ${selectedIds.length} applicants`);
setSelectedIds([]);
const handleBulkReminders = async () => {
if (selectedIds.length === 0) return;
try {
setIsSendingReminders(true);
const res = await onboardingService.sendBulkReminders(selectedIds);
if (res.success) {
toast.success(res.message || `Reminder emails sent to ${selectedIds.length} applicant(s)`);
setSelectedIds([]);
}
} catch (error: any) {
console.error('Failed to send reminders:', error);
toast.error(error.message || 'Failed to send reminders');
} finally {
setIsSendingReminders(false);
}
};
const handleExport = () => {
@ -283,9 +299,14 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
variant="outline"
size="sm"
onClick={handleBulkReminders}
disabled={isSendingReminders}
data-testid="onboarding-applications-reminders-button"
>
<Mail className="w-4 h-4 mr-2" />
{isSendingReminders ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Mail className="w-4 h-4 mr-2" />
)}
Send Reminders ({selectedIds.length})
</Button>
)}

View File

@ -11,6 +11,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { cn } from '@/components/ui/utils';
import {
Search,
Download,
@ -182,9 +190,12 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
setApplicationsData(mappedApps);
// Extract unique locations
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean);
setLocations(uniqueLocations);
// Extract unique locations and states from the returned data
const newLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean) as string[];
setLocations(prev => Array.from(new Set([...prev, ...newLocations])));
const newStates = Array.from(new Set(mappedApps.map(app => app.state))).filter(Boolean) as string[];
setStates(prev => Array.from(new Set([...prev, ...newStates])));
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load non-opportunity requests');
@ -247,29 +258,55 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</div>
<div className="flex items-center gap-2">
<div className="relative w-full md:w-36">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="pl-10 text-xs"
placeholder="From"
data-testid="onboarding-non-opps-from-date"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-36 justify-start text-left font-normal h-10 px-3",
!fromDate && "text-muted-foreground"
)}
data-testid="onboarding-non-opps-from-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{fromDate ? format(new Date(fromDate), "PP") : <span className="text-xs text-slate-500">From Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={fromDate ? new Date(fromDate) : undefined}
onSelect={(date) => setFromDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
<span className="text-slate-400">to</span>
<div className="relative w-full md:w-36">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="pl-10 text-xs"
placeholder="To"
data-testid="onboarding-non-opps-to-date"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-36 justify-start text-left font-normal h-10 px-3",
!toDate && "text-muted-foreground"
)}
data-testid="onboarding-non-opps-to-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{toDate ? format(new Date(toDate), "PP") : <span className="text-xs text-slate-500">To Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={toDate ? new Date(toDate) : undefined}
onSelect={(date) => setToDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<Select value={locationFilter} onValueChange={setLocationFilter}>
@ -284,6 +321,22 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-slate-700 h-10 px-3"
onClick={() => {
setFromDate('');
setToDate('');
setLocationFilter('all');
setStateFilter('all');
setSearchQuery('');
}}
data-testid="onboarding-non-opps-clear-filters"
>
Clear Filters
</Button>
<Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-state-select">
<SelectValue placeholder="All States" />

View File

@ -11,6 +11,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { cn } from '@/components/ui/utils';
import {
Pagination,
PaginationContent,
@ -27,7 +35,6 @@ import {
Mail,
Grid3x3,
List,
AlertCircle,
Loader2,
Calendar,
ArrowUpDown
@ -67,6 +74,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [showShortlistModal, setShowShortlistModal] = useState(false);
const [shortlistRemark, setShortlistRemark] = useState('');
@ -160,9 +168,13 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
setApplicationsData(mappedApps);
// Extract unique locations for filtering
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean);
setLocations(uniqueLocations);
// Extract unique locations and states from the returned data
// Note: This appends new ones to the existing list to ensure all found locations are selectable
const newLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean) as string[];
setLocations(prev => Array.from(new Set([...prev, ...newLocations])));
const newStates = Array.from(new Set(mappedApps.map(app => app.state))).filter(Boolean) as string[];
setStates(prev => Array.from(new Set([...prev, ...newStates])));
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load opportunity requests');
@ -216,7 +228,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
if (response && response.success) {
// Refresh data from server to ensure correct filtering and pagination
await fetchApplications();
setSelectedIds([]);
setShowShortlistModal(false);
setShortlistRemark('');
@ -231,21 +243,34 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
}
};
const handleBulkReminders = () => {
const handleBulkReminders = async () => {
if (selectedIds.length === 0) {
toast.error('Please select at least one application');
return;
}
toast.success(`Reminder emails sent to ${selectedIds.length} applicant(s)`);
try {
setIsSendingReminders(true);
const res = await onboardingService.sendBulkReminders(selectedIds);
if (res.success) {
toast.success(res.message || `Reminder emails sent to ${selectedIds.length} applicant(s)`);
setSelectedIds([]);
}
} catch (error: any) {
console.error('Failed to send reminders:', error);
toast.error(error.message || 'Failed to send reminders');
} finally {
setIsSendingReminders(false);
}
};
const handleExport = async () => {
// Exclude 'Questionnaire Pending' from export as they have no responses yet
const validApplications = filteredApplications.filter(app => app.status !== 'Questionnaire Pending');
const selectedValidApps = validApplications.filter(app => selectedIds.includes(app.id));
let idsToExport: string[] = [];
if (selectedIds.length > 0) {
if (selectedValidApps.length === 0) {
toast.error('Selected applications are in "Questionnaire Pending" status and cannot be exported.');
@ -258,7 +283,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
} else {
idsToExport = validApplications.map(a => a.id);
}
if (idsToExport.length === 0) {
toast.error('No applications with completed questionnaires available for export');
return;
@ -268,7 +293,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const loadingToast = toast.loading('Preparing Excel export...');
const data = await onboardingService.exportResponses(idsToExport);
toast.dismiss(loadingToast);
if (!data || data.length === 0) {
toast.error('No response data found');
return;
@ -278,7 +303,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const headers = Object.keys(data[0]);
const csvRows = [
headers.join(','), // Header row
...data.map((row: any) =>
...data.map((row: any) =>
headers.map(header => {
const val = row[header] ?? '';
// Escape quotes and wrap in quotes for CSV safety
@ -297,7 +322,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success(`Exported ${idsToExport.length} records to Excel successfully`);
} catch (error: any) {
console.error('Export failed:', error);
@ -380,20 +405,6 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
return (
<div className="space-y-6">
{/* Info Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4" data-testid="onboarding-opp-requests-banner">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-amber-900 mb-1">DD Lead Workflow - Opportunity Requests</h3>
<p className="text-amber-800">
This page shows <strong>applications where dealerships are being offered</strong> at the applicant's preferred location.
These have been shortlisted by DD and are waiting for your review. Select and <strong>Shortlist</strong> promising candidates
to move them to the <strong>Dealership Requests</strong> page for further processing.
</p>
</div>
</div>
</div>
{/* Header with Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
@ -424,6 +435,23 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-slate-700 h-9"
onClick={() => {
setFromDate('');
setToDate('');
setStatusFilter('all');
setLocationFilter('all');
setStateFilter('all');
setSearchQuery('');
}}
data-testid="onboarding-opp-requests-clear-filters"
>
Clear Filters
</Button>
<Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full md:w-48" data-testid="onboarding-opp-requests-state-select">
<SelectValue placeholder="Filter by state" />
@ -449,29 +477,55 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</Select>
<div className="flex items-center gap-2 flex-1 md:flex-none">
<div className="relative w-full md:w-40">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="pl-10 text-xs"
placeholder="From"
data-testid="onboarding-opp-requests-from-date"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-40 justify-start text-left font-normal h-9 px-3",
!fromDate && "text-muted-foreground"
)}
data-testid="onboarding-opp-requests-from-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{fromDate ? format(new Date(fromDate), "PPP") : <span className="text-xs">From Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={fromDate ? new Date(fromDate) : undefined}
onSelect={(date) => setFromDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
<span className="text-slate-400">to</span>
<div className="relative w-full md:w-40">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="pl-10 text-xs"
placeholder="To"
data-testid="onboarding-opp-requests-to-date"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-40 justify-start text-left font-normal h-9 px-3",
!toDate && "text-muted-foreground"
)}
data-testid="onboarding-opp-requests-to-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{toDate ? format(new Date(toDate), "PPP") : <span className="text-xs">To Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={toDate ? new Date(toDate) : undefined}
onSelect={(date) => setToDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
@ -516,9 +570,9 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</div>
<Button variant="outline" size="sm" onClick={handleExport} data-testid="onboarding-opp-requests-export-btn">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
{selectedIds.length > 0 && (
<>
@ -526,9 +580,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
variant="outline"
size="sm"
onClick={handleBulkReminders}
disabled={isSendingReminders}
data-testid="onboarding-opp-requests-bulk-reminder-btn"
>
<Mail className="w-4 h-4 mr-2" />
{isSendingReminders ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Mail className="w-4 h-4 mr-2" />
)}
Send Reminders ({selectedIds.length})
</Button>
@ -672,23 +731,23 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
<PaginationPrevious
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{[...Array(paginationMeta.totalPages)].map((_, i) => {
const pageNum = i + 1;
// Simple pagination: show first, last, and current +/- 1
if (
pageNum === 1 ||
pageNum === paginationMeta.totalPages ||
pageNum === 1 ||
pageNum === paginationMeta.totalPages ||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
) {
return (
<PaginationItem key={pageNum}>
<PaginationLink
<PaginationLink
isActive={currentPage === pageNum}
onClick={() => setCurrentPage(pageNum)}
className="cursor-pointer"
@ -698,7 +757,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</PaginationItem>
);
} else if (
pageNum === currentPage - 2 ||
pageNum === currentPage - 2 ||
pageNum === currentPage + 2
) {
return (
@ -711,7 +770,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
})}
<PaginationItem>
<PaginationNext
<PaginationNext
onClick={() => setCurrentPage(p => Math.min(paginationMeta.totalPages, p + 1))}
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>

View File

@ -29,11 +29,9 @@ const workflowStages = [
{ id: 3, name: 'DD ZM Review', key: 'DD_ZM_REVIEW', role: 'DD-ZM' },
{ id: 4, name: 'ZBH Review', key: 'ZBH_REVIEW', role: 'ZBH' },
{ id: 5, name: 'DD Lead Review', key: 'DD_LEAD_REVIEW', role: 'DD Lead' },
{ id: 6, name: 'DD Head Approval', key: 'DD_HEAD_APPROVAL', role: 'DD Head' },
{ id: 7, name: 'NBH Approval', key: 'NBH_APPROVAL', role: 'NBH' },
{ id: 8, name: 'Legal Clearance', key: 'LEGAL_CLEARANCE', role: 'Legal Admin' },
{ id: 9, name: 'NBH Clearance with EOR', key: 'NBH_CLEARANCE_EOR', role: 'NBH' },
{ id: 10, name: 'Relocation Complete', key: 'COMPLETED', role: 'System' }
{ id: 6, name: 'NBH Approval', key: 'NBH_APPROVAL', role: 'NBH' },
{ id: 7, name: 'Legal Clearance', key: 'LEGAL_CLEARANCE', role: 'Legal Admin' },
{ id: 8, name: 'Relocation Complete', key: 'COMPLETED', role: 'System' }
];
/** Map API stage / status label to 1-based workflow row index; 0 = unknown; length+1 = finished */
@ -296,15 +294,6 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
if (response.data.success) {
const req = response.data.request;
setRequest(req);
const currentStage = req.currentStage;
if (
currentStage === 'NBH_CLEARANCE_EOR' ||
currentStage === 'NBH Clearance with EOR' ||
req.status === 'Completed'
) {
fetchEorChecklist(req.id);
}
}
} catch (error) {
console.error('Fetch relocation request details error:', error);
@ -351,7 +340,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
const timelineResolvedOrdinal = relocationTimelineResolvedOrdinal(timelineEntries);
const auditResolvedOrdinal = relocationAuditResolvedOrdinal(auditLogs);
const dbOrdinal = request ? getDbStageOrdinal() : 1;
/** Audit/timeline can reference later steps (e.g. NBH EOR) while the request still sits at NBH Approval — do not use that to drive the active step. */
/** Audit/timeline can reference later steps while the request still sits in a prior stage — do not use that to drive the active step. */
const workflowProgressMismatch =
Boolean(request) &&
Math.max(timelineResolvedOrdinal, auditResolvedOrdinal) > dbOrdinal &&
@ -365,7 +354,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
request?.status === 'Completed' ||
request?.currentStage === 'Completed' ||
dbOrdinal >= workflowStages.length + 1;
/** Match backend: N/10 while on pipeline (NBH EOR = 9 → 90%); 100% only when completed — avoids stale API 100% at NBH EOR. */
/** Match backend: N/(pipeline+1) while in flight; 100% only when completed. */
const timelineProgressPct = allWorkflowComplete
? 100
: Math.min(100, Math.round((dbOrdinal / workflowStages.length) * 100));
@ -422,7 +411,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
request.currentStage &&
request.currentStage !== 'ASM Review' &&
request.currentStage !== 'Rejected';
const canRevoke = showActions && ['ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Super Admin'].includes(currentUser?.role || '');
const canRevoke = showActions && ['ZBH', 'DD Lead', 'NBH', 'Legal Admin', 'Super Admin'].includes(currentUser?.role || '');
const requiresDocGate = request?.currentStage === 'NBH Approval' || request?.currentStage === 'Legal Clearance';
const canApprove = showActions && (!requiresDocGate || (missingRequiredDocs.length === 0 && pendingVerificationDocs.length === 0));
@ -696,9 +685,6 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<TabsList className="w-max min-w-full justify-start">
<TabsTrigger value="workflow">Workflow Progress</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
{(request.currentStage === 'NBH Clearance with EOR' || request.status === 'Completed' || request.currentStage === 'NBH_CLEARANCE_EOR') && (
<TabsTrigger value="eor">EOR Checklist</TabsTrigger>
)}
<TabsTrigger value="history">History & Audit Trail</TabsTrigger>
</TabsList>
</div>
@ -1039,7 +1025,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{doc.status === 'Pending Verification' && (() => {
const role = currentUser?.role || currentUser?.roleCode || '';
// SRS — only authorized review roles can verify relocation documents
return ['DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(role);
return ['DD Lead', 'NBH', 'Legal Admin', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(role);
})() && (
<>
<Button
@ -1134,7 +1120,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{(!eorChecklist.items || eorChecklist.items.length === 0) ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-slate-500 py-8 text-sm">
No checklist rows returned. Use &quot;Try Refreshing&quot; above or reload the page; rows are created when the request enters NBH Clearance with EOR.
No checklist rows returned. Use &quot;Try Refreshing&quot; above or reload the page.
</TableCell>
</TableRow>
) : (

View File

@ -9,7 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner';
@ -44,8 +44,9 @@ const STAGE_TO_ROLE_MAP: Record<string, string> = {
const TERMINAL_STAGE_LABELS = ['REJECTED', 'Rejected', 'REVOKED', 'Revoked', 'WITHDRAWN', 'Withdrawn'];
const RESIGNATION_STAGE_ALIASES: Record<string, string[]> = {
'ASM': ['ASM', 'ASM Review', 'Submission', 'Submitted'],
'RBM': ['RBM', 'RBM Review', 'Regional Review'],
'Request Submitted': ['Submission', 'Submitted', 'Initiation'],
'ASM': ['ASM', 'ASM Review'],
'RBM': ['RBM', 'RBM Review', 'Regional Review', 'RBM + DD-ZM Review'],
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
'DD Lead': ['DD Lead', 'DD Lead Review', 'DDL Review'],
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
@ -97,6 +98,16 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadDocType, setUploadDocType] = useState<string>(RESIGNATION_DOCUMENT_TYPES[0]);
const [uploadStage, setUploadStage] = useState('');
const hasUploadedPPT = useMemo(() => {
const allDocs = [
...(resignationData?.documents || []),
...(resignationData?.uploadedDocuments || [])
];
return allDocs.some(doc =>
(doc.documentType || doc.type) === 'PPT Presentation'
);
}, [resignationData]);
const fetchResignation = async () => {
try {
setIsLoading(true);
@ -127,18 +138,19 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
// Progress stages logic based on live data
const progressStages = [
{ id: 1, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
{ id: 2, name: 'RBM Review', key: 'RBM', description: 'Regional Business Manager evaluation' },
{ id: 3, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
{ id: 4, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead final review' },
{ id: 5, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
{ id: 6, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification' },
{ id: 1, name: 'Request Submitted', key: 'Request Submitted', description: 'Dealer submitted the resignation request' },
{ id: 2, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
{ id: 3, name: 'RBM + DD-ZM Review', key: 'RBM', description: 'Joint approval by Regional Business Manager and DD-ZM' },
{ id: 4, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
{ id: 5, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead final review' },
{ id: 6, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
{ id: 7, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
{ id: 8, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
{ id: 9, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
{ id: 8, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
{ id: 9, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
{ id: 10, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
];
const stagesOrdered = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'DD Admin', 'Legal', 'F&F Initiated', 'Completed'];
const stagesOrdered = ['Request Submitted', 'ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'];
const legalStageApproved = (() => {
if (!resignationData) return false;
@ -157,6 +169,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const atLegal = stage === 'legal' || stage === 'legal - resignation letter';
const legalApprovedTransition =
targetStage === 'legal' ||
targetStage === 'dd admin' ||
targetStage === 'f&f initiated' ||
targetStage === 'fnf_initiated' ||
action.includes('approved');
@ -173,8 +186,17 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const currentStage = resignationData.currentStage;
const status = resignationData.status;
const userRole = currentUser.role;
const isZmRbmStage = currentStage === 'RBM' || currentStage === 'RBM Review' || currentStage === 'RBM + DD-ZM Review';
const userRoleCode = String(currentUser.roleCode || currentUser.role || '').trim().toUpperCase();
// Final states where no more actions are possible
// Check if current user already partially approved this request at this stage
const hasAlreadyPartiallyApproved = isZmRbmStage && auditLogs.some(log =>
log.action === 'PARTIAL_APPROVE' &&
(log.actor?.id === currentUser.id || log.actorId === currentUser.id || log.actor?.email === currentUser.email || log.userEmail === currentUser.email) &&
(log.details?.roleCode === userRoleCode || (log.details?.roleCode === 'DD-ZM' && userRoleCode === 'DD ZM'))
);
const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Revoked'].includes(status);
// Check if it's already in the settlement phase
@ -184,29 +206,48 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const nbhIndex = stagesOrdered.indexOf('NBH');
const isPastNBH = stageIndex !== -1 && nbhIndex !== -1 && stageIndex >= nbhIndex;
const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === STAGE_TO_ROLE_MAP[currentStage];
const isCurrentlyAssigned = userRoleCode === 'SUPER_ADMIN' ||
(isZmRbmStage && (userRoleCode === 'RBM' || userRoleCode === 'DD-ZM' || userRoleCode === 'DD ZM')) ||
userRole === STAGE_TO_ROLE_MAP[currentStage];
const isDDLeadStage = currentStage === 'DD Lead' || currentStage === 'DD Lead Review';
const isDDLead = userRoleCode === 'DD_LEAD' || userRoleCode === 'DD LEAD';
const isLwdReached = (() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lwdString = resignationData.lastOperationalDateServices || resignationData.lastOperationalDateSales;
if (!lwdString) return true;
const lwd = new Date(lwdString);
lwd.setHours(0, 0, 0, 0);
return today >= lwd;
})();
const canApprove = isCurrentlyAssigned &&
!isFinalState &&
!isSettlementPhase &&
!(currentStage === 'Legal' && legalStageApproved);
!hasAlreadyPartiallyApproved &&
!(currentStage === 'Legal' && legalStageApproved) &&
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
!(currentStage === 'DD Admin' && !isLwdReached);
return {
canApprove,
canSendBack: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && stageIndex > 0,
canWithdraw: userRole === 'Dealer' && !isPastNBH && !isFinalState,
canRevoke: (userRole === 'Super Admin' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase,
canRevoke: (userRoleCode === 'SUPER_ADMIN' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase,
canPushToFnF: ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(userRole) &&
!isSettlementPhase && !isFinalState,
!isSettlementPhase && !isFinalState && currentStage === 'Legal' && isLwdReached,
canAssign: userRole !== 'Dealer' && !isFinalState
};
};
const permissions = getResignationPermissions();
const isNationalLevel = ['Super Admin', 'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Legal Admin', 'DD-ZM'].includes(currentUser?.role || '');
const stageAliases: Record<string, string[]> = {
'ASM': ['ASM', 'ASM Review', 'Request Initiated'],
'RBM': ['RBM', 'RBM Review'],
'RBM': ['RBM', 'RBM Review', 'RBM + DD-ZM Review'],
'ZBH': ['ZBH', 'ZBH Review'],
'DD Lead': ['DD Lead', 'DD Lead Review', 'Lead Review'],
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
@ -446,6 +487,31 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-600 mr-2">Workflow Actions:</span>
{/* Debug for PPT button visibility */}
{(() => {
const roleNormalized = String(currentUser?.roleCode || currentUser?.role || '').trim().toUpperCase();
const isDDLeadUser = roleNormalized === 'DD LEAD' || roleNormalized === 'DD_LEAD';
const isDDLeadStageCurrent = ['DD Lead', 'DD Lead Review', 'DDL Review'].includes(resignationData?.currentStage);
if (isDDLeadUser && isDDLeadStageCurrent) {
return (
<Button
size="sm"
variant="outline"
className="text-amber-700 border-amber-300 hover:bg-amber-50 shadow-sm"
onClick={() => {
setUploadDocType('PPT Presentation');
setUploadStage('DD Lead');
setShowUploadDialog(true);
}}
>
<Upload className="w-4 h-4 mr-2" />
Upload PPT
</Button>
);
}
return null;
})()}
{permissions.canApprove && (
<Button
size="sm"
@ -562,6 +628,9 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<TabsTrigger value="progress" className="data-[state=active]:bg-white">Progress</TabsTrigger>
<TabsTrigger value="documents" className="data-[state=active]:bg-white">Documents</TabsTrigger>
<TabsTrigger value="audit" className="data-[state=active]:bg-white">Audit Trail</TabsTrigger>
{isNationalLevel && (
<TabsTrigger value="approvals" className="data-[state=active]:bg-white">Approval Summary</TabsTrigger>
)}
</TabsList>
{/* Details Tab */}
@ -750,19 +819,27 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</div>
<p className="text-slate-600 text-sm mb-1">{stage.description}</p>
{timelineEntry && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
{timelineEntry.user || 'System'}
</Badge>
<span className="text-[10px] text-slate-500 italic">
{timelineEntry.action}
</span>
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
{timelineEntry.comments || timelineEntry.remarks || 'No remarks provided.'}
</div>
{stageTimelineEntries.length > 0 && (
<div className="space-y-4 mt-3">
{stageTimelineEntries.map((entry: any, i: number) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
{entry.user || 'System'}
</Badge>
<span className="text-[10px] text-slate-500 italic">
{entry.action}
</span>
<span className="text-[10px] text-slate-400 ml-auto">
{formatDateTime(entry.timestamp || entry.createdAt)}
</span>
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
{entry.comments || entry.remarks || 'No remarks provided.'}
</div>
</div>
))}
</div>
)}
</div>
@ -938,6 +1015,64 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</CardContent>
</Card>
</TabsContent>
{/* Approval Summary Tab */}
{isNationalLevel && (
<TabsContent value="approvals">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Approval Summary</CardTitle>
<CardDescription>Comprehensive view of all approvals and remarks</CardDescription>
</div>
{permissions.canApprove && (
<Button onClick={() => handleAction('approve')} className="bg-green-600 hover:bg-green-700">
<Check className="w-4 h-4 mr-2" />
Approve Request
</Button>
)}
</CardHeader>
<CardContent>
<Table className="w-full border-collapse">
<TableHeader>
<TableRow className="bg-slate-50/50">
<TableHead className="min-w-[120px]">Stage</TableHead>
<TableHead className="min-w-[120px]">Approver</TableHead>
<TableHead className="min-w-[200px]">Action</TableHead>
<TableHead className="w-full min-w-[300px]">Remarks</TableHead>
<TableHead className="min-w-[180px] text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(resignationData?.timeline || []).length > 0 ? (
resignationData.timeline.map((entry: any, index: number) => (
<TableRow key={index}>
<TableCell className="font-medium">{entry.stage}</TableCell>
<TableCell>
<Badge variant="outline">{entry.user || 'System'}</Badge>
</TableCell>
<TableCell className="whitespace-normal break-words">{entry.action}</TableCell>
<TableCell className="whitespace-normal break-words">
{entry.remarks || entry.comments || '-'}
</TableCell>
<TableCell className="text-slate-500 whitespace-nowrap text-right">
{formatDateTime(entry.timestamp || entry.createdAt)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center py-6 text-slate-500">
No approval records found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
{/* Action Dialogs */}

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2, Upload } from 'lucide-react';
import { ArrowLeft, Check, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, AlertTriangle, Send, ShieldCheck, Loader2, Upload, PauseCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@ -16,6 +16,11 @@ import { terminationService } from '@/services/termination.service';
import { useNavigate } from 'react-router-dom';
import { API } from '@/api/API';
import { formatDateTime } from '@/components/ui/utils';
import { formatTerminationStatusLabel } from '@/lib/terminationDisplay';
import {
getJointRoundCutoffMsFromTimeline,
isAuditLogInCurrentJointRound
} from '@/lib/terminationJointReviewRound';
import { TERMINATION_DOCUMENT_TYPES, TERMINATION_STAGE_OPTIONS } from '@/lib/offboardingDocumentOptions';
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles';
@ -27,7 +32,7 @@ interface TerminationDetailsProps {
export function TerminationDetails({ terminationId, onBack, currentUser }: TerminationDetailsProps) {
const navigate = useNavigate();
const [actionDialog, setActionDialog] = useState<{ open: boolean; type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | null }>({ open: false, type: null });
const [actionDialog, setActionDialog] = useState<{ open: boolean; type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'hold' | null }>({ open: false, type: null });
const [remarks, setRemarks] = useState('');
const [assignToUser, setAssignToUser] = useState('');
const [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] });
@ -153,7 +158,41 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
};
// Check if user can push to F&F (DD Lead and above)
const canPushToFnF = currentUser && ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(currentUser.role || currentUser.roleCode);
const canPushToFnF = currentUser && ['DD Lead', 'DD Head', 'DD_HEAD', 'NBH', 'DD Admin', 'Super Admin'].includes(currentUser.role || currentUser.roleCode);
const stageAliases: Record<string, string[]> = {
'Submitted': ['Submitted'],
'RBM + DD-ZM Review': ['RBM + DD-ZM Review'],
'ZBH Review': ['ZBH Review'],
'DD Lead Review': ['DD Lead Review'],
'Legal Verification': ['Legal Verification'],
'DD Head Review': ['DD Head Review'],
'NBH Evaluation': ['NBH Evaluation'],
'Show Cause Notice (SCN)': ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'],
'Evaluation of Dealer SCN Response': ['Evaluation of Dealer SCN Response', 'Personal Hearing'],
'NBH Final Approval': ['NBH Final Approval'],
'CCO Approval': ['CCO Approval'],
'CEO Final Approval': ['CEO Final Approval'],
'Legal - Termination Letter': ['Legal - Termination Letter'],
'Dealer Terminated': ['Terminated', 'Dealer Terminated']
};
const stageSequence = [
'Submitted',
'RBM + DD-ZM Review',
'ZBH Review',
'DD Lead Review',
'Legal Verification',
'DD Head Review',
'NBH Evaluation',
'Show Cause Notice (SCN)',
'Evaluation of Dealer SCN Response',
'NBH Final Approval',
'CCO Approval',
'CEO Final Approval',
'Legal - Termination Letter',
'Dealer Terminated'
];
// Centralized Permissions Utility for Termination logic (Robust Validation)
const getTerminationPermissions = () => {
@ -164,28 +203,67 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const currentStage = terminationData.currentStage;
const status = terminationData.status;
const userRole = currentUser.role || currentUser.roleCode;
const isScnStage = ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(currentStage);
const isScnStage = ['Show Cause Notice (SCN)', 'SCN'].includes(currentStage);
const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Terminated'].includes(status) || currentStage === 'Terminated';
const isSettlementPhase = status === 'F&F Initiated' || currentStage === 'F&F Initiated' || status === 'Settled' || status === 'FNF_INITIATED';
const scnJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'scn_response_eval');
const rbmJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'rbm_review');
const isScnResponseEvalStage =
currentStage === 'Evaluation of Dealer SCN Response' || currentStage === 'Personal Hearing';
const jointRoundCutoffMs =
currentStage === 'RBM + DD-ZM Review' ? rbmJointRoundCutoffMs : isScnResponseEvalStage ? scnJointRoundCutoffMs : null;
const userHasApprovedJointly = auditLogs.some(log => {
if (!isAuditLogInCurrentJointRound(log, jointRoundCutoffMs)) return false;
const logUserId = log.userId || log.user?.id || log.actor?.id || log.actorId;
const isThisUser = String(logUserId) === String(currentUser.id);
const actionText = (log.action || log.description || '').toUpperCase();
const isPartialApprove = actionText.includes('PARTIAL_APPROVE') || actionText.includes('PARTIAL APPROVED');
const logStage = log.details?.stage || log.stage || '';
const isRbmReviewLog = logStage === 'RBM + DD-ZM Review' || (log.remarks || '').includes('Partial approval by');
const isPersonalHearingLog =
logStage === 'Evaluation of Dealer SCN Response' ||
logStage === 'Personal Hearing' ||
(log.remarks || '').includes('SCN Response Review by');
const stageMatches =
(currentStage === 'RBM + DD-ZM Review' && isRbmReviewLog) ||
(isScnResponseEvalStage && isPersonalHearingLog);
return isThisUser && isPartialApprove && stageMatches;
});
const isStage = (stageName: string) => {
const aliases = stageAliases[stageName] || [stageName];
return aliases.includes(currentStage);
};
const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === 'DD Admin' || (
(currentStage === 'RBM Review' && userRole === 'RBM') ||
(currentStage === 'ZBH Review' && userRole === 'ZBH') ||
(currentStage === 'DD Lead Review' && userRole === 'DD Lead') ||
(currentStage === 'Legal Verification' && userRole === 'Legal Admin') ||
(currentStage === 'DD Head Review' && userRole === 'DD Head') ||
(currentStage === 'NBH Evaluation' && userRole === 'NBH') ||
(currentStage === 'NBH Final Approval' && userRole === 'NBH') ||
(currentStage === 'CCO Approval' && userRole === 'CCO') ||
(currentStage === 'CEO Final Approval' && userRole === 'CEO') ||
(currentStage === 'Legal - Termination Letter' && userRole === 'Legal Admin')
(isStage('RBM + DD-ZM Review') && (userRole === 'RBM' || userRole === 'DD-ZM') && !userHasApprovedJointly) ||
(isStage('ZBH Review') && userRole === 'ZBH') ||
(isStage('DD Lead Review') && userRole === 'DD Lead') ||
(isStage('Legal Verification') && userRole === 'Legal Admin') ||
(isStage('DD Head Review') && (userRole === 'DD Head' || userRole === 'DD_HEAD')) ||
(isStage('NBH Evaluation') && userRole === 'NBH') ||
(isStage('Evaluation of Dealer SCN Response') && ['DD Lead', 'ZBH', 'RBM', 'DD Head', 'DD_HEAD'].includes(userRole) && !userHasApprovedJointly) ||
(isStage('NBH Final Approval') && userRole === 'NBH') ||
(isStage('CCO Approval') && userRole === 'CCO') ||
(isStage('CEO Final Approval') && userRole === 'CEO') ||
(isStage('Legal - Termination Letter') && userRole === 'Legal Admin')
);
return {
canApprove: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && ![...['NBH Final Approval', 'CCO Approval', 'CEO Final Approval'], 'Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(currentStage),
canApprove: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && ![...['NBH Final Approval', 'CCO Approval', 'CEO Final Approval'], 'Show Cause Notice (SCN)', 'SCN'].includes(currentStage),
canIssueSCN: currentStage === 'NBH Evaluation' && (userRole === 'NBH' || userRole === 'Super Admin') && !isFinalState,
canUploadSCNResponse: isScnStage && (['Legal Admin', 'DD Admin', 'Super Admin'].includes(userRole)) && !isFinalState,
canUploadSCNResponse: isScnStage && (['Legal Admin', 'DD Admin', 'DD Lead', 'Super Admin'].includes(userRole)) && !isFinalState,
canHold:
(isStage('NBH Evaluation') || isStage('NBH Final Approval')) &&
(userRole === 'NBH' || userRole === 'Super Admin') &&
status !== 'On Hold' &&
!isFinalState,
canFinalize: (
(currentStage === 'NBH Final Approval' && userRole === 'NBH') ||
(currentStage === 'CCO Approval' && userRole === 'CCO') ||
@ -205,39 +283,6 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const request = terminationData || {};
const isScnStage = ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'].includes(request.currentStage);
const stageAliases: Record<string, string[]> = {
'Submitted': ['Submitted', 'Request Initiated'],
'RBM Review': ['RBM Review'],
'ZBH Review': ['ZBH Review'],
'DD Lead Review': ['DD Lead Review'],
'Legal Verification': ['Legal Verification'],
'DD Head Review': ['DD Head Review'],
'NBH Evaluation': ['NBH Evaluation'],
'Show Cause Notice (SCN)': ['Show Cause Notice', 'Show Cause Notice (SCN)', 'SCN'],
'Personal Hearing': ['Personal Hearing'],
'NBH Final Approval': ['NBH Final Approval'],
'CCO Approval': ['CCO Approval'],
'CEO Final Approval': ['CEO Final Approval'],
'Legal - Termination Letter': ['Legal - Termination Letter'],
'Dealer Terminated': ['Terminated', 'Dealer Terminated']
};
const stageSequence = [
'Submitted',
'RBM Review',
'ZBH Review',
'DD Lead Review',
'Legal Verification',
'DD Head Review',
'NBH Evaluation',
'Show Cause Notice (SCN)',
'Personal Hearing',
'NBH Final Approval',
'CCO Approval',
'CEO Final Approval',
'Legal - Termination Letter',
'Dealer Terminated'
];
const resolveCanonicalStage = (currentStage?: string) => {
if (!currentStage) return '';
@ -301,21 +346,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
return acc;
}, {});
const getLatestStageTimelineEntry = (stageName: string) => {
const getStageTimelineEntries = (stageName: string) => {
const aliases = stageAliases[stageName] || [stageName];
const entries = (request.timeline || []).filter((entry: any) => aliases.includes(entry.stage));
const entries = (request.timeline || []).filter((entry: any) =>
aliases.includes(entry.stage) ||
(stageName === 'Submitted' && (entry.stage === 'Submitted' || entry.stage === 'Request Initiated'))
);
if (entries.length === 0) return null;
// Keep submitted row anchored to initiation details, not later stage-transition remarks.
if (stageName === 'Submitted') {
const initiatedEntry = entries.find((entry: any) =>
String(entry?.action || '').toLowerCase().includes('initiated')
);
return initiatedEntry || entries[0];
}
return entries[entries.length - 1];
// Sort by timestamp
return entries.sort((a: any, b: any) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
};
const progressStages = [
@ -332,9 +373,9 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
},
{
id: 2,
name: 'RBM Review',
status: getProgressStatus('RBM Review'),
description: 'Regional Business Manager review'
name: 'RBM + DD-ZM Review',
status: getProgressStatus('RBM + DD-ZM Review'),
description: 'Joint review and approval by RBM and DD-ZM'
},
{
id: 3,
@ -374,9 +415,9 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
},
{
id: 9,
name: 'Personal Hearing',
status: getProgressStatus('Personal Hearing'),
description: 'Evaluation of SCN response & Hearing'
name: 'Evaluation of Dealer SCN Response',
status: getProgressStatus('Evaluation of Dealer SCN Response'),
description: 'Joint evaluation of SCN response by DD-Lead, ZBH, RBM, and DD-Head'
},
{
id: 10,
@ -420,7 +461,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setStageDocumentsDialog({ open: true, stageName, documents });
};
const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke') => {
const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'hold') => {
setActionDialog({ open: true, type });
};
@ -448,27 +489,34 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setIsProcessing(true);
try {
if (actionType === 'approve' || actionType === 'sendBack' || actionType === 'withdrawal' || actionType === 'revoke') {
await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks);
let response: any;
if (actionType === 'approve' || actionType === 'sendBack' || actionType === 'withdrawal' || actionType === 'revoke' || actionType === 'hold') {
response = await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks);
} else if (actionType === 'pushfnf') {
// Logic for push to fnf (using existing service if available)
await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks);
response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks);
} else {
toast.error('Action logic not fully implemented for this type');
setIsProcessing(false);
return;
}
if (response && (response.success === false || response.ok === false)) {
console.error('[TerminationDetails] Action failed:', response);
toast.error(response.message || response.data?.message || 'Failed to perform action');
setIsProcessing(false);
return;
}
const actionMessages: Record<string, string> = {
approve: 'Request approved and forwarded',
withdrawal: 'Request withdrawn successfully',
sendBack: 'Request sent back for clarification',
assign: `Request assigned to ${assignToUser}`,
assign: `Request assigned successfully`,
pushfnf: 'Request pushed to F&F successfully',
revoke: 'Request revoked and withdrawn'
};
toast.success(actionMessages[actionType!] || 'Action completed');
toast.success(actionMessages[actionType!] || response?.message || 'Action completed');
setActionDialog({ open: false, type: null });
setRemarks('');
setAssignToUser('');
@ -527,7 +575,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
? 'bg-red-100 text-red-700 border-red-300'
: 'bg-yellow-100 text-yellow-700 border-yellow-300'
}>
{request.status === 'Settled' ? 'Completed' : (request.status || 'Pending')}
{request.status === 'Settled' ? 'Completed' : formatTerminationStatusLabel(request.status || 'Pending')}
</Badge>
</div>
</div>
@ -536,6 +584,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<Card className="border-amber-200 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4">
{(request.currentStage === 'Evaluation of Dealer SCN Response' || request.currentStage === 'Personal Hearing') && (
<Alert className="mb-4 bg-blue-50 border-blue-200">
<AlertTitle className="text-blue-800 text-sm font-semibold">Joint Review Stage</AlertTitle>
<AlertDescription className="text-blue-700 text-xs">
This stage requires a joint evaluation of the SCN response by the <strong>DD-Lead, ZBH, RBM, and DD-Head</strong>.
The case will only advance to NBH Final Approval once all four stakeholders have recorded their review.
</AlertDescription>
</Alert>
)}
{/* Primary Actions Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@ -590,6 +647,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
)}
</>
)}
{permissions.canHold && (
<Button
size="sm"
variant="outline"
className="border-orange-200 text-orange-700 hover:bg-orange-50"
onClick={() => handleAction('hold')}
>
<PauseCircle className="w-4 h-4 mr-2" />
Hold Decision
</Button>
)}
{permissions.canFinalize && (
<Button
size="sm"
@ -844,7 +912,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
<div className="space-y-4">
{progressStages.map((stage, index) => {
const documentCount = stageDocuments[stage.name]?.length || 0;
const timelineEntry = getLatestStageTimelineEntry(stage.name);
const stageEntries = getStageTimelineEntries(stage.name);
return (
<div key={stage.id} className="flex gap-4">
<div className="flex flex-col items-center">
@ -884,29 +952,59 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</button>
)}
</div>
{(timelineEntry?.timestamp || stage.date) && (
<div className="flex items-center gap-1 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
<span>{formatDateTime(timelineEntry?.timestamp || stage.date)}</span>
{(stageEntries[0]?.timestamp || stage.date) && (
<div className="flex items-center gap-1 text-xs text-slate-500 bg-slate-50 px-2 py-0.5 rounded-full border border-slate-100">
<Calendar className="w-3 h-3" />
<span>{formatDateTime(stageEntries[0]?.timestamp || stage.date)}</span>
</div>
)}
</div>
<p className="text-slate-600 text-sm">{stage.description}</p>
{timelineEntry && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<Badge className="bg-blue-100 text-blue-700 border-blue-300">{timelineEntry.action || 'Updated'}</Badge>
<span className="text-xs text-slate-500">by {timelineEntry.user || 'System'}</span>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
<div className="space-y-2">
<div>
<Label className="text-xs text-slate-600">Remarks:</Label>
<p className="text-sm text-slate-700 mt-1">{timelineEntry.remarks || 'No remarks provided.'}</p>
{stageEntries.length > 0 && (
<div className="mt-3 space-y-3">
{stageEntries.map((entry: any, entryIdx: number) => {
const rawRemarks = entry.remarks || entry.comments || '';
const isAttachment = rawRemarks?.startsWith('Attachment:');
const remarksContent = isAttachment
? rawRemarks.replace('Attachment:', '').trim()
: rawRemarks;
return (
<div key={entryIdx} className="group">
<div className="flex items-center gap-2 mb-1">
<Badge className={`
text-[10px] h-4 px-1.5
${entry.action?.toLowerCase().includes('rejected') || entry.action?.toLowerCase().includes('revoked')
? 'bg-red-100 text-red-700'
: entry.action?.toLowerCase().includes('approved')
? 'bg-emerald-100 text-emerald-700'
: 'bg-blue-100 text-blue-700'}
`}>
{entry.action || 'Action'}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">
by {entry.user || 'System'}{entry.role ? ` (${entry.role})` : ''} {formatDateTime(entry.timestamp)}
</span>
</div>
<div className={`
p-2.5 rounded-lg border text-sm
${isAttachment
? 'bg-amber-50/50 border-amber-100 text-amber-900'
: 'bg-slate-50 border-slate-100 text-slate-700'}
`}>
{isAttachment ? (
<div className="flex items-center gap-2">
<FileText className="w-3.5 h-3.5 text-amber-600" />
<span className="font-medium truncate">{remarksContent}</span>
</div>
) : (
<p className="leading-relaxed">{remarksContent || 'No remarks provided.'}</p>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
@ -1064,6 +1162,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{actionDialog.type === 'revoke' && 'Revoke Termination Request'}
{actionDialog.type === 'assign' && 'Assign to User'}
{actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'}
{actionDialog.type === 'hold' && 'Hold Termination Case'}
</DialogTitle>
<DialogDescription>
{actionDialog.type === 'assign'
@ -1126,6 +1225,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
className={
actionDialog.type === 'approve' ? 'bg-green-600 hover:bg-green-700' :
actionDialog.type === 'withdrawal' ? 'bg-red-600 hover:bg-red-700' :
actionDialog.type === 'hold' ? 'bg-orange-600 hover:bg-orange-700' :
'bg-blue-600 hover:bg-blue-700'
}
>

View File

@ -23,6 +23,7 @@ import {
} from "@/components/ui/pagination";
import { User } from '@/lib/mock-data';
import { toast } from 'sonner';
import { formatTerminationStatusLabel } from '@/lib/terminationDisplay';
interface TerminationPageProps {
currentUser: User | null;
@ -51,6 +52,8 @@ const getStatusColor = (status: string) => {
return 'bg-blue-100 text-blue-700 border-blue-300';
};
const formatStatus = formatTerminationStatusLabel;
export function TerminationPage({ currentUser, onViewDetails }: TerminationPageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dealers, setDealers] = useState<any[]>([]);
@ -103,7 +106,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
};
useEffect(() => {
if (!isDialogOpen || !isDDLead) return;
if (!isDialogOpen || !canCreateTermination) return;
let cancelled = false;
(async () => {
@ -254,7 +257,8 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
}
};
const isDDLead = currentUser?.role === 'DD Lead';
const allowedRoles = ['DD Lead', 'ASM', 'DD Admin', 'DD AM', 'Super Admin'];
const canCreateTermination = currentUser?.role && allowedRoles.includes(currentUser.role);
// Map terminations to tab-specific views (already filtered by backend, but need variables for render)
const openRequests = activeTab === 'open' || activeTab === 'all' ? terminations : [];
@ -312,14 +316,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<CardTitle>Termination Requests</CardTitle>
<CardDescription>
Manage dealer termination proceedings and legal compliance
{!isDDLead && (
<span className="block mt-1 text-red-600">
Note: Only DD Lead can create termination requests. Current role: {currentUser?.role || 'Not logged in'}
</span>
)}
</CardDescription>
</div>
{isDDLead && (
{canCreateTermination && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-red-600 hover:bg-red-700">
@ -408,7 +407,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<SelectContent>
<SelectItem value="Working Capital">Working Capital</SelectItem>
<SelectItem value="Performance Issues">Performance Issues</SelectItem>
<SelectItem value="Unethical Practical">Unethical Practical</SelectItem>
<SelectItem value="Unethical Practice">Unethical Practice</SelectItem>
<SelectItem value="Unforeseen Circumstances">Unforeseen Circumstances</SelectItem>
<SelectItem value="Others">Others</SelectItem>
</SelectContent>
@ -495,12 +494,12 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getSeverityColor(request.severity || 'Medium')}>
{request.severity || 'Normal'}
</Badge>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
@ -518,7 +517,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</div>
<div>
<p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p>
<p>{formatStatus(request.currentStage)}</p>
</div>
<div>
<p className="text-slate-600">Proposed LWD</p>
@ -570,9 +569,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
@ -586,7 +585,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</div>
<div>
<p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p>
<p>{formatStatus(request.currentStage)}</p>
</div>
<div>
<p className="text-slate-600">Submitted On</p>
@ -632,9 +631,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">

View File

@ -4,24 +4,14 @@
const PRIVATE_LIMITED = 'Private Limited';
const LLP = 'LLP';
const LLP_CONVERSION = 'LLP Conversion';
const PARTNERSHIP = 'Partnership';
const PARTNERSHIP_CHANGE = 'Partnership Change';
const PROPRIETORSHIP = 'Proprietorship';
const DIRECTOR_CHANGE = 'Director Change';
const OWNERSHIP_TRANSFER = 'Ownership Transfer';
const COMPANY_FORMATION = 'Company Formation';
const ALL: string[] = [
PROPRIETORSHIP,
PARTNERSHIP,
LLP_CONVERSION,
LLP,
PRIVATE_LIMITED,
COMPANY_FORMATION,
OWNERSHIP_TRANSFER,
PARTNERSHIP_CHANGE,
DIRECTOR_CHANGE
PRIVATE_LIMITED
];
export function isRegisteredConstitutionalChangeType(value: string): boolean {
@ -44,14 +34,9 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
) {
return PRIVATE_LIMITED;
}
if (compact.includes('llp') && compact.includes('conversion')) return LLP_CONVERSION;
if (compact.includes('llp')) return LLP;
if (compact.includes('partnership') && compact.includes('change')) return PARTNERSHIP_CHANGE;
if (compact.includes('partnership')) return PARTNERSHIP;
if (compact.includes('proprietorship') || compact === 'sole proprietorship') return PROPRIETORSHIP;
if (compact.includes('director')) return DIRECTOR_CHANGE;
if (compact.includes('ownership') && compact.includes('transfer')) return OWNERSHIP_TRANSFER;
if (compact.includes('company') && compact.includes('formation')) return COMPANY_FORMATION;
const exact = ALL.find((v) => v.toLowerCase() === s.toLowerCase());
return exact || null;
}

View File

@ -2,20 +2,27 @@
export type UserRole =
| 'DD-ZM'
| 'DD_ZM'
| 'RBM'
| 'DD'
| 'ZBH'
| 'DD Lead'
| 'DD_LEAD'
| 'DD Head'
| 'DD_HEAD'
| 'NBH'
| 'DD Admin'
| 'DD_ADMIN'
| 'Legal Admin'
| 'LEGAL_ADMIN'
| 'Super Admin'
| 'SUPER_ADMIN'
| 'DD AM'
| 'FDD'
| 'DDL'
| 'Finance'
| 'Finance Admin'
| 'FINANCE_ADMIN'
| 'Dealer'
| 'ASM'
| 'CCO'

View File

@ -2,13 +2,15 @@ export const RESIGNATION_DOCUMENT_TYPES = [
"Resignation Letter",
"Dealer Undertaking",
"Approval Note",
"Legal Communication",
"Resignation Acceptance Letter",
"Handover Document",
"Settlement Supporting Document",
"PPT Presentation",
"Other",
] as const;
export const RESIGNATION_STAGE_OPTIONS = [
"Request Submitted",
"ASM",
"RBM",
"ZBH",
@ -32,7 +34,7 @@ export const TERMINATION_DOCUMENT_TYPES = [
export const TERMINATION_STAGE_OPTIONS = [
"Submitted",
"RBM Review",
"RBM + DD-ZM Review",
"ZBH Review",
"DD Lead Review",
"Legal Verification",

View File

@ -0,0 +1,5 @@
/** Legacy workflow used "Personal Hearing"; UI and newer APIs use "SCN Response Evaluation" wording. */
export function formatTerminationStatusLabel(value: string | null | undefined): string {
if (!value) return 'Pending';
return value.replace(/Personal Hearing/gi, 'SCN Response Evaluation');
}

View File

@ -0,0 +1,64 @@
/** Mirrors backend terminationJointReviewRound.util.ts — keep send-back / reconsider detection aligned. */
const norm = (s: string | undefined | null) =>
String(s || '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
const SCN_CANONICAL = 'Evaluation of Dealer SCN Response';
export const isScnResponseJointTargetStage = (targetStage: string | undefined | null): boolean => {
const n = norm(targetStage);
if (!n) return false;
if (n === norm(SCN_CANONICAL)) return true;
if (n.includes('evaluation') && n.includes('scn') && n.includes('response')) return true;
if (n.includes('personal hearing')) return true;
return false;
};
export const isRbmJointTargetStage = (targetStage: string | undefined | null): boolean => {
const n = norm(targetStage);
return n.includes('rbm') && (n.includes('dd-zm') || n.includes('dd zm'));
};
function isSendBackOrReconsiderTimelineAction(action: string | undefined | null): boolean {
const a = norm(action);
return (
a.includes('sent back') ||
a.includes('send back') ||
a.includes('reconsider') ||
a.includes('reconsideration')
);
}
export type JointRoundTimelineMode = 'scn_response_eval' | 'rbm_review';
export function getJointRoundCutoffMsFromTimeline(
timeline: unknown,
mode: JointRoundTimelineMode
): number | null {
if (!Array.isArray(timeline) || timeline.length === 0) return null;
const matcher = mode === 'scn_response_eval' ? isScnResponseJointTargetStage : isRbmJointTargetStage;
const arr = timeline as Record<string, unknown>[];
for (let i = arr.length - 1; i >= 0; i--) {
const e = arr[i];
if (!isSendBackOrReconsiderTimelineAction(e?.action as string)) continue;
if (!matcher(e?.targetStage as string)) continue;
const t = e?.timestamp != null ? new Date(e.timestamp as string | number | Date).getTime() : NaN;
if (!Number.isNaN(t)) return t;
}
return null;
}
export function auditLogTimestampMs(log: { createdAt?: string | Date; timestamp?: string | Date }): number {
const raw = log.createdAt ?? log.timestamp;
if (raw == null) return 0;
const t = new Date(raw).getTime();
return Number.isNaN(t) ? 0 : t;
}
export function isAuditLogInCurrentJointRound(log: { createdAt?: string | Date; timestamp?: string | Date }, cutoffMs: number | null): boolean {
if (cutoffMs == null) return true;
return auditLogTimestampMs(log) >= cutoffMs;
}

View File

@ -115,6 +115,11 @@ export const onboardingService = {
if (!response.ok) throw new Error(response.data?.message || 'Failed to perform bulk conversion');
return response.data;
},
sendBulkReminders: async (applicationIds: string[]) => {
const response: any = await API.sendBulkReminders({ applicationIds });
if (!response.ok) throw new Error(response.data?.message || 'Failed to send reminders');
return response.data;
},
createDealer: async (data: any) => {
const response: any = await API.createDealer(data);
if (!response.ok) throw new Error(response.data?.message || 'Failed to create dealer profile');

View File

@ -10,7 +10,10 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #daaa00;
--primary: #da291c;
--primary-600: #da291c;
--primary-700: #b82216;
--primary-50: #fef2f2;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
@ -99,6 +102,9 @@
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-50: var(--primary-50);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
@ -298,6 +304,29 @@ html {
scrollbar-color: #e2e8f0 transparent;
}
/* Extra-thin, subtle horizontal scrollbar (documents modal tables) */
.custom-scrollbar-x-slim::-webkit-scrollbar {
height: 3px;
}
.custom-scrollbar-x-slim::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar-x-slim::-webkit-scrollbar-thumb {
background: #f1f5f9;
border-radius: 9999px;
}
.custom-scrollbar-x-slim::-webkit-scrollbar-thumb:hover {
background: #e2e8f0;
}
.custom-scrollbar-x-slim {
scrollbar-width: thin;
scrollbar-color: #f1f5f9 transparent;
}
/* Extra-thin, light vertical scrollbar (e.g. modals) */
.custom-scrollbar-slim::-webkit-scrollbar {
width: 2px;