nomenclature changed for the requests and the changes asked in the demo Active / Inactive Opportunity will be labeled as Opportunity with value as Yes or No auto assign to DD_AM , auto assignment configuration and kt matrix configuration added
This commit is contained in:
parent
bb3e78873f
commit
5fbf06d827
@ -46,6 +46,7 @@ import { DealerConstitutionalChangePage } from '@/features/constitutional/pages/
|
||||
import { DealerRelocationPage } from '@/features/relocation/pages/DealerRelocationPage';
|
||||
import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder';
|
||||
import QuestionnaireList from '@/components/admin/QuestionnaireList';
|
||||
import InterviewConfigManagement from '@/features/master/components/InterviewConfigManagement';
|
||||
import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage';
|
||||
import { NotificationsPage } from '@/pages/NotificationsPage';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
@ -265,6 +266,11 @@ export default function App() {
|
||||
<Route path="/questionnaire-builder" element={<QuestionnaireBuilder />} />
|
||||
<Route path="/questionnaire-builder/:id" element={<QuestionnaireBuilder />} />
|
||||
<Route path="/questionnaires" element={<QuestionnaireList />} />
|
||||
<Route path="/interview-configs" element={
|
||||
hasRole(['Super Admin', 'DD Admin', 'DD Head'])
|
||||
? <InterviewConfigManagement />
|
||||
: <Navigate to="/dashboard" />
|
||||
} />
|
||||
|
||||
{/* HR/Finance Modules (Simplified for brevity, following pattern) */}
|
||||
<Route path="/resignation" element={
|
||||
|
||||
@ -33,13 +33,13 @@ export const API = {
|
||||
saveZonalManager: (data: any) => client.post('/master/zonal-managers', data),
|
||||
getDDLeads: () => client.get('/master/dd-leads'),
|
||||
saveDDLead: (data: any) => client.post('/master/dd-leads', data),
|
||||
getManagersByRole: (params: any) => client.get('/master/managers', { params }),
|
||||
getManagersByRole: (params: any) => client.get('/master/managers', params),
|
||||
|
||||
|
||||
// Onboarding
|
||||
submitApplication: (data: any) => client.post('/onboarding/apply', data),
|
||||
exportApplicationResponses: (params: { applicationIds: string }) => client.get('/onboarding/applications/export-responses', params),
|
||||
getApplications: () => client.get('/onboarding/applications'),
|
||||
getApplications: (params?: any) => client.get('/onboarding/applications', params),
|
||||
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data),
|
||||
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
|
||||
updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data),
|
||||
@ -52,6 +52,8 @@ export const API = {
|
||||
updateArchitectureStatus: (applicationId: string, status: string, remarks?: string) => client.post(`/onboarding/applications/${applicationId}/architecture-status`, { status, remarks }),
|
||||
generateDealerCodes: (applicationId: string) => client.post(`/onboarding/applications/${applicationId}/generate-codes`),
|
||||
updateApplicationStatus: (id: string, data: any) => client.put(`/onboarding/applications/${id}/status`, data),
|
||||
convertToOpportunity: (id: string, data?: any) => client.post(`/onboarding/applications/${id}/convert-to-opportunity`, data),
|
||||
bulkConvertToOpportunity: (data: any) => client.post('/onboarding/applications/bulk-convert-to-opportunity', data),
|
||||
retriggerEvaluators: (id: string) => client.post(`/onboarding/applications/${id}/retrigger-evaluators`),
|
||||
getSecurityDeposit: (applicationId: string) => client.get(`/loa/security-deposit/${applicationId}`),
|
||||
updateSecurityDeposit: (data: any) => client.post('/loa/security-deposit', data),
|
||||
@ -100,7 +102,7 @@ export const API = {
|
||||
deleteUser: (id: string) => client.delete(`/admin/users/${id}`),
|
||||
|
||||
// Dealer & Outlets
|
||||
getDealers: (params?: { onboarded?: string; activeOnly?: string }) => client.get('/dealer', { params }),
|
||||
getDealers: (params?: { onboarded?: string; activeOnly?: string }) => client.get('/dealer', params),
|
||||
createDealer: (data: any) => client.post('/dealer', data),
|
||||
getDealerById: (id: string) => client.get(`/dealer/${id}`),
|
||||
updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data),
|
||||
@ -156,7 +158,7 @@ export const API = {
|
||||
rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data),
|
||||
withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }),
|
||||
|
||||
getTerminations: () => client.get('/termination'),
|
||||
getTerminations: (params?: any) => client.get('/termination', params),
|
||||
createTermination: (data: any) => client.post('/termination', data),
|
||||
updateTermination: (id: string, data: any) => client.post(`/termination/${id}/status`, data),
|
||||
|
||||
@ -179,7 +181,7 @@ export const API = {
|
||||
updateLineItem: (itemId: string, data: any) => client.put(`/settlement/fnf/line-items/${itemId}`, data),
|
||||
deleteLineItem: (itemId: string) => client.delete(`/settlement/fnf/line-items/${itemId}`),
|
||||
|
||||
getRelocationRequests: () => client.get('/relocation'),
|
||||
getRelocationRequests: (params?: any) => client.get('/relocation', params),
|
||||
getRelocationRequestById: (id: string) => client.get(`/relocation/${id}`),
|
||||
createRelocationRequest: (data: any) => client.post('/relocation', data),
|
||||
updateRelocationRequest: (id: string, action: string, data?: any) => client.post(`/relocation/${id}/action`, { action, ...data }),
|
||||
@ -190,7 +192,7 @@ export const API = {
|
||||
rejectRelocationDocument: (id: string, documentId: string, data?: any) =>
|
||||
client.post(`/relocation/${id}/documents/${documentId}/reject`, data || {}),
|
||||
|
||||
getConstitutionalChanges: () => client.get('/constitutional-change'),
|
||||
getConstitutionalChanges: (params?: any) => client.get('/constitutional-change', params),
|
||||
getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'),
|
||||
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
|
||||
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
|
||||
@ -208,6 +210,15 @@ export const API = {
|
||||
saveSlaConfig: (data: any) => client.post('/master/sla-configs', data),
|
||||
initializeDefaultSlas: () => client.post('/master/sla-configs/initialize'),
|
||||
|
||||
// Interview Configs
|
||||
getInterviewConfigs: (params?: any) => client.get('/master/interview-configs', params),
|
||||
getInterviewConfigById: (id: string) => client.get(`/master/interview-configs/${id}`),
|
||||
getInterviewConfigByType: (configType: string) => client.get(`/master/interview-configs/active/${configType}`),
|
||||
createInterviewConfig: (data: any) => client.post('/master/interview-configs', data),
|
||||
updateInterviewConfig: (id: string, data: any) => client.put(`/master/interview-configs/${id}`, data),
|
||||
deleteInterviewConfig: (id: string) => client.delete(`/master/interview-configs/${id}`),
|
||||
initializeDefaultInterviewConfigs: () => client.post('/master/interview-configs/initialize'),
|
||||
|
||||
// System Configs
|
||||
getSystemConfigs: (params?: any) => client.get('/master/system-configs', params),
|
||||
saveSystemConfig: (data: any) => client.post('/master/system-configs', data),
|
||||
|
||||
@ -3,10 +3,27 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { RefreshCw, Settings2, Edit2, Save, X } from 'lucide-react';
|
||||
import { RefreshCw, Settings2, Edit2, Save, X, Plus } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '../ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '../ui/dropdown-menu';
|
||||
import { ROLES } from '../../lib/constants';
|
||||
import { approvalPolicyService } from '../../services/approvalPolicy.service';
|
||||
|
||||
type ApprovalMode = 'ALL' | 'MIN_N' | 'ROLE_MANDATORY';
|
||||
@ -19,13 +36,64 @@ interface Policy {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const AVAILABLE_ROLES = Object.values(ROLES).sort();
|
||||
|
||||
const STAGE_OPTIONS = [
|
||||
{
|
||||
label: 'Onboarding',
|
||||
stages: [
|
||||
{ label: 'General Info', value: 'ONBOARDING_GENERAL' },
|
||||
{ label: 'KYC Verification', value: 'ONBOARDING_KYC' },
|
||||
{ label: 'Level 1 Interview', value: 'LEVEL_1_INTERVIEW' },
|
||||
{ label: 'Level 2 Interview', value: 'LEVEL_2_INTERVIEW' },
|
||||
{ label: 'Level 3 Interview', value: 'LEVEL_3_INTERVIEW' },
|
||||
{ label: 'FDD Verification', value: 'FDD_VERIFICATION' },
|
||||
{ label: 'LOI Approval', value: 'LOI_APPROVAL' },
|
||||
{ label: 'LOA Approval', value: 'LOA_APPROVAL' },
|
||||
{ label: 'Architecture Team Assigned', value: 'ARCHITECTURE_ASSIGNMENT' },
|
||||
{ label: 'Architecture Doc Upload', value: 'ARCHITECTURE_DOCUMENT_UPLOAD' },
|
||||
{ label: 'Statutory Verification', value: 'STATUTORY_CHECK' },
|
||||
{ label: 'EOR Verification', value: 'EOR_VERIFICATION' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Offboarding (Resignation)',
|
||||
stages: [
|
||||
{ label: 'Regional Review', value: 'RESIGNATION_REGIONAL_REVIEW' },
|
||||
{ label: 'ZM Review', value: 'RESIGNATION_ZM_REVIEW' },
|
||||
{ label: 'ZBH Review', value: 'RESIGNATION_ZBH_REVIEW' },
|
||||
{ label: 'Finance Clearance', value: 'RESIGNATION_FINANCE_REVIEW' },
|
||||
{ label: 'DDL Review', value: 'RESIGNATION_DDL_REVIEW' },
|
||||
{ label: 'Final Approval', value: 'RESIGNATION_APPROVED' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Termination',
|
||||
stages: [
|
||||
{ label: 'RBM Review', value: 'TERMINATION_HEARING' },
|
||||
{ label: 'DDL Evaluation', value: 'TERMINATION_REVIEW' },
|
||||
{ label: 'Legal Verification', value: 'TERMINATION_LEGAL_VERIFICATION' },
|
||||
{ label: 'Final NBH Approval', value: 'TERMINATION_CLOSED' },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Relocation & CC',
|
||||
stages: [
|
||||
{ label: 'Relocation ASM Review', value: 'RELOCATION_ASM_REVIEW' },
|
||||
{ label: 'Relocation Head Approval', value: 'RELOCATION_COMPLETED' },
|
||||
{ label: 'CC Legal Review', value: 'CONSTITUTIONAL_LEGAL_REVIEW' },
|
||||
{ label: 'CC Head Approval', value: 'CONSTITUTIONAL_APPROVED' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function ApprovalPoliciesPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [policies, setPolicies] = useState<Policy[]>([]);
|
||||
const [editingCode, setEditingCode] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Policy | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newPolicy, setNewPolicy] = useState<Policy>({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [isCustomStage, setIsCustomStage] = useState(false);
|
||||
const [draft, setDraft] = useState<Policy>({
|
||||
stageCode: '',
|
||||
minApprovals: 1,
|
||||
approvalMode: 'MIN_N',
|
||||
@ -52,26 +120,37 @@ export function ApprovalPoliciesPage() {
|
||||
fetchPolicies();
|
||||
}, []);
|
||||
|
||||
const startEdit = (policy: Policy) => {
|
||||
setEditingCode(policy.stageCode);
|
||||
const openCreateModal = () => {
|
||||
setIsEditMode(false);
|
||||
setIsCustomStage(false);
|
||||
setDraft({
|
||||
stageCode: '',
|
||||
minApprovals: 1,
|
||||
approvalMode: 'MIN_N',
|
||||
requiredRoles: [],
|
||||
isActive: true
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (policy: Policy) => {
|
||||
setIsEditMode(true);
|
||||
setIsCustomStage(true); // Always treat existings as "custom entry" if not found in list, for UI consistency
|
||||
setDraft({
|
||||
stageCode: policy.stageCode,
|
||||
minApprovals: policy.minApprovals || 1,
|
||||
approvalMode: policy.approvalMode || 'MIN_N',
|
||||
requiredRoles: Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [],
|
||||
approvalMode: (policy.approvalMode as ApprovalMode) || 'MIN_N',
|
||||
requiredRoles: Array.isArray(policy.requiredRoles) ? [...policy.requiredRoles] : [],
|
||||
isActive: policy.isActive !== false
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingCode(null);
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!draft) return;
|
||||
const savePolicy = async () => {
|
||||
if (!draft.stageCode.trim()) return;
|
||||
|
||||
if (draft.approvalMode === 'ROLE_MANDATORY' && draft.requiredRoles.length > 0 && draft.minApprovals > draft.requiredRoles.length) {
|
||||
alert('In ROLE_MANDATORY mode, min approvals cannot exceed the number of required roles.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -81,42 +160,13 @@ export function ApprovalPoliciesPage() {
|
||||
requiredRoles: draft.requiredRoles,
|
||||
isActive: draft.isActive
|
||||
};
|
||||
const res = await approvalPolicyService.savePolicy(draft.stageCode, payload);
|
||||
if (res?.success) {
|
||||
await fetchPolicies();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const resetNewPolicy = () => {
|
||||
setNewPolicy({
|
||||
stageCode: '',
|
||||
minApprovals: 1,
|
||||
approvalMode: 'MIN_N',
|
||||
requiredRoles: [],
|
||||
isActive: true
|
||||
});
|
||||
};
|
||||
|
||||
const createPolicy = async () => {
|
||||
const stageCode = newPolicy.stageCode.trim().toUpperCase().replace(/\s+/g, '_');
|
||||
if (!stageCode) return;
|
||||
|
||||
if (newPolicy.approvalMode === 'ROLE_MANDATORY' && newPolicy.requiredRoles.length > 0 && newPolicy.minApprovals > newPolicy.requiredRoles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
minApprovals: Number(newPolicy.minApprovals) || 1,
|
||||
approvalMode: newPolicy.approvalMode,
|
||||
requiredRoles: newPolicy.requiredRoles,
|
||||
isActive: newPolicy.isActive
|
||||
};
|
||||
const stageCode = draft.stageCode.trim().toUpperCase().replace(/\s+/g, '_');
|
||||
const res = await approvalPolicyService.savePolicy(stageCode, payload);
|
||||
|
||||
if (res?.success) {
|
||||
await fetchPolicies();
|
||||
resetNewPolicy();
|
||||
setCreating(false);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -130,207 +180,278 @@ export function ApprovalPoliciesPage() {
|
||||
</h1>
|
||||
<p className="text-slate-500">Configure stage-level approvers, mode, and minimum approvals.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchPolicies} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchPolicies} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button className="bg-amber-600 hover:bg-amber-700" onClick={openCreateModal}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add New Policy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200 overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle>Configured Stages</CardTitle>
|
||||
{!creating ? (
|
||||
<Button className="bg-amber-600 hover:bg-amber-700" onClick={() => setCreating(true)}>
|
||||
Add New Policy
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => { setCreating(false); resetNewPolicy(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="bg-amber-600 hover:bg-amber-700" onClick={createPolicy}>
|
||||
Save New Policy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card className="border-slate-200 overflow-hidden shadow-sm">
|
||||
<CardHeader className="bg-slate-50 px-6 py-4 border-b">
|
||||
<CardTitle className="text-lg font-semibold text-slate-800">Configured Stages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{creating && (
|
||||
<div className="border-b bg-amber-50/40 p-4 grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label>Stage Code</Label>
|
||||
<Input
|
||||
placeholder="e.g. ARCHITECTURE_APPROVAL"
|
||||
value={newPolicy.stageCode}
|
||||
onChange={(e) => setNewPolicy({ ...newPolicy, stageCode: e.target.value })}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">Stage Code</TableHead>
|
||||
<TableHead>Approval Mode</TableHead>
|
||||
<TableHead>Min Appr.</TableHead>
|
||||
<TableHead className="min-w-[300px]">Required Roles</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedPolicies.map((policy) => (
|
||||
<TableRow key={policy.stageCode} className="hover:bg-slate-50/50 transition-colors">
|
||||
<TableCell className="font-mono text-xs font-semibold text-slate-700">{policy.stageCode}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-medium px-2 py-0.5 text-slate-600 border-slate-300 uppercase">
|
||||
{policy.approvalMode}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full text-xs">
|
||||
{policy.minApprovals}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1.5 py-1">
|
||||
{(policy.requiredRoles || []).map((role) => (
|
||||
<Badge key={role} variant="secondary" className="bg-slate-100 text-slate-600 border-transparent hover:bg-slate-200 text-[11px] font-normal">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${policy.isActive ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]' : 'bg-slate-400'}`} />
|
||||
<span className={`text-xs font-medium ${policy.isActive ? 'text-green-700' : 'text-slate-500'}`}>
|
||||
{policy.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-amber-600 hover:text-amber-700 hover:bg-amber-50 h-8 px-2"
|
||||
onClick={() => openEditModal(policy)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unified Edit/Create Modal */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="sm:max-w-[480px] overflow-visible">
|
||||
<DialogHeader className="gap-1 pb-2 border-b">
|
||||
<DialogTitle className="text-base flex items-center gap-2">
|
||||
{isEditMode ? <Edit2 className="w-4 h-4 text-amber-600" /> : <Plus className="w-4 h-4 text-amber-600" />}
|
||||
{isEditMode ? 'Edit Policy' : 'Create New Policy'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[11px]">
|
||||
{isEditMode
|
||||
? `Update configuration for stage ${draft.stageCode}.`
|
||||
: 'Define approval requirements for a workflow stage.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-4">
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight pt-2">Stage</Label>
|
||||
<div className="col-span-3 space-y-2">
|
||||
{!isCustomStage && !isEditMode ? (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={draft.stageCode}
|
||||
onValueChange={(val) => {
|
||||
if (val === 'CUSTOM') {
|
||||
setIsCustomStage(true);
|
||||
setDraft({ ...draft, stageCode: '' });
|
||||
} else {
|
||||
setDraft({ ...draft, stageCode: val });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full h-8 text-[11px] font-medium border-slate-200">
|
||||
<SelectValue placeholder="Select a workflow stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{STAGE_OPTIONS.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel className="text-[10px] uppercase text-slate-400 font-bold bg-slate-50 px-2 py-1">{group.label}</SelectLabel>
|
||||
{group.stages.map((stage) => (
|
||||
<SelectItem key={stage.value} value={stage.value} className="text-xs">
|
||||
{stage.label} <span className="text-[10px] text-slate-400 font-mono ml-1">({stage.value})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
<SelectGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<SelectItem value="CUSTOM" className="text-xs font-semibold text-amber-600 italic">
|
||||
+ Enter Custom Stage Code
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="w-full font-mono text-xs h-8 pr-8"
|
||||
placeholder="e.g. FNF_SETTLEMENT"
|
||||
disabled={isEditMode}
|
||||
value={draft.stageCode}
|
||||
onChange={(e) => setDraft({ ...draft, stageCode: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
{!isEditMode && (
|
||||
<X
|
||||
className="w-3 h-3 absolute right-2.5 top-2.5 text-slate-400 cursor-pointer hover:text-slate-600"
|
||||
onClick={() => {
|
||||
setIsCustomStage(false);
|
||||
setDraft({ ...draft, stageCode: '' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Approval Mode</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Mode</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={newPolicy.approvalMode}
|
||||
onValueChange={(val: ApprovalMode) => setNewPolicy({ ...newPolicy, approvalMode: val })}
|
||||
value={draft.approvalMode}
|
||||
onValueChange={(val: ApprovalMode) => setDraft({ ...draft, approvalMode: val })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger className="w-full h-8 text-[11px] font-medium border-slate-200">
|
||||
<SelectValue placeholder="Select mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">ALL</SelectItem>
|
||||
<SelectItem value="MIN_N">MIN_N</SelectItem>
|
||||
<SelectItem value="ROLE_MANDATORY">ROLE_MANDATORY</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Min Approvals</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={newPolicy.minApprovals}
|
||||
onChange={(e) => setNewPolicy({ ...newPolicy, minApprovals: Number(e.target.value || 1) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Required Roles (comma separated)</Label>
|
||||
<Input
|
||||
placeholder="DD Head, NBH"
|
||||
value={newPolicy.requiredRoles.join(', ')}
|
||||
onChange={(e) =>
|
||||
setNewPolicy({
|
||||
...newPolicy,
|
||||
requiredRoles: e.target.value.split(',').map((r) => r.trim()).filter(Boolean)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={newPolicy.isActive ? 'active' : 'inactive'}
|
||||
onValueChange={(val) => setNewPolicy({ ...newPolicy, isActive: val === 'active' })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="ALL" className="text-xs">ALL (Everyone must approve)</SelectItem>
|
||||
<SelectItem value="MIN_N" className="text-xs">MIN_N (First N approvals count)</SelectItem>
|
||||
<SelectItem value="ROLE_MANDATORY" className="text-xs">ROLE_MANDATORY (Hierarchy base)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50">
|
||||
<TableRow>
|
||||
<TableHead>Stage Code</TableHead>
|
||||
<TableHead>Approval Mode</TableHead>
|
||||
<TableHead>Min Approvals</TableHead>
|
||||
<TableHead>Required Roles</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedPolicies.map((policy) => {
|
||||
const isEditing = editingCode === policy.stageCode && draft;
|
||||
return (
|
||||
<TableRow key={policy.stageCode}>
|
||||
<TableCell className="font-medium">{policy.stageCode}</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={draft.approvalMode}
|
||||
onValueChange={(val: ApprovalMode) => setDraft({ ...draft, approvalMode: val })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">ALL</SelectItem>
|
||||
<SelectItem value="MIN_N">MIN_N</SelectItem>
|
||||
<SelectItem value="ROLE_MANDATORY">ROLE_MANDATORY</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge variant="outline">{policy.approvalMode}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.minApprovals}
|
||||
onChange={(e) => setDraft({ ...draft, minApprovals: Number(e.target.value || 1) })}
|
||||
className="w-28"
|
||||
/>
|
||||
) : (
|
||||
policy.minApprovals
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[420px]">
|
||||
{isEditing ? (
|
||||
<div className="space-y-1">
|
||||
<Label>Comma separated roles</Label>
|
||||
<Input
|
||||
value={draft.requiredRoles.join(', ')}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
requiredRoles: e.target.value
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(policy.requiredRoles || []).map((role) => (
|
||||
<Badge key={role} variant="secondary">{role}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={draft.isActive ? 'active' : 'inactive'}
|
||||
onValueChange={(val) => setDraft({ ...draft, isActive: val === 'active' })}
|
||||
>
|
||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge className={policy.isActive ? 'bg-green-600' : 'bg-slate-500'}>
|
||||
{policy.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isEditing ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" className="bg-amber-600 hover:bg-amber-700" onClick={saveEdit}>
|
||||
<Save className="w-4 h-4 mr-1" /> Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={cancelEdit}>
|
||||
<X className="w-4 h-4 mr-1" /> Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => startEdit(policy)}>
|
||||
<Edit2 className="w-4 h-4 mr-1" /> Edit
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Min Appr.</Label>
|
||||
<div className="col-span-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.minApprovals}
|
||||
onChange={(e) => setDraft({ ...draft, minApprovals: Number(e.target.value || 1) })}
|
||||
className="w-20 h-8 text-xs border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight pt-2">Roles</Label>
|
||||
<div className="col-span-3 space-y-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full justify-between h-8 text-[11px] text-slate-500 font-normal border-slate-200">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Plus className="w-3 h-3 text-amber-600" />
|
||||
<span>Add Roles...</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-transparent text-[9px] px-1 h-4">
|
||||
{draft.requiredRoles.length}
|
||||
</Badge>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[280px] max-h-[250px] overflow-y-auto shadow-xl" align="start">
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-slate-400 font-bold px-2 py-1 border-b">Available Roles</DropdownMenuLabel>
|
||||
{AVAILABLE_ROLES.map((role) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={role}
|
||||
className="text-xs"
|
||||
checked={draft.requiredRoles.includes(role)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setDraft({ ...draft, requiredRoles: [...draft.requiredRoles, role] });
|
||||
} else {
|
||||
setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex flex-wrap gap-1 min-h-[32px] p-2 rounded-sm bg-slate-50/50 border border-slate-100">
|
||||
{draft.requiredRoles.map((role) => (
|
||||
<Badge key={role} variant="secondary" className="bg-white text-slate-600 border-slate-200 text-[10px] font-normal flex items-center gap-1 py-0 px-1.5 h-5 transition-all">
|
||||
{role}
|
||||
<X
|
||||
className="w-2.5 h-2.5 cursor-pointer text-slate-400 hover:text-red-500"
|
||||
onClick={() => setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) })}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{draft.requiredRoles.length === 0 && (
|
||||
<span className="text-slate-400 text-[10px] italic px-1 py-0.5">No roles assigned.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Status</Label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={draft.isActive ? 'active' : 'inactive'}
|
||||
onValueChange={(val) => setDraft({ ...draft, isActive: val === 'active' })}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-8 text-[11px] font-medium border-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active" className="text-xs">Active</SelectItem>
|
||||
<SelectItem value="inactive" className="text-xs">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 pt-3 border-t">
|
||||
<Button variant="outline" size="sm" className="text-xs h-8" onClick={() => setIsModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="bg-amber-600 hover:bg-amber-700 h-8 text-xs font-semibold" onClick={savePolicy}>
|
||||
<Save className="w-3 h-3 mr-1.5" />
|
||||
{isEditMode ? 'Save Changes' : 'Create Policy'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,7 +13,8 @@ import {
|
||||
Settings,
|
||||
RefreshCcw,
|
||||
MapPin,
|
||||
ClipboardList
|
||||
ClipboardList,
|
||||
ListChecks
|
||||
} from 'lucide-react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
@ -109,6 +110,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
|
||||
if (hasRole(['Super Admin'])) {
|
||||
menuItems.push({ id: 'users', label: 'User Management', icon: Users });
|
||||
menuItems.push({ id: 'questionnaires', label: 'Questionnaire Templates', icon: ClipboardList });
|
||||
menuItems.push({ id: 'interview-configs', label: 'Interview Configs', icon: ListChecks });
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
@ -152,7 +154,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
|
||||
{collapsed ? (
|
||||
/* Collapsed header: logo + toggle stacked, centered */
|
||||
<div className="flex flex-col items-center py-3 gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-white flex items-center justify-center p-1.5 shadow-md">
|
||||
<div className="w-8 h-8 rounded-lg bg-white flex items-center justify-center p-1 shadow-md">
|
||||
<img
|
||||
src="/assets/images/Re_Logo.png"
|
||||
alt="RE"
|
||||
@ -171,7 +173,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
|
||||
/* Expanded header: logo + subtitle + collapse toggle */
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<img src="/assets/images/Re_Logo.png" alt="Royal Enfield" className="h-8 w-auto" />
|
||||
<img src="/assets/images/Re_Logo.png" alt="Royal Enfield" className="h-6 w-auto" />
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-slate-400 mt-1 whitespace-nowrap">
|
||||
Dealer Onboarding
|
||||
</span>
|
||||
|
||||
@ -290,7 +290,7 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-10 h-[44px]">
|
||||
<span className="text-[14px] font-medium text-[#333333]">Own a Royal Enfield?</span>
|
||||
<span className="text-[14px] font-medium text-[#333333]">Own a Royal Enfield? <span className="text-red-500">*</span></span>
|
||||
<div className="flex gap-8">
|
||||
{['yes', 'no'].map(val => (
|
||||
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
||||
@ -349,14 +349,14 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
|
||||
value={formData.source}
|
||||
onChange={(e) => setFormData({...formData, source: e.target.value})}
|
||||
>
|
||||
<option value="">Select Source</option>
|
||||
<option value="">Select Source*</option>
|
||||
{sourceOptions.map(s => <option key={s} value={s}>{s}</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>
|
||||
|
||||
<div className="flex flex-col justify-center h-auto min-h-[44px] space-y-1">
|
||||
<span className="text-[13px] font-medium text-[#333333]">Are you an existing Dealer / Vendor of Royal Enfield?</span>
|
||||
<span className="text-[13px] font-medium text-[#333333]">Are you an existing Dealer / Vendor of Royal Enfield? <span className="text-red-500">*</span></span>
|
||||
<div className="flex gap-8">
|
||||
{['yes', 'no'].map(val => (
|
||||
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@ -14,6 +14,15 @@ import { toast } from 'sonner';
|
||||
import { API } from '@/api/API';
|
||||
import { formatDateTime } from '@/components/ui/utils';
|
||||
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface ConstitutionalChangePageProps {
|
||||
currentUser?: UserType | null;
|
||||
@ -87,6 +96,10 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
const [requiredDocs, setRequiredDocs] = useState<number[]>([]);
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const itemsPerPage = 10;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [dialogDataLoading, setDialogDataLoading] = useState(false);
|
||||
|
||||
@ -102,9 +115,14 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
const isSubmittedRequest = (request: any) =>
|
||||
request.status === 'Submitted' || request.currentStage === 'Submitted';
|
||||
|
||||
const handleTabChange = (val: string) => {
|
||||
setActiveTab(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, []);
|
||||
}, [currentPage, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) return;
|
||||
@ -140,11 +158,16 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await API.getConstitutionalChanges() as any;
|
||||
const response = await API.getConstitutionalChanges({
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
status: activeTab === 'all' ? undefined : activeTab
|
||||
}) as any;
|
||||
if (response.data.success) {
|
||||
setRequests(response.data.requests || []);
|
||||
setPaginationMeta(response.meta);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Fetch requests error:', error);
|
||||
toast.error('Failed to fetch requests');
|
||||
} finally {
|
||||
@ -258,25 +281,25 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Requests',
|
||||
value: requests.length,
|
||||
value: paginationMeta?.stats?.total || 0,
|
||||
icon: FileText,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'Submitted / Review',
|
||||
value: requests.filter(r => isSubmittedRequest(r) || isPendingReviewRequest(r)).length,
|
||||
value: paginationMeta?.stats?.pending || 0,
|
||||
icon: Calendar,
|
||||
color: 'bg-yellow-500',
|
||||
},
|
||||
{
|
||||
title: 'Completed',
|
||||
value: requests.filter(r => isCompletedRequest(r)).length,
|
||||
value: paginationMeta?.stats?.completed || 0,
|
||||
icon: Shield,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
title: 'Rejected / Revoked',
|
||||
value: requests.filter(r => isRejectedRequest(r)).length,
|
||||
value: paginationMeta?.stats?.rejected || 0,
|
||||
icon: Building,
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
@ -519,11 +542,11 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all">All Requests</TabsTrigger>
|
||||
<TabsTrigger value="pending">Submitted / Review</TabsTrigger>
|
||||
<TabsTrigger value="in-progress">Rejected / Revoked</TabsTrigger>
|
||||
<TabsTrigger value="rejected">Rejected / Revoked</TabsTrigger>
|
||||
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@ -839,6 +862,56 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-4 border-t flex justify-center bg-white rounded-b-lg">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{[...Array(paginationMeta.totalPages)].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
if (
|
||||
(pageNum === 2 && currentPage > 3) ||
|
||||
(pageNum === paginationMeta.totalPages - 1 && currentPage < paginationMeta.totalPages - 2)
|
||||
) {
|
||||
return <PaginationItem key={pageNum}><PaginationEllipsis /></PaginationItem>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(prev => Math.min(paginationMeta.totalPages, prev + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -45,10 +45,11 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [settlements, apps] = await Promise.all([
|
||||
const [settlements, response] = await Promise.all([
|
||||
settlementService.getFnFSettlements(),
|
||||
onboardingService.getApplications()
|
||||
]);
|
||||
const apps = response.data || [];
|
||||
|
||||
// Derive Onboarding Payments from Application + SecurityDeposit (Standardized nomenclature)
|
||||
// This ensures applications in "Payment Pending" / "Security Details" are visible
|
||||
|
||||
178
src/features/master/components/AutoAssignmentSettings.tsx
Normal file
178
src/features/master/components/AutoAssignmentSettings.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { masterService } from '@/services/master.service';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Settings2,
|
||||
Info,
|
||||
RefreshCcw,
|
||||
ClipboardList,
|
||||
Truck,
|
||||
FileX,
|
||||
Gavel,
|
||||
FileText,
|
||||
Banknote,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface SystemConfig {
|
||||
id: string;
|
||||
key: string;
|
||||
value: { enabled: boolean };
|
||||
category: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const AutoAssignmentSettings: React.FC = () => {
|
||||
const [configs, setConfigs] = useState<SystemConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await masterService.getSystemConfigs({ category: 'ASSIGNMENT' });
|
||||
if (res.success) {
|
||||
setConfigs(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load assignment configurations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
|
||||
const handleToggle = (key: string, enabled: boolean) => {
|
||||
setConfigs(prev => prev.map(c =>
|
||||
c.key === key ? { ...c, value: { ...c.value, enabled } } : c
|
||||
));
|
||||
};
|
||||
|
||||
const handleSave = async (config: SystemConfig) => {
|
||||
try {
|
||||
const res: any = await masterService.saveSystemConfig({
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
category: config.category,
|
||||
description: config.description
|
||||
});
|
||||
if (res.success) {
|
||||
toast.success(`${config.key.replace('AUTO_ASSIGN_', '')} setting updated`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to save configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const modules = [
|
||||
{ key: 'AUTO_ASSIGN_ONBOARDING', label: 'Onboarding', icon: ClipboardList, color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
{ key: 'AUTO_ASSIGN_RELOCATION', label: 'Relocation', icon: Truck, color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
{ key: 'AUTO_ASSIGN_TERMINATION', label: 'Termination', icon: FileX, color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
{ key: 'AUTO_ASSIGN_CONSTITUTIONAL', label: 'Constitutional', icon: Gavel, color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
{ key: 'AUTO_ASSIGN_RESIGNATION', label: 'Resignation', icon: FileText, color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
{ key: 'AUTO_ASSIGN_FNF', label: 'F&F Settlement', icon: Banknote, color: 'text-slate-500', bg: 'bg-slate-100' },
|
||||
];
|
||||
|
||||
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" />
|
||||
<p className="text-slate-500 font-medium">Loading governance controls...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<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>
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold">Auto-Assignment Governance</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Control the automated mapping of participants across different business modules
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{modules.map((mod) => {
|
||||
const config = configs.find(c => c.key === mod.key);
|
||||
const isEnabled = config?.value?.enabled ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mod.key}
|
||||
className="flex items-center justify-between p-4 rounded-xl border bg-white transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"w-12 h-12 flex items-center justify-center rounded-full transition-colors",
|
||||
mod.bg,
|
||||
mod.color
|
||||
)}>
|
||||
<mod.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-900">{mod.label}</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="w-3.5 h-3.5 text-slate-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>When OFF, all participants for new {mod.label} requests must be assigned manually by authorized administrators.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isEnabled ? "default" : "secondary"} className={isEnabled ? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100" : "bg-slate-50 text-slate-500 border-slate-200"}>
|
||||
{isEnabled ? 'Auto-Assign ON' : 'Manual Mode'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
handleToggle(mod.key, val);
|
||||
// Save immediately for better UX
|
||||
const updatedConfig = configs.find(c => c.key === mod.key);
|
||||
if (updatedConfig) {
|
||||
handleSave({ ...updatedConfig, value: { enabled: val } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
618
src/features/master/components/InterviewConfigManagement.tsx
Normal file
618
src/features/master/components/InterviewConfigManagement.tsx
Normal file
@ -0,0 +1,618 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { API } from '@/api/API';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, Plus, Save, Loader2, RotateCcw, Edit3, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ConfigOption {
|
||||
id?: string;
|
||||
optionLabel: string;
|
||||
optionValue: string;
|
||||
score: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface ConfigItem {
|
||||
id?: string;
|
||||
itemKey: string;
|
||||
label: string;
|
||||
type: 'select' | 'text' | 'textarea' | 'number';
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
weight: number | null;
|
||||
maxScore: number | null;
|
||||
options?: ConfigOption[];
|
||||
}
|
||||
|
||||
interface InterviewConfig {
|
||||
id?: string;
|
||||
configType: 'KT_MATRIX' | 'LEVEL2_FEEDBACK' | 'LEVEL3_FEEDBACK';
|
||||
name: string;
|
||||
version: string;
|
||||
isActive: boolean;
|
||||
items?: ConfigItem[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
const CONFIG_TYPES = [
|
||||
{ value: 'KT_MATRIX', label: 'KT Matrix (Level 1)', description: 'Scored criteria for Level 1 interview assessment' },
|
||||
{ value: 'LEVEL2_FEEDBACK', label: 'Level 2 Feedback', description: 'Qualitative feedback fields for Level 2 interview' },
|
||||
{ value: 'LEVEL3_FEEDBACK', label: 'Level 3 Feedback', description: 'Qualitative feedback fields for Level 3 interview' },
|
||||
];
|
||||
|
||||
const InterviewConfigManagement: React.FC = () => {
|
||||
const [configs, setConfigs] = useState<InterviewConfig[]>([]);
|
||||
const [, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('KT_MATRIX');
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<InterviewConfig | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
|
||||
const fetchConfigs = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response: any = await API.getInterviewConfigs();
|
||||
if (response.data?.success) {
|
||||
setConfigs(response.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch configs error:', error);
|
||||
toast.error('Failed to load interview configurations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, [fetchConfigs]);
|
||||
|
||||
const getActiveConfig = (type: string) => configs.find(c => c.configType === type && c.isActive);
|
||||
|
||||
const handleCreateNew = (configType: string) => {
|
||||
const defaults: Record<string, ConfigItem[]> = {
|
||||
KT_MATRIX: [
|
||||
{ itemKey: 'example_criterion', label: 'Example Criterion', type: 'select', order: 1, isRequired: true, weight: 5, maxScore: 10, options: [
|
||||
{ optionLabel: 'Excellent', optionValue: 'excellent', score: 10, order: 1 },
|
||||
{ optionLabel: 'Good', optionValue: 'good', score: 5, order: 2 }
|
||||
]}
|
||||
],
|
||||
LEVEL2_FEEDBACK: [
|
||||
{ itemKey: 'strategicVision', label: 'Strategic Vision', type: 'textarea', order: 1, isRequired: true, weight: null, maxScore: null },
|
||||
{ itemKey: 'managementCapabilities', label: 'Management Capabilities', type: 'textarea', order: 2, isRequired: true, weight: null, maxScore: null },
|
||||
{ itemKey: 'additionalComments', label: 'Additional Comments', type: 'textarea', order: 3, isRequired: false, weight: null, maxScore: null }
|
||||
],
|
||||
LEVEL3_FEEDBACK: [
|
||||
{ itemKey: 'businessVision', label: 'Business Vision & Strategy', type: 'textarea', order: 1, isRequired: true, weight: null, maxScore: null },
|
||||
{ itemKey: 'leadership', label: 'Leadership & Decision Making', type: 'textarea', order: 2, isRequired: true, weight: null, maxScore: null },
|
||||
{ itemKey: 'additionalComments', label: 'Additional Comments', type: 'textarea', order: 3, isRequired: false, weight: null, maxScore: null }
|
||||
]
|
||||
};
|
||||
|
||||
setEditingConfig({
|
||||
configType: configType as any,
|
||||
name: `${CONFIG_TYPES.find(t => t.value === configType)?.label || 'New Config'}`,
|
||||
version: `v${new Date().toISOString().split('T')[0]}`,
|
||||
isActive: true,
|
||||
items: defaults[configType] || []
|
||||
});
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleEdit = async (configId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response: any = await API.getInterviewConfigById(configId);
|
||||
if (response.data?.success) {
|
||||
setEditingConfig(response.data.data);
|
||||
setShowEditor(true);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load configuration for editing');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingConfig) return;
|
||||
if (!editingConfig.name || !editingConfig.version) {
|
||||
toast.error('Name and version are required');
|
||||
return;
|
||||
}
|
||||
if (!editingConfig.items || editingConfig.items.length === 0) {
|
||||
toast.error('Add at least one item');
|
||||
return;
|
||||
}
|
||||
// Validate KT Matrix total weight
|
||||
if (editingConfig.configType === 'KT_MATRIX') {
|
||||
const totalWeight = editingConfig.items.reduce((sum, i) => sum + (Number(i.weight) || 0), 0);
|
||||
if (Math.abs(totalWeight - 100) > 0.01) {
|
||||
toast.error(`KT Matrix total weight must be 100. Current: ${totalWeight}`);
|
||||
return;
|
||||
}
|
||||
for (const item of editingConfig.items) {
|
||||
if (item.type === 'select' && (!item.options || item.options.length === 0)) {
|
||||
toast.error(`Select item "${item.label}" must have options`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
configType: editingConfig.configType,
|
||||
name: editingConfig.name,
|
||||
version: editingConfig.version,
|
||||
items: editingConfig.items.map((item, idx) => ({
|
||||
...item,
|
||||
order: item.order || idx + 1,
|
||||
options: item.options?.map((opt, oIdx) => ({ ...opt, order: opt.order || oIdx + 1 }))
|
||||
}))
|
||||
};
|
||||
|
||||
if (editingConfig.id) {
|
||||
await API.updateInterviewConfig(editingConfig.id, payload);
|
||||
toast.success('Configuration updated successfully');
|
||||
} else {
|
||||
await API.createInterviewConfig(payload);
|
||||
toast.success('New configuration published successfully');
|
||||
}
|
||||
setShowEditor(false);
|
||||
setEditingConfig(null);
|
||||
await fetchConfigs();
|
||||
} catch (error: any) {
|
||||
console.error('Save error:', error);
|
||||
toast.error(error?.response?.data?.message || 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (configId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this configuration?')) return;
|
||||
try {
|
||||
await API.deleteInterviewConfig(configId);
|
||||
toast.success('Configuration deleted');
|
||||
await fetchConfigs();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitializeDefaults = async () => {
|
||||
if (!confirm('This will reset all interview configurations to system defaults. Continue?')) return;
|
||||
try {
|
||||
setInitializing(true);
|
||||
await API.initializeDefaultInterviewConfigs();
|
||||
toast.success('Default configurations initialized');
|
||||
await fetchConfigs();
|
||||
} catch (error) {
|
||||
toast.error('Failed to initialize defaults');
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
if (!editingConfig) return;
|
||||
const newItem: ConfigItem = {
|
||||
itemKey: `field_${(editingConfig.items?.length || 0) + 1}`,
|
||||
label: '',
|
||||
type: editingConfig.configType === 'KT_MATRIX' ? 'select' : 'textarea',
|
||||
order: (editingConfig.items?.length || 0) + 1,
|
||||
isRequired: true,
|
||||
weight: editingConfig.configType === 'KT_MATRIX' ? 5 : null,
|
||||
maxScore: editingConfig.configType === 'KT_MATRIX' ? 10 : null,
|
||||
options: editingConfig.configType === 'KT_MATRIX' ? [
|
||||
{ optionLabel: 'Option 1', optionValue: 'opt1', score: 10, order: 1 },
|
||||
{ optionLabel: 'Option 2', optionValue: 'opt2', score: 5, order: 2 }
|
||||
] : undefined
|
||||
};
|
||||
setEditingConfig({ ...editingConfig, items: [...(editingConfig.items || []), newItem] });
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
if (!editingConfig) return;
|
||||
const newItems = editingConfig.items?.filter((_, i) => i !== index) || [];
|
||||
// Re-order
|
||||
const reOrdered = newItems.map((item, i) => ({ ...item, order: i + 1 }));
|
||||
setEditingConfig({ ...editingConfig, items: reOrdered });
|
||||
};
|
||||
|
||||
const updateItem = (index: number, field: keyof ConfigItem, value: any) => {
|
||||
if (!editingConfig) return;
|
||||
const newItems = [...(editingConfig.items || [])];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setEditingConfig({ ...editingConfig, items: newItems });
|
||||
};
|
||||
|
||||
const addOption = (itemIndex: number) => {
|
||||
if (!editingConfig) return;
|
||||
const newItems = [...(editingConfig.items || [])];
|
||||
const item = newItems[itemIndex];
|
||||
if (!item.options) item.options = [];
|
||||
item.options.push({ optionLabel: '', optionValue: '', score: 0, order: item.options.length + 1 });
|
||||
setEditingConfig({ ...editingConfig, items: newItems });
|
||||
};
|
||||
|
||||
const updateOption = (itemIndex: number, optionIndex: number, field: keyof ConfigOption, value: any) => {
|
||||
if (!editingConfig) return;
|
||||
const newItems = [...(editingConfig.items || [])];
|
||||
if (newItems[itemIndex].options) {
|
||||
newItems[itemIndex].options![optionIndex] = { ...newItems[itemIndex].options![optionIndex], [field]: value };
|
||||
setEditingConfig({ ...editingConfig, items: newItems });
|
||||
}
|
||||
};
|
||||
|
||||
const removeOption = (itemIndex: number, optionIndex: number) => {
|
||||
if (!editingConfig) return;
|
||||
const newItems = [...(editingConfig.items || [])];
|
||||
if (newItems[itemIndex].options) {
|
||||
newItems[itemIndex].options = newItems[itemIndex].options!.filter((_, i) => i !== optionIndex);
|
||||
setEditingConfig({ ...editingConfig, items: newItems });
|
||||
}
|
||||
};
|
||||
|
||||
const totalWeight = editingConfig?.configType === 'KT_MATRIX'
|
||||
? (editingConfig.items || []).reduce((sum, i) => sum + (Number(i.weight) || 0), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Interview Configuration</h2>
|
||||
<p className="text-slate-500 text-sm mt-1">Manage KT Matrix criteria and feedback fields for all interview levels</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleInitializeDefaults} disabled={initializing} className="gap-2">
|
||||
<RotateCcw size={16} /> {initializing ? 'Initializing...' : 'Reset to Defaults'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
{CONFIG_TYPES.map(t => (
|
||||
<TabsTrigger key={t.value} value={t.value}>{t.label}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{CONFIG_TYPES.map(type => {
|
||||
const activeConfig = getActiveConfig(type.value);
|
||||
const typeConfigs = configs.filter(c => c.configType === type.value);
|
||||
return (
|
||||
<TabsContent key={type.value} value={type.value}>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{type.label}</CardTitle>
|
||||
<p className="text-sm text-slate-500">{type.description}</p>
|
||||
</div>
|
||||
<Button onClick={() => handleCreateNew(type.value)} className="gap-2">
|
||||
<Plus size={16} /> Publish New Version
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeConfig ? (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-green-800">Active: {activeConfig.name}</p>
|
||||
<p className="text-sm text-green-700">Version {activeConfig.version} • {activeConfig.items?.length || 0} items</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => activeConfig.id && handleEdit(activeConfig.id)}>
|
||||
<Edit3 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
|
||||
No active configuration found. Click "Publish New Version" to create one, or "Reset to Defaults" to initialize system defaults.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3">Version History</h3>
|
||||
{typeConfigs.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No versions found.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{typeConfigs.map(cfg => (
|
||||
<div key={cfg.id} className={`flex items-center justify-between p-3 rounded-lg border ${cfg.isActive ? 'bg-green-50 border-green-200' : 'bg-white border-slate-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{cfg.isActive && <Badge className="bg-green-600">Active</Badge>}
|
||||
<span className="font-medium text-sm">{cfg.name}</span>
|
||||
<span className="text-xs text-slate-500">{cfg.version}</span>
|
||||
<span className="text-xs text-slate-400">{cfg.items?.length || 0} items</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<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)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs> {/* Precision Engineered Editor Dialog */}
|
||||
<Dialog open={showEditor} onOpenChange={setShowEditor}>
|
||||
<DialogContent className="max-w-[95vw] w-full lg:max-w-7xl p-0 overflow-hidden border-none shadow-2xl rounded-2xl flex flex-col max-h-[95vh]">
|
||||
{/* Compact Minimalist Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-100 bg-white flex shrink-0 items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold text-slate-900 leading-none">
|
||||
{editingConfig?.id ? 'Edit Configuration' : 'New Configuration'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[11px] text-slate-500 mt-1 uppercase tracking-wider font-semibold">
|
||||
{editingConfig?.configType.replace(/_/g, ' ')} · {editingConfig?.version || 'v1.0'}
|
||||
</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'}`}>
|
||||
Weight: {totalWeight}% / 100%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 bg-white space-y-6 custom-scrollbar">
|
||||
{editingConfig && (
|
||||
<div className="space-y-6">
|
||||
{/* Compact Meta Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end bg-slate-50/40 p-4 rounded-xl border border-slate-100">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest pl-1">Name</Label>
|
||||
<Input
|
||||
value={editingConfig.name}
|
||||
onChange={e => setEditingConfig({ ...editingConfig, name: e.target.value })}
|
||||
className="h-9 border-slate-200 bg-white shadow-none text-sm font-normal"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest pl-1">Version</Label>
|
||||
<Input
|
||||
value={editingConfig.version}
|
||||
onChange={e => setEditingConfig({ ...editingConfig, version: e.target.value })}
|
||||
className="h-9 border-slate-200 bg-white shadow-none text-sm font-normal"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end h-9">
|
||||
<Button variant="outline" onClick={addItem} className="h-full border-dashed border-slate-300 text-slate-500 hover:text-slate-900 text-xs font-bold px-4">
|
||||
<Plus size={14} className="mr-2" /> Add Criteria
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Area with Horizontal Guard */}
|
||||
<div className="overflow-x-auto pb-4 -mx-1 px-1">
|
||||
<div className="min-w-[900px] border border-slate-100 rounded-xl">
|
||||
<table className="w-full text-left border-collapse table-fixed">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50 border-b border-slate-100 text-[10px] uppercase font-black text-slate-400 tracking-[0.1em]">
|
||||
<th className="px-4 py-2 w-12 text-center">#</th>
|
||||
<th className="px-4 py-2 w-[35%]">Label</th>
|
||||
<th className="px-4 py-2 w-[20%]">Data Key</th>
|
||||
<th className="px-4 py-2 w-32">Type</th>
|
||||
{editingConfig.configType === 'KT_MATRIX' && (
|
||||
<>
|
||||
<th className="px-4 py-2 w-24">Weight</th>
|
||||
<th className="px-4 py-2 w-20">Max</th>
|
||||
</>
|
||||
)}
|
||||
<th className="px-4 py-2 w-16 text-center">Req.</th>
|
||||
<th className="px-4 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{(editingConfig.items || []).map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<tr className="group hover:bg-slate-50/10 transition-colors">
|
||||
<td className="px-4 py-3 text-slate-300 font-bold text-[11px] align-top pt-5 text-center">{String(index + 1).padStart(2, '0')}</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={e => updateItem(index, 'label', e.target.value)}
|
||||
className="h-10 border-slate-100 hover:border-slate-300 focus:bg-white bg-slate-50/30 text-sm font-normal transition-all"
|
||||
placeholder="Age/Qualification etc."
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Input
|
||||
value={item.itemKey}
|
||||
onChange={e => updateItem(index, 'itemKey', e.target.value)}
|
||||
className="h-10 border-slate-100 hover:border-slate-300 focus:bg-white bg-slate-50/30 font-mono text-[11px] text-slate-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Select value={item.type} onValueChange={(v: any) => updateItem(index, 'type', v)}>
|
||||
<SelectTrigger className="h-10 border-slate-100 hover:border-slate-300 bg-slate-50/30 text-xs focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="select">Selection</SelectItem>
|
||||
<SelectItem value="text">Text</SelectItem>
|
||||
<SelectItem value="textarea">Comment</SelectItem>
|
||||
<SelectItem value="number">Numeric</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
{editingConfig.configType === 'KT_MATRIX' && (
|
||||
<>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="relative group/num">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.weight || ''}
|
||||
onChange={e => updateItem(index, 'weight', parseFloat(e.target.value) || 0)}
|
||||
className="h-10 w-full border-slate-100 bg-slate-50/30 text-sm font-normal text-right pr-8 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<div className="absolute right-0.5 top-0.5 bottom-0.5 flex flex-col border-l border-slate-200/50 bg-white/40 rounded-r-md overflow-hidden opacity-40 group-hover/num:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItem(index, 'weight', (item.weight || 0) + 1)}
|
||||
className="flex-1 px-1.5 hover:bg-slate-200/50 flex items-center justify-center border-b border-slate-200/50"
|
||||
>
|
||||
<ChevronUp size={10} className="text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItem(index, 'weight', Math.max(0, (item.weight || 0) - 1))}
|
||||
className="flex-1 px-1.5 hover:bg-slate-200/50 flex items-center justify-center"
|
||||
>
|
||||
<ChevronDown size={10} className="text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-[9px] text-slate-300 font-bold">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="relative group/num">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.maxScore || ''}
|
||||
onChange={e => updateItem(index, 'maxScore', parseFloat(e.target.value) || 0)}
|
||||
className="h-10 border-slate-100 bg-slate-50/30 text-sm font-normal text-right pr-6 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<div className="absolute right-0.5 top-0.5 bottom-0.5 flex flex-col border-l border-slate-200/50 bg-white/40 rounded-r-md overflow-hidden opacity-40 group-hover/num:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItem(index, 'maxScore', (item.maxScore || 0) + 1)}
|
||||
className="flex-1 px-1.5 hover:bg-slate-200/50 flex items-center justify-center border-b border-slate-200/50"
|
||||
>
|
||||
<ChevronUp size={10} className="text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateItem(index, 'maxScore', Math.max(0, (item.maxScore || 0) - 1))}
|
||||
className="flex-1 px-1.5 hover:bg-slate-200/50 flex items-center justify-center"
|
||||
>
|
||||
<ChevronDown size={10} className="text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="px-4 py-3 align-top text-center pt-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.isRequired}
|
||||
onChange={e => updateItem(index, 'isRequired', e.target.checked)}
|
||||
className="w-4 h-4 accent-slate-900 border-slate-300"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top pt-4">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-200 hover:text-red-500 hover:bg-red-50" onClick={() => removeItem(index)}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{item.type === 'select' && (
|
||||
<tr className="bg-slate-50/30">
|
||||
<td colSpan={1}></td>
|
||||
<td colSpan={editingConfig.configType === 'KT_MATRIX' ? 7 : 5} className="px-4 py-4">
|
||||
<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
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-3 mb-2 px-1">
|
||||
<div className="col-span-6 text-[9px] font-black uppercase text-slate-300 tracking-tighter">Display Label</div>
|
||||
<div className="col-span-3 text-[9px] font-black uppercase text-slate-300 tracking-tighter">API Value</div>
|
||||
<div className="col-span-2 text-[9px] font-black uppercase text-slate-300 tracking-tighter">Score</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(item.options || []).map((opt, optIndex) => (
|
||||
<div key={optIndex} className="grid grid-cols-12 gap-3 items-center group/opt">
|
||||
<div className="col-span-6">
|
||||
<Input placeholder="Label" value={opt.optionLabel} onChange={e => updateOption(index, optIndex, 'optionLabel', e.target.value)} className="h-9 border-slate-200 bg-white text-xs font-normal" />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Input placeholder="Value" value={opt.optionValue} onChange={e => updateOption(index, optIndex, 'optionValue', e.target.value)} className="h-9 border-slate-200 bg-white text-xs font-mono" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="relative group/optnum">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={opt.score}
|
||||
onChange={e => updateOption(index, optIndex, 'score', parseFloat(e.target.value) || 0)}
|
||||
className="h-9 border-slate-200 bg-white text-xs font-normal text-right pr-6 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<div className="absolute right-0.5 top-0.5 bottom-0.5 flex flex-col border-l border-slate-100 bg-slate-50/50 rounded-r-md overflow-hidden opacity-0 group-hover/optnum:opacity-100 transition-opacity">
|
||||
<button onClick={() => updateOption(index, optIndex, 'score', (opt.score || 0) + 1)} className="flex-1 px-1 hover:bg-slate-200 flex items-center justify-center border-b border-slate-100">
|
||||
<ChevronUp size={8} />
|
||||
</button>
|
||||
<button onClick={() => updateOption(index, optIndex, 'score', (opt.score || 0) - 1)} className="flex-1 px-1 hover:bg-slate-200 flex items-center justify-center">
|
||||
<ChevronDown size={8} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-center">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-red-500 opacity-0 group-hover/opt:opacity-100" onClick={() => removeOption(index, optIndex)}>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex shrink-0 justify-end gap-3">
|
||||
<Button variant="ghost" className="px-6 h-10 text-xs font-bold text-slate-500 hover:bg-slate-200 transition-colors" onClick={() => setShowEditor(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-slate-900 hover:bg-slate-950 text-white px-8 h-10 rounded-lg text-xs font-black uppercase tracking-widest shadow-xl shadow-slate-200 transition-all active:scale-95"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
Commit Changes
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InterviewConfigManagement;
|
||||
@ -118,14 +118,14 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-sm leading-none font-medium text-slate-700">Status</Label>
|
||||
<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">
|
||||
<SelectValue placeholder="Select Status" />
|
||||
<SelectValue placeholder="Is this an active Opportunity?" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="active">Yes</SelectItem>
|
||||
<SelectItem value="inactive">No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@ -70,13 +70,13 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Status" />
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Opportunity Filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="active">Active Only</SelectItem>
|
||||
<SelectItem value="inactive">Inactive Only</SelectItem>
|
||||
<SelectItem value="all">All Opportunities</SelectItem>
|
||||
<SelectItem value="active">Opportunity: Yes</SelectItem>
|
||||
<SelectItem value="inactive">Opportunity: No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@ -96,7 +96,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
|
||||
<TableHead>City</TableHead>
|
||||
<TableHead>District</TableHead>
|
||||
<TableHead>Active Period</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Opportunity</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@ -140,10 +140,10 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={district.isActive ? 'default' : 'secondary'}
|
||||
className={district.isActive ? 'bg-green-600 hover:bg-green-700 text-white border-transparent' : ''}
|
||||
variant={district.isOpportunity ? 'default' : 'secondary'}
|
||||
className={district.isOpportunity ? 'bg-green-600 hover:bg-green-700 text-white border-transparent' : ''}
|
||||
>
|
||||
{district.isActive ? 'Active' : 'Inactive'}
|
||||
{district.isOpportunity ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
||||
@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Tabs, TabsContent, TabsList, TabsTrigger
|
||||
} from '@/components/ui/tabs';
|
||||
import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText } from 'lucide-react';
|
||||
import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText, Settings2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@ -31,6 +31,7 @@ import { TemplateDialog } from '@/features/master/components/TemplateDialog';
|
||||
import { LocationDialog } from '@/features/master/components/LocationDialog';
|
||||
import { SecurityDepositMaster } from '@/features/master/components/SecurityDepositMaster';
|
||||
import { DocumentConfigManagement } from '@/features/master/components/DocumentConfigManagement';
|
||||
import { AutoAssignmentSettings } from '@/features/master/components/AutoAssignmentSettings';
|
||||
import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage';
|
||||
import { RootState } from '@/store';
|
||||
|
||||
@ -377,7 +378,7 @@ export const MasterPage: React.FC = () => {
|
||||
setLocationDistrict(loc.districtId || '');
|
||||
setLocationActiveFrom(loc.openFrom ? new Date(loc.openFrom).toISOString().split('T')[0] : '');
|
||||
setLocationActiveTo(loc.openTo ? new Date(loc.openTo).toISOString().split('T')[0] : '');
|
||||
setLocationStatus(loc.isActive ? 'active' : 'inactive');
|
||||
setLocationStatus(loc.isOpportunity ? 'active' : 'inactive');
|
||||
setShowLocationDialog(true);
|
||||
};
|
||||
|
||||
@ -404,7 +405,7 @@ export const MasterPage: React.FC = () => {
|
||||
status: locationStatus,
|
||||
openFrom: locationActiveFrom,
|
||||
openTo: locationActiveTo,
|
||||
isActive: locationStatus === 'active'
|
||||
isOpportunity: locationStatus === 'active'
|
||||
};
|
||||
const res = await (editingLocationId
|
||||
? masterService.updateArea(editingLocationId, payload)
|
||||
@ -423,11 +424,11 @@ export const MasterPage: React.FC = () => {
|
||||
search: districtsSearch,
|
||||
page: districtsPage,
|
||||
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter,
|
||||
isActive: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
|
||||
isOpportunity: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
|
||||
});
|
||||
}, 500);
|
||||
return () => clearTimeout(handler);
|
||||
}, [districtsSearch, districtsPage, locationStateFilter, fetchAreas]);
|
||||
}, [districtsSearch, districtsPage, locationStateFilter, locationStatusFilter, fetchAreas]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -446,7 +447,7 @@ export const MasterPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-6 h-auto sticky top-0 z-10 bg-white/80 backdrop-blur-sm border shadow-sm rounded-xl p-1">
|
||||
<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">
|
||||
<Globe className="w-4 h-4" /> Organisation
|
||||
</TabsTrigger>
|
||||
@ -465,6 +466,9 @@ export const MasterPage: React.FC = () => {
|
||||
<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]">
|
||||
<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]">
|
||||
<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]">
|
||||
<Settings className="w-4 h-4" /> App Settings
|
||||
</TabsTrigger>
|
||||
@ -582,6 +586,10 @@ export const MasterPage: React.FC = () => {
|
||||
<DocumentConfigManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="governance" className="animate-in fade-in duration-300">
|
||||
<AutoAssignmentSettings />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="animate-in fade-in duration-300">
|
||||
<SecurityDepositMaster />
|
||||
</TabsContent>
|
||||
|
||||
@ -135,7 +135,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Remark (Required)</Label>
|
||||
<Label>Remark <span className="text-red-500">*</span></Label>
|
||||
<Textarea
|
||||
placeholder="Enter approval remarks..."
|
||||
value={approvalRemark}
|
||||
@ -214,7 +214,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Reason for Rejection (Required)</Label>
|
||||
<Label>Reason for Rejection <span className="text-red-500">*</span></Label>
|
||||
<Textarea
|
||||
placeholder="Enter rejection reason..."
|
||||
value={rejectionReason}
|
||||
@ -253,17 +253,17 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Interview Mode</Label>
|
||||
<Label>Interview Mode <span className="text-red-500">*</span></Label>
|
||||
<Select value={interviewMode} onValueChange={setInterviewMode}>
|
||||
<SelectTrigger className="mt-2" data-testid="onboarding-schedule-mode-select"><SelectValue placeholder="Select interview mode" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="virtual">Virtual</SelectItem><SelectItem value="physical">Physical</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Date & Time</Label><Input type="datetime-local" className="mt-2" value={interviewDate} onChange={(e) => setInterviewDate(e.target.value)} data-testid="onboarding-schedule-date-input" /></div>
|
||||
<div><Label>Date & Time <span className="text-red-500">*</span></Label><Input type="datetime-local" className="mt-2" value={interviewDate} onChange={(e) => setInterviewDate(e.target.value)} data-testid="onboarding-schedule-date-input" /></div>
|
||||
{interviewMode === 'virtual' && <div><Label>Meeting Link</Label><Input placeholder="https://meet.google.com/..." className="mt-2" value={meetingLink} onChange={(e) => setMeetingLink(e.target.value)} data-testid="onboarding-schedule-link-input" /></div>}
|
||||
{interviewMode === 'physical' && <div><Label>Location</Label><Input placeholder="Enter interview location address" className="mt-2" value={location} onChange={(e) => setLocation(e.target.value)} data-testid="onboarding-schedule-location-input" /></div>}
|
||||
<div>
|
||||
<Label>Interviewers</Label>
|
||||
<Label>Interviewers <span className="text-red-500">*</span></Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Select value={selectedInterviewerId} onValueChange={setSelectedInterviewerId}>
|
||||
<SelectTrigger className="flex-1" data-testid="onboarding-schedule-interviewer-select"><SelectValue placeholder="Select interviewer" /></SelectTrigger>
|
||||
@ -301,7 +301,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Select Architecture Lead</Label>
|
||||
<Label>Select Architecture Lead <span className="text-red-500">*</span></Label>
|
||||
<Select value={architectureLeadId} onValueChange={setArchitectureLeadId}>
|
||||
<SelectTrigger className="mt-2" data-testid="onboarding-architecture-lead-select"><SelectValue placeholder="Search users..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -330,7 +330,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
<Label>Status <span className="text-red-500">*</span></Label>
|
||||
<Select value={architectureStatus} onValueChange={setArchitectureStatus}>
|
||||
<SelectTrigger className="mt-2" data-testid="onboarding-architecture-status-select"><SelectValue placeholder="Select status" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="COMPLETED">Completed</SelectItem><SelectItem value="REJECTED">Rejected / Needs Revision</SelectItem></SelectContent>
|
||||
|
||||
@ -27,7 +27,9 @@ interface ApplicationDetailsExtendedModalsProps {
|
||||
export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtendedModalsProps) {
|
||||
const {
|
||||
application,
|
||||
KT_MATRIX_CRITERIA,
|
||||
ktCriteria,
|
||||
l2Fields,
|
||||
l3Fields,
|
||||
showKTMatrixModal,
|
||||
setShowKTMatrixModal,
|
||||
ktMatrixSelectedValues,
|
||||
@ -105,16 +107,16 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
<DialogDescription className="text-sm leading-relaxed">
|
||||
Level 1 interview · {application.name}
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{Object.keys(ktMatrixSelectedValues).length} of {KT_MATRIX_CRITERIA.length} criteria answered
|
||||
{Object.keys(ktMatrixSelectedValues).length} of {ktCriteria.length} criteria answered
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="custom-scrollbar-slim min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="space-y-6">
|
||||
{KT_MATRIX_CRITERIA.map((criterion: any, idx: number) => (
|
||||
{ktCriteria.map((criterion: any, idx: number) => (
|
||||
<div key={criterion.name} className="space-y-2">
|
||||
<Label htmlFor={`kt-matrix-${idx}`} className="block text-sm font-medium leading-relaxed text-foreground">
|
||||
<span className="text-muted-foreground">{idx + 1}.</span> {criterion.name}{' '}
|
||||
<span className="text-muted-foreground">{idx + 1}.</span> {criterion.name} <span className="text-red-500">*</span>{' '}
|
||||
<span className="font-normal text-muted-foreground">({criterion.weight}%)</span>
|
||||
</Label>
|
||||
<Select
|
||||
@ -154,7 +156,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 || Object.keys(ktMatrixSelectedValues).length < KT_MATRIX_CRITERIA.length} data-testid="onboarding-kt-matrix-submit">{isSubmittingKT ? 'Saving…' : 'Submit'}</Button>
|
||||
<Button onClick={handleSubmitKTMatrix} disabled={isSubmittingKT || Object.keys(ktMatrixSelectedValues).length < ktCriteria.length} data-testid="onboarding-kt-matrix-submit">{isSubmittingKT ? 'Saving…' : 'Submit'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@ -167,22 +169,44 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
<DialogDescription>Provide detailed feedback from the Level 2 interview (DD Lead + ZBH evaluation).</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Interview Date</Label><Input type="date" className="mt-2" value={level2Feedback.interviewDate} disabled /></div>
|
||||
<div><Label>Interviewer Name</Label><Input placeholder="Enter your name" className="mt-2" value={level2Feedback.interviewerName} disabled /></div>
|
||||
<div><Label>Interview Date <span className="text-red-500">*</span></Label><Input type="date" className="mt-2" value={level2Feedback.interviewDate} disabled /></div>
|
||||
<div><Label>Interviewer Name <span className="text-red-500">*</span></Label><Input placeholder="Enter your name" className="mt-2" value={level2Feedback.interviewerName} disabled /></div>
|
||||
<div>
|
||||
<Label>Overall Performance Score</Label>
|
||||
<Label>Overall Performance Score <span className="text-red-500">*</span></Label>
|
||||
<Select value={level2Feedback.overallScore} onValueChange={(value) => handleLevel2Change('overallScore', value)}>
|
||||
<SelectTrigger className="mt-2" data-testid="onboarding-level2-overall-score-select"><SelectValue placeholder="Select score" /></SelectTrigger>
|
||||
<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>
|
||||
<Separator />
|
||||
<div><Label>Strategic Vision</Label><Textarea placeholder="Evaluate the candidate's strategic thinking and long-term vision..." className="mt-2" rows={3} value={level2Feedback.strategicVision} onChange={(e) => handleLevel2Change('strategicVision', e.target.value)} data-testid="onboarding-level2-strategic-vision-textarea" /></div>
|
||||
<div><Label>Management Capabilities</Label><Textarea placeholder="Assess leadership and team management potential..." className="mt-2" rows={3} value={level2Feedback.managementCapabilities} onChange={(e) => handleLevel2Change('managementCapabilities', e.target.value)} data-testid="onboarding-level2-management-textarea" /></div>
|
||||
<div><Label>Operational Understanding</Label><Textarea placeholder="Review understanding of dealership operations and processes..." className="mt-2" rows={3} value={level2Feedback.operationalUnderstanding} onChange={(e) => handleLevel2Change('operationalUnderstanding', e.target.value)} data-testid="onboarding-level2-operational-textarea" /></div>
|
||||
<div><Label>Key Strengths</Label><Textarea placeholder="List the candidate's key strengths and positive attributes..." className="mt-2" rows={3} value={level2Feedback.keyStrengths} onChange={(e) => handleLevel2Change('keyStrengths', e.target.value)} data-testid="onboarding-level2-strengths-textarea" /></div>
|
||||
<div><Label>Areas of Concern</Label><Textarea placeholder="Highlight any concerns or areas needing improvement..." className="mt-2" rows={3} value={level2Feedback.areasOfConcern} onChange={(e) => handleLevel2Change('areasOfConcern', e.target.value)} data-testid="onboarding-level2-concerns-textarea" /></div>
|
||||
<div><Label>Additional Comments</Label><Textarea placeholder="Any additional observations or comments..." className="mt-2" rows={3} value={level2Feedback.additionalComments} onChange={(e) => handleLevel2Change('additionalComments', e.target.value)} data-testid="onboarding-level2-comments-textarea" /></div>
|
||||
{(l2Fields || []).map((field: any, idx: number) => (
|
||||
<div key={field.itemKey || idx}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.isRequired && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select value={level2Feedback[field.itemKey] || ''} onValueChange={(value) => handleLevel2Change(field.itemKey, value)}>
|
||||
<SelectTrigger className="mt-2"><SelectValue placeholder={`Select ${field.label}...`} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((opt: any, oIdx: number) => (
|
||||
<SelectItem key={oIdx} value={opt.optionValue || opt.value}>{opt.optionLabel || opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'number' ? (
|
||||
<Input type="number" className="mt-2" value={level2Feedback[field.itemKey] || ''} onChange={(e) => handleLevel2Change(field.itemKey, e.target.value)} />
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={`Enter ${field.label.toLowerCase()}...`}
|
||||
className="mt-2"
|
||||
rows={3}
|
||||
value={level2Feedback[field.itemKey] || ''}
|
||||
onChange={(e) => handleLevel2Change(field.itemKey, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</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} data-testid="onboarding-level2-feedback-submit">{isSubmittingLevel2 ? 'Submitting...' : 'Submit Feedback'}</Button>
|
||||
@ -230,23 +254,44 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
<DialogDescription>Provide detailed feedback from the Level 3 interview (NBH + DD-Head evaluation).</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div><Label>Interview Date</Label><Input type="date" className="mt-2" value={level3Feedback.interviewDate} disabled /></div>
|
||||
<div><Label>Interviewer Name</Label><Input placeholder="Enter your name" className="mt-2" value={level3Feedback.interviewerName} disabled /></div>
|
||||
<div><Label>Interview Date <span className="text-red-500">*</span></Label><Input type="date" className="mt-2" value={level3Feedback.interviewDate} disabled /></div>
|
||||
<div><Label>Interviewer Name <span className="text-red-500">*</span></Label><Input placeholder="Enter your name" className="mt-2" value={level3Feedback.interviewerName} disabled /></div>
|
||||
<div>
|
||||
<Label>Overall Performance Score</Label>
|
||||
<Label>Overall Performance Score <span className="text-red-500">*</span></Label>
|
||||
<Select value={level3Feedback.overallScore} onValueChange={(value) => handleLevel3Change('overallScore', value)}>
|
||||
<SelectTrigger className="mt-2" data-testid="onboarding-level3-overall-score-select"><SelectValue placeholder="Select score" /></SelectTrigger>
|
||||
<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>
|
||||
<Separator />
|
||||
<div><Label>Business Vision & Strategy</Label><Textarea placeholder="Evaluate the candidate's long-term business vision and strategic planning..." className="mt-2" rows={3} value={level3Feedback.strategicVision} onChange={(e) => handleLevel3Change('strategicVision', e.target.value)} data-testid="onboarding-level3-strategic-vision-textarea" /></div>
|
||||
<div><Label>Leadership & Decision Making</Label><Textarea placeholder="Assess leadership qualities and decision-making capabilities..." className="mt-2" rows={3} value={level3Feedback.managementCapabilities} onChange={(e) => handleLevel3Change('managementCapabilities', e.target.value)} data-testid="onboarding-level3-management-textarea" /></div>
|
||||
<div><Label>Operational & Financial Readiness</Label><Textarea placeholder="Review financial commitment and investment readiness..." className="mt-2" rows={3} value={level3Feedback.operationalUnderstanding} onChange={(e) => handleLevel3Change('operationalUnderstanding', e.target.value)} data-testid="onboarding-level3-operational-textarea" /></div>
|
||||
<div><Label>Brand Alignment</Label><Textarea placeholder="Evaluate alignment with Royal Enfield brand values and culture..." className="mt-2" rows={3} value={level3Feedback.brandAlignment} onChange={(e) => handleLevel3Change('brandAlignment', e.target.value)} data-testid="onboarding-level3-brand-alignment-textarea" /></div>
|
||||
<div><Label>Key Strengths</Label><Textarea placeholder="List the candidate's key strengths and exceptional qualities..." className="mt-2" rows={3} value={level3Feedback.keyStrengths} onChange={(e) => handleLevel3Change('keyStrengths', e.target.value)} data-testid="onboarding-level3-strengths-textarea" /></div>
|
||||
<div><Label>Areas of Concern</Label><Textarea placeholder="Highlight any red flags or major concerns..." className="mt-2" rows={3} value={level3Feedback.areasOfConcern} onChange={(e) => handleLevel3Change('areasOfConcern', e.target.value)} data-testid="onboarding-level3-concerns-textarea" /></div>
|
||||
<div><Label>Executive Summary</Label><Textarea placeholder="Provide a comprehensive executive summary of the interview and final thoughts..." className="mt-2" rows={4} value={level3Feedback.executiveSummary} onChange={(e) => handleLevel3Change('executiveSummary', e.target.value)} data-testid="onboarding-level3-summary-textarea" /></div>
|
||||
{(l3Fields || []).map((field: any, idx: number) => (
|
||||
<div key={field.itemKey || idx}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.isRequired && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select value={level3Feedback[field.itemKey] || ''} onValueChange={(value) => handleLevel3Change(field.itemKey, value)}>
|
||||
<SelectTrigger className="mt-2"><SelectValue placeholder={`Select ${field.label}...`} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((opt: any, oIdx: number) => (
|
||||
<SelectItem key={oIdx} value={opt.optionValue || opt.value}>{opt.optionLabel || opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'number' ? (
|
||||
<Input type="number" className="mt-2" value={level3Feedback[field.itemKey] || ''} onChange={(e) => handleLevel3Change(field.itemKey, e.target.value)} />
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={`Enter ${field.label.toLowerCase()}...`}
|
||||
className="mt-2"
|
||||
rows={3}
|
||||
value={level3Feedback[field.itemKey] || ''}
|
||||
onChange={(e) => handleLevel3Change(field.itemKey, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</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} data-testid="onboarding-level3-feedback-submit">{isSubmittingLevel3 ? 'Submitting...' : 'Submit Feedback'}</Button>
|
||||
@ -285,7 +330,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
<TableCell className="text-right py-3">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-full" onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }} data-testid={`onboarding-document-preview-${index}`}><Eye className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-600 hover:bg-amber-50 rounded-full" onClick={() => { const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'; window.open(`${baseUrl}/${doc.filePath}`, '_blank'); }} data-testid={`onboarding-document-download-${index}`}><Download className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-600 hover:bg-amber-50 rounded-full" onClick={() => { const baseUrl = (import.meta as any).env?.VITE_API_URL || 'http://localhost:5000'; window.open(`${baseUrl}/${doc.filePath}`, '_blank'); }} data-testid={`onboarding-document-download-${index}`}><Download className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -306,7 +351,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
<div className="grid gap-6 bg-slate-50/50 p-4 sm:p-6 rounded-2xl border border-slate-200">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-semibold px-1">Stage context</Label>
|
||||
<Label className="text-slate-700 font-semibold px-1">Stage context <span className="text-red-500">*</span></Label>
|
||||
<Select value={selectedStage || 'null'} onValueChange={(val) => setSelectedStage(val === 'null' ? null : val)}>
|
||||
<SelectTrigger className="bg-white border-slate-200 h-11 rounded-xl focus:ring-amber-500 shadow-sm" data-testid="onboarding-documents-stage-select"><SelectValue placeholder="Select stage" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -316,7 +361,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-semibold px-1">Document Type</Label>
|
||||
<Label className="text-slate-700 font-semibold px-1">Document Type <span className="text-red-500">*</span></Label>
|
||||
<Select value={uploadDocType} onValueChange={setUploadDocType}>
|
||||
<SelectTrigger className="bg-white border-slate-200 h-11 rounded-xl focus:ring-amber-500 shadow-sm" data-testid="onboarding-documents-type-select"><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -350,7 +395,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-semibold px-1">Select File</Label>
|
||||
<Label className="text-slate-700 font-semibold px-1">Select File <span className="text-red-500">*</span></Label>
|
||||
<Input type="file" className="bg-white border-slate-200 h-12 rounded-xl focus:ring-amber-500 shadow-sm file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-amber-50 file:text-amber-700 hover:file:bg-amber-100 cursor-pointer" onChange={(e) => setUploadFile(e.target.files ? e.target.files[0] : null)} data-testid="onboarding-documents-file-input" />
|
||||
</div>
|
||||
</div>
|
||||
@ -377,7 +422,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
<div className="space-y-4">
|
||||
{(currentUser?.role !== 'FDD' && currentUser?.roleCode !== 'FDD') && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">Auditor Recommendation</Label>
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">Auditor Recommendation <span className="text-red-500">*</span></Label>
|
||||
<div className="flex gap-2">
|
||||
{['Recommended', 'Qualified with Observations', 'Not Recommended'].map((rec) => (
|
||||
<Button key={rec} variant={fddAuditRecommendation === rec ? 'default' : 'outline'} className={cn("flex-1 h-10 font-bold text-[9px] uppercase tracking-wider rounded-xl transition-all", fddAuditRecommendation === rec && rec === 'Recommended' && "bg-emerald-600 hover:bg-emerald-700", fddAuditRecommendation === rec && rec === 'Qualified with Observations' && "bg-amber-500 hover:bg-amber-600", fddAuditRecommendation === rec && rec === 'Not Recommended' && "bg-red-600 hover:bg-red-700")} onClick={() => setFddAuditRecommendation(rec)} data-testid={`onboarding-fdd-recommendation-${rec.replace(/\s+/g, '-').toLowerCase()}`}>{rec}</Button>
|
||||
@ -483,7 +528,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
||||
</div>
|
||||
<div className="p-8 space-y-6 bg-white">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-slate-400 uppercase tracking-widest font-black">Proposed Legal Constitution</Label>
|
||||
<Label className="text-[10px] text-slate-400 uppercase tracking-widest font-black">Proposed Legal Constitution <span className="text-red-500">*</span></Label>
|
||||
<Select value={tempFirmType} onValueChange={setTempFirmType}>
|
||||
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-amber-500" data-testid="onboarding-firm-type-select"><SelectValue placeholder="Select Firm Type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@ -94,7 +94,7 @@ export function ApplicationDetailsFddAuditContent({
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1.5 block">Select FDD Agency</label>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1.5 block">Select FDD Agency <span className="text-red-500">*</span></label>
|
||||
<select
|
||||
className="w-full h-11 bg-white border border-slate-200 rounded-xl px-4 text-sm font-medium focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 outline-none transition-all shadow-sm"
|
||||
value={selectedAgencyId}
|
||||
|
||||
@ -26,7 +26,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@ -149,7 +148,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{application.isShortlisted !== false && (
|
||||
{(application.isShortlisted !== false || application.status === 'Submitted') && (
|
||||
<Card data-testid="onboarding-details-actions-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Actions</CardTitle>
|
||||
@ -412,4 +411,3 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -548,4 +548,3 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
|
||||
handleRetriggerEvaluators,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { toast } from 'sonner';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { onboardingService } from '@/services/onboarding.service';
|
||||
import { KT_MATRIX_CRITERIA } from '@/features/onboarding/components/application-details/applicationDetails.shared';
|
||||
import type { InterviewConfig } from './useInterviewConfigs';
|
||||
|
||||
interface UseApplicationDetailsFeedbackActionsParams {
|
||||
ktMatrixScores: Record<string, number>;
|
||||
@ -24,6 +25,9 @@ interface UseApplicationDetailsFeedbackActionsParams {
|
||||
currentUser: any;
|
||||
fetchInterviews: () => Promise<void>;
|
||||
fetchApplication: (silent?: boolean) => Promise<void>;
|
||||
ktMatrixConfig: InterviewConfig | null;
|
||||
level2Config: InterviewConfig | null;
|
||||
level3Config: InterviewConfig | null;
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().split('T')[0];
|
||||
@ -75,7 +79,61 @@ export function useApplicationDetailsFeedbackActions({
|
||||
currentUser,
|
||||
fetchInterviews,
|
||||
fetchApplication,
|
||||
ktMatrixConfig,
|
||||
level2Config,
|
||||
level3Config,
|
||||
}: UseApplicationDetailsFeedbackActionsParams) {
|
||||
// Resolve active criteria/fields from config or fallback to hardcoded defaults
|
||||
const getKtCriteria = () => {
|
||||
if (ktMatrixConfig?.items && ktMatrixConfig.items.length > 0) {
|
||||
return ktMatrixConfig.items.map(item => ({
|
||||
name: item.label,
|
||||
weight: Number(item.weight) || 0,
|
||||
maxScore: Number(item.maxScore) || 10,
|
||||
options: (item.options || []).map(o => ({
|
||||
label: o.optionLabel,
|
||||
value: o.optionValue,
|
||||
score: Number(o.score) || 0,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
return KT_MATRIX_CRITERIA;
|
||||
};
|
||||
|
||||
const getLevel2Fields = () => {
|
||||
if (level2Config?.items && level2Config.items.length > 0) {
|
||||
return level2Config.items;
|
||||
}
|
||||
return [
|
||||
{ itemKey: 'strategicVision', label: 'Strategic Vision', isRequired: true },
|
||||
{ itemKey: 'managementCapabilities', label: 'Management Capabilities', isRequired: true },
|
||||
{ itemKey: 'operationalUnderstanding', label: 'Operational Understanding', isRequired: true },
|
||||
{ itemKey: 'keyStrengths', label: 'Key Strengths', isRequired: true },
|
||||
{ itemKey: 'areasOfConcern', label: 'Areas of Concern', isRequired: true },
|
||||
{ itemKey: 'additionalComments', label: 'Additional Comments', isRequired: false },
|
||||
];
|
||||
};
|
||||
|
||||
const getLevel3Fields = () => {
|
||||
if (level3Config?.items && level3Config.items.length > 0) {
|
||||
return level3Config.items;
|
||||
}
|
||||
return [
|
||||
{ itemKey: 'strategicVision', label: 'Business Vision & Strategy', isRequired: true },
|
||||
{ itemKey: 'managementCapabilities', label: 'Leadership & Decision Making', isRequired: true },
|
||||
{ itemKey: 'operationalUnderstanding', label: 'Operational & Financial Readiness', isRequired: true },
|
||||
{ itemKey: 'brandAlignment', label: 'Brand Alignment', isRequired: true },
|
||||
{ itemKey: 'keyStrengths', label: 'Key Strengths', isRequired: true },
|
||||
{ itemKey: 'areasOfConcern', label: 'Areas of Concern', isRequired: true },
|
||||
{ itemKey: 'executiveSummary', label: 'Executive Summary', isRequired: false },
|
||||
{ itemKey: 'additionalComments', label: 'Additional Comments', isRequired: false },
|
||||
];
|
||||
};
|
||||
|
||||
const ktCriteria = getKtCriteria();
|
||||
const l2Fields = getLevel2Fields();
|
||||
const l3Fields = getLevel3Fields();
|
||||
|
||||
const handleKTMatrixChange = (criterionName: string, value: string, score: number) => {
|
||||
setKtMatrixScores((prev) => ({ ...prev, [criterionName]: score }));
|
||||
setKtMatrixSelectedValues((prev) => ({ ...prev, [criterionName]: value }));
|
||||
@ -83,15 +141,17 @@ export function useApplicationDetailsFeedbackActions({
|
||||
|
||||
const calculateKTScore = () => {
|
||||
let totalWeightedScore = 0;
|
||||
KT_MATRIX_CRITERIA.forEach((criterion) => {
|
||||
const score = ktMatrixScores[criterion.name] || 0;
|
||||
totalWeightedScore += (score / criterion.maxScore) * criterion.weight;
|
||||
ktCriteria.forEach((criterion: any) => {
|
||||
const score = ktMatrixScores[criterion.name || criterion.label] || 0;
|
||||
const maxScore = criterion.maxScore || 10;
|
||||
const weight = criterion.weight || 0;
|
||||
totalWeightedScore += (score / maxScore) * weight;
|
||||
});
|
||||
return totalWeightedScore.toFixed(2);
|
||||
};
|
||||
|
||||
const handleSubmitKTMatrix = async () => {
|
||||
if (Object.keys(ktMatrixScores).length < KT_MATRIX_CRITERIA.length) {
|
||||
if (Object.keys(ktMatrixScores).length < ktCriteria.length) {
|
||||
toast.warning('Please fill all fields in the KT Matrix');
|
||||
return;
|
||||
}
|
||||
@ -102,11 +162,11 @@ export function useApplicationDetailsFeedbackActions({
|
||||
}
|
||||
try {
|
||||
setIsSubmittingKT(true);
|
||||
const criteriaScores = KT_MATRIX_CRITERIA.map((c) => ({
|
||||
criterionName: c.name,
|
||||
score: ktMatrixScores[c.name] || 0,
|
||||
maxScore: c.maxScore,
|
||||
weightage: c.weight,
|
||||
const criteriaScores = ktCriteria.map((c: any) => ({
|
||||
criterionName: c.name || c.label,
|
||||
score: ktMatrixScores[c.name || c.label] || 0,
|
||||
maxScore: c.maxScore || 10,
|
||||
weightage: c.weight || 0,
|
||||
}));
|
||||
await onboardingService.submitKTMatrix({ interviewId, criteriaScores, feedback: ktMatrixRemarks, recommendation: null });
|
||||
toast.success('KT Matrix submitted successfully');
|
||||
@ -139,14 +199,9 @@ export function useApplicationDetailsFeedbackActions({
|
||||
}
|
||||
try {
|
||||
setIsSubmittingLevel2(true);
|
||||
const feedbackItems = [
|
||||
{ type: 'Strategic Vision', comments: level2Feedback.strategicVision },
|
||||
{ type: 'Management Capabilities', comments: level2Feedback.managementCapabilities },
|
||||
{ type: 'Operational Understanding', comments: level2Feedback.operationalUnderstanding },
|
||||
{ type: 'Key Strengths', comments: level2Feedback.keyStrengths },
|
||||
{ type: 'Areas of Concern', comments: level2Feedback.areasOfConcern },
|
||||
{ type: 'Additional Comments', comments: level2Feedback.additionalComments },
|
||||
].filter((item) => item.comments.trim() !== '');
|
||||
const feedbackItems = l2Fields
|
||||
.map((f: any) => ({ type: f.label, comments: level2Feedback[f.itemKey] || '' }))
|
||||
.filter((item) => item.comments.trim() !== '');
|
||||
await onboardingService.submitLevel2Feedback({ interviewId, overallScore: Number(level2Feedback.overallScore), feedbackItems });
|
||||
toast.success('Level 2 Feedback submitted successfully');
|
||||
setShowLevel2FeedbackModal(false);
|
||||
@ -176,16 +231,9 @@ export function useApplicationDetailsFeedbackActions({
|
||||
}
|
||||
try {
|
||||
setIsSubmittingLevel3(true);
|
||||
const feedbackItems = [
|
||||
{ type: 'Business Vision & Strategy', comments: level3Feedback.strategicVision },
|
||||
{ type: 'Leadership & Decision Making', comments: level3Feedback.managementCapabilities },
|
||||
{ type: 'Operational & Financial Readiness', comments: level3Feedback.operationalUnderstanding },
|
||||
{ type: 'Brand Alignment', comments: level3Feedback.brandAlignment },
|
||||
{ type: 'Key Strengths', comments: level3Feedback.keyStrengths },
|
||||
{ type: 'Areas of Concern', comments: level3Feedback.areasOfConcern },
|
||||
{ type: 'Executive Summary', comments: level3Feedback.executiveSummary },
|
||||
{ type: 'Additional Comments', comments: level3Feedback.additionalComments },
|
||||
].filter((item) => item.comments.trim() !== '');
|
||||
const feedbackItems = l3Fields
|
||||
.map((f: any) => ({ type: f.label, comments: level3Feedback[f.itemKey] || '' }))
|
||||
.filter((item) => item.comments.trim() !== '');
|
||||
await onboardingService.submitLevel2Feedback({ interviewId, overallScore: Number(level3Feedback.overallScore), feedbackItems });
|
||||
toast.success('Level 3 Feedback submitted successfully');
|
||||
setShowLevel3FeedbackModal(false);
|
||||
@ -207,6 +255,9 @@ export function useApplicationDetailsFeedbackActions({
|
||||
handleSubmitLevel2Feedback,
|
||||
handleLevel3Change,
|
||||
handleSubmitLevel3Feedback,
|
||||
ktCriteria,
|
||||
l2Fields,
|
||||
l3Fields,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -88,7 +88,7 @@ export function useApplicationDetailsPermissions({
|
||||
const ddHeadLoaApproved = application.stageApprovals?.some((a: any) => a.stageCode === 'LOA_APPROVAL' && a.actorRole === 'DD Head' && a.decision === 'Approved');
|
||||
|
||||
let sequenceMet = true;
|
||||
if (!['Super Admin', 'DD Admin'].includes(currentUser.role)) {
|
||||
if (!['Super Admin', 'DD Admin', 'DD Lead', 'DD Head'].includes(currentUser.role)) {
|
||||
if (application.status === 'FDD Verification' || application.status === 'Level 3 Approved') sequenceMet = false;
|
||||
if (application.status === 'LOI In Progress') sequenceMet = currentUser.role === 'NBH' ? !!ddHeadApproved : currentUser.role === 'DD Head';
|
||||
if (application.status === 'LOA Pending') sequenceMet = currentUser.role === 'NBH' ? !!ddHeadLoaApproved : currentUser.role === 'DD Head';
|
||||
|
||||
@ -5,16 +5,10 @@ interface UseApplicationDetailsUIStateParams {
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
const getToday = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
export function useApplicationDetailsUIState({
|
||||
currentUser,
|
||||
initialTab = 'questionnaire',
|
||||
}: UseApplicationDetailsUIStateParams) {
|
||||
export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: UseApplicationDetailsUIStateParams) {
|
||||
const [showFirmTypeModal, setShowFirmTypeModal] = useState(false);
|
||||
const [updatingFirmType, setUpdatingFirmType] = useState(false);
|
||||
const [tempFirmType, setTempFirmType] = useState('');
|
||||
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showOnboardModal, setShowOnboardModal] = useState(false);
|
||||
@ -29,15 +23,12 @@ export function useApplicationDetailsUIState({
|
||||
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
const [selectedStage, setSelectedStage] = useState<string | null>(null);
|
||||
const [interviewMode, setInterviewMode] = useState('virtual');
|
||||
const [interviewMode, setInterviewMode] = useState('physical');
|
||||
const [approvalRemark, setApprovalRemark] = useState('');
|
||||
const [expandedBranches, setExpandedBranches] = useState<{ [key: string]: boolean }>({
|
||||
'architectural-work': true,
|
||||
'statutory-documents': true,
|
||||
});
|
||||
const [expandedBranches, setExpandedBranches] = useState<Record<string, boolean>>({});
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<string>('');
|
||||
const [participantType, setParticipantType] = useState<string>('contributor');
|
||||
const [selectedUser, setSelectedUser] = useState('');
|
||||
const [participantType, setParticipantType] = useState('contributor');
|
||||
const [interviewDate, setInterviewDate] = useState('');
|
||||
const [interviewType, setInterviewType] = useState('level1');
|
||||
const [meetingLink, setMeetingLink] = useState('');
|
||||
@ -49,77 +40,41 @@ export function useApplicationDetailsUIState({
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewDoc, setPreviewDoc] = useState<any>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [selectedInterviewerId, setSelectedInterviewerId] = useState<string>('');
|
||||
const [selectedInterviewerId, setSelectedInterviewerId] = useState('');
|
||||
const [isEditingStatutory, setIsEditingStatutory] = useState(false);
|
||||
const [statutoryForm, setStatutoryForm] = useState({
|
||||
accountHolderName: '',
|
||||
panNumber: '',
|
||||
gstNumber: '',
|
||||
bankName: '',
|
||||
accountNumber: '',
|
||||
ifscCode: '',
|
||||
registeredAddress: '',
|
||||
});
|
||||
const [statutoryForm, setStatutoryForm] = useState<any>({});
|
||||
const [isSavingStatutory, setIsSavingStatutory] = useState(false);
|
||||
|
||||
const [interviews, setInterviews] = useState<any[]>([]);
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
const [showAssignArchitectureModal, setShowAssignArchitectureModal] = useState(false);
|
||||
const [architectureLeadId, setArchitectureLeadId] = useState<string>('');
|
||||
const [architectureLeadId, setArchitectureLeadId] = useState('');
|
||||
const [isAssigningArchitecture, setIsAssigningArchitecture] = useState(false);
|
||||
const [showArchitectureStatusModal, setShowArchitectureStatusModal] = useState(false);
|
||||
const [architectureStatus, setArchitectureStatus] = useState<string>('COMPLETED');
|
||||
const [architectureRemarks, setArchitectureRemarks] = useState<string>('');
|
||||
const [architectureStatus, setArchitectureStatus] = useState('');
|
||||
const [architectureRemarks, setArchitectureRemarks] = useState('');
|
||||
const [isUpdatingArchitecture, setIsUpdatingArchitecture] = useState(false);
|
||||
const [isAssigningParticipant, setIsAssigningParticipant] = useState(false);
|
||||
const [documentConfigs, setDocumentConfigs] = useState<any[]>([]);
|
||||
const [fddAgencies, setFddAgencies] = useState<any[]>([]);
|
||||
const [selectedAgencyId, setSelectedAgencyId] = useState<string>('');
|
||||
const [selectedAgencyId, setSelectedAgencyId] = useState('');
|
||||
const [isAssigningAgency, setIsAssigningAgency] = useState(false);
|
||||
const [isApproving, setIsApproving] = useState(false);
|
||||
const [isRejecting, setIsRejecting] = useState(false);
|
||||
|
||||
const [ktMatrixScores, setKtMatrixScores] = useState<Record<string, number>>({});
|
||||
const [ktMatrixSelectedValues, setKtMatrixSelectedValues] = useState<Record<string, string>>({});
|
||||
const [ktMatrixRemarks, setKtMatrixRemarks] = useState('');
|
||||
const [ktMatrixRemarks, setKtMatrixRemarks] = useState<string>('');
|
||||
const [isSubmittingKT, setIsSubmittingKT] = useState(false);
|
||||
const [selectedInterviewForFeedback, setSelectedInterviewForFeedback] = useState<any>(null);
|
||||
|
||||
const [showFddFinalizeModal, setShowFddFinalizeModal] = useState(false);
|
||||
const [showFddFlagModal, setShowFddFlagModal] = useState(false);
|
||||
const [fddAuditRecommendation, setFddAuditRecommendation] = useState<string>('Recommended');
|
||||
const [fddAuditFindings, setFddAuditFindings] = useState<string>('');
|
||||
const [isFinalizingFdd, setIsFinalizingFdd] = useState(false);
|
||||
const [isFddFlagging, setIsFddFlagging] = useState(false);
|
||||
|
||||
const [level2Feedback, setLevel2Feedback] = useState({
|
||||
strategicVision: '',
|
||||
managementCapabilities: '',
|
||||
operationalUnderstanding: '',
|
||||
keyStrengths: '',
|
||||
areasOfConcern: '',
|
||||
additionalComments: '',
|
||||
overallScore: '',
|
||||
interviewerName: currentUser?.name || '',
|
||||
interviewDate: getToday(),
|
||||
});
|
||||
const [level2Feedback, setLevel2Feedback] = useState<any>({});
|
||||
const [isSubmittingLevel2, setIsSubmittingLevel2] = useState(false);
|
||||
|
||||
const [level3Feedback, setLevel3Feedback] = useState({
|
||||
strategicVision: '',
|
||||
managementCapabilities: '',
|
||||
operationalUnderstanding: '',
|
||||
brandAlignment: '',
|
||||
executiveSummary: '',
|
||||
keyStrengths: '',
|
||||
areasOfConcern: '',
|
||||
additionalComments: '',
|
||||
overallScore: '',
|
||||
interviewerName: currentUser?.name || '',
|
||||
interviewDate: getToday(),
|
||||
});
|
||||
const [level3Feedback, setLevel3Feedback] = useState<any>({});
|
||||
const [isSubmittingLevel3, setIsSubmittingLevel3] = useState(false);
|
||||
|
||||
const [selectedEvaluationForView, setSelectedEvaluationForView] = useState<any>(null);
|
||||
const [showFeedbackDetailsModal, setShowFeedbackDetailsModal] = useState(false);
|
||||
|
||||
|
||||
88
src/features/onboarding/hooks/useInterviewConfigs.ts
Normal file
88
src/features/onboarding/hooks/useInterviewConfigs.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { API } from '@/api/API';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface InterviewConfigItemOption {
|
||||
id?: string;
|
||||
optionLabel: string;
|
||||
optionValue: string;
|
||||
score: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface InterviewConfigItem {
|
||||
id?: string;
|
||||
itemKey: string;
|
||||
label: string;
|
||||
type: 'select' | 'text' | 'textarea' | 'number';
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
weight: number | null;
|
||||
maxScore: number | null;
|
||||
options?: InterviewConfigItemOption[];
|
||||
}
|
||||
|
||||
export interface InterviewConfig {
|
||||
id?: string;
|
||||
configType: 'KT_MATRIX' | 'LEVEL2_FEEDBACK' | 'LEVEL3_FEEDBACK';
|
||||
name: string;
|
||||
version: string;
|
||||
isActive: boolean;
|
||||
items?: InterviewConfigItem[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export function useInterviewConfigs() {
|
||||
const [ktMatrixConfig, setKtMatrixConfig] = useState<InterviewConfig | null>(null);
|
||||
const [level2Config, setLevel2Config] = useState<InterviewConfig | null>(null);
|
||||
const [level3Config, setLevel3Config] = useState<InterviewConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchConfig = useCallback(async (configType: string) => {
|
||||
try {
|
||||
const response: any = await API.getInterviewConfigByType(configType);
|
||||
if (response.data?.success) {
|
||||
return response.data.data as InterviewConfig;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status !== 404) {
|
||||
console.warn(`Failed to fetch ${configType} config:`, err);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const loadAllConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [kt, l2, l3] = await Promise.all([
|
||||
fetchConfig('KT_MATRIX'),
|
||||
fetchConfig('LEVEL2_FEEDBACK'),
|
||||
fetchConfig('LEVEL3_FEEDBACK')
|
||||
]);
|
||||
setKtMatrixConfig(kt);
|
||||
setLevel2Config(l2);
|
||||
setLevel3Config(l3);
|
||||
} catch (err) {
|
||||
setError('Failed to load interview configurations');
|
||||
toast.error('Failed to load interview configurations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAllConfigs();
|
||||
}, [loadAllConfigs]);
|
||||
|
||||
return {
|
||||
ktMatrixConfig,
|
||||
level2Config,
|
||||
level3Config,
|
||||
loading,
|
||||
error,
|
||||
refresh: loadAllConfigs
|
||||
};
|
||||
}
|
||||
@ -12,6 +12,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
@ -19,7 +28,8 @@ import {
|
||||
List,
|
||||
Mail,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@ -56,10 +66,18 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [states, setStates] = useState<string[]>([]);
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
const [initialFetchDone, setInitialFetchDone] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
}, [currentPage, searchQuery, statusFilter, locationFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, statusFilter, locationFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
@ -77,8 +95,17 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await onboardingService.getApplications();
|
||||
const rawData = response.data || (Array.isArray(response) ? response : []);
|
||||
const response = await onboardingService.getApplications({
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
search: searchQuery,
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
location: locationFilter !== 'all' ? locationFilter : undefined,
|
||||
state: stateFilter !== 'all' ? stateFilter : undefined,
|
||||
isShortlisted: undefined
|
||||
});
|
||||
const rawData = response.data || [];
|
||||
setPaginationMeta(response.meta);
|
||||
|
||||
// Map backend data to Application interface
|
||||
const mappedApps: Application[] = rawData.map((app: any) => ({
|
||||
@ -128,23 +155,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
||||
}
|
||||
};
|
||||
|
||||
// Filter applications
|
||||
const filteredApplications = applicationsData.filter((app) => {
|
||||
// For "All Applications", we show everything that hasn't reached final stages?
|
||||
// Actually, usually "All Applications" means everything.
|
||||
// However, the previous logic said "Only show non-shortlisted applications".
|
||||
// That's weird for an "All Applications" page.
|
||||
|
||||
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(app.phone && app.phone.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||
(app.email && app.email.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
const matchesStatus = statusFilter === 'all' || app.status === statusFilter;
|
||||
const matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
|
||||
const matchesState = stateFilter === 'all' || app.state === stateFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesLocation && matchesState;
|
||||
});
|
||||
const filteredApplications = applicationsData;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
@ -369,7 +380,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
||||
|
||||
<div className="ml-auto">
|
||||
<Badge variant="outline" className="text-slate-600" data-testid="onboarding-all-apps-pending-badge">
|
||||
{filteredApplications.length} pending shortlisting
|
||||
{paginationMeta?.total || filteredApplications.length} pending shortlisting
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@ -377,7 +388,11 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
||||
</div>
|
||||
|
||||
{/* Applications Grid/Table */}
|
||||
{viewMode === 'grid' ? (
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-96 bg-white rounded-lg border border-slate-200">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" data-testid="onboarding-all-apps-grid-container">
|
||||
{filteredApplications.map((app, idx) => (
|
||||
<div key={app.id} className="relative" data-testid={`onboarding-all-apps-grid-item-${idx}`}>
|
||||
@ -474,6 +489,61 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-6 border-t border-slate-200 bg-white rounded-b-lg">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<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;
|
||||
// Complex pagination: show first, last, and current +/- 1
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
} else if (
|
||||
pageNum === currentPage - 2 ||
|
||||
pageNum === currentPage + 2
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(p => Math.min(paginationMeta.totalPages, p + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shortlist Modal */}
|
||||
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
||||
<DialogContent data-testid="onboarding-all-apps-shortlist-modal">
|
||||
|
||||
@ -8,10 +8,11 @@ import { ApplicationDetailsSidebar } from '@/features/onboarding/components/appl
|
||||
import { ApplicationDetailsActionModals } from '@/features/onboarding/components/application-details/ApplicationDetailsActionModals';
|
||||
import { ApplicationDetailsExtendedModals } from '@/features/onboarding/components/application-details/ApplicationDetailsExtendedModals';
|
||||
import { ApplicationDetailsFddAuditContent } from '@/features/onboarding/components/application-details/ApplicationDetailsFddAuditContent';
|
||||
import { KT_MATRIX_CRITERIA, auditLogActionBadgeClass } from '@/features/onboarding/components/application-details/applicationDetails.shared';
|
||||
import { auditLogActionBadgeClass } from '@/features/onboarding/components/application-details/applicationDetails.shared';
|
||||
import { useApplicationDetailsPermissions } from '@/features/onboarding/hooks/useApplicationDetailsPermissions';
|
||||
import { useApplicationDetailsUIState } from '@/features/onboarding/hooks/useApplicationDetailsUIState';
|
||||
import { useApplicationDetailsFeedbackActions } from '@/features/onboarding/hooks/useApplicationDetailsFeedbackActions';
|
||||
import { useInterviewConfigs } from '@/features/onboarding/hooks/useInterviewConfigs';
|
||||
import { useApplicationDetailsAdminActions } from '@/features/onboarding/hooks/useApplicationDetailsAdminActions';
|
||||
import { useApplicationDetailsData } from '@/features/onboarding/hooks/useApplicationDetailsData';
|
||||
import { useApplicationDetailsLocalActions } from '@/features/onboarding/hooks/useApplicationDetailsLocalActions';
|
||||
@ -20,15 +21,13 @@ import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
|
||||
|
||||
export const ApplicationDetails = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||
const applicationId = id || '';
|
||||
const onBack = () => navigate(-1);
|
||||
// const application = mockApplications.find(app => app.id === applicationId);
|
||||
|
||||
const {
|
||||
application,
|
||||
loading,
|
||||
@ -119,8 +118,8 @@ export const ApplicationDetails = () => {
|
||||
isSubmittingLevel2, setIsSubmittingLevel2,
|
||||
level3Feedback, setLevel3Feedback,
|
||||
isSubmittingLevel3, setIsSubmittingLevel3,
|
||||
selectedEvaluationForView, setSelectedEvaluationForView,
|
||||
showFeedbackDetailsModal, setShowFeedbackDetailsModal,
|
||||
selectedEvaluationForView, setSelectedEvaluationForView,
|
||||
} = useApplicationDetailsUIState({
|
||||
currentUser,
|
||||
initialTab: routerLocation.state?.activeTab || 'questionnaire',
|
||||
@ -158,12 +157,10 @@ export const ApplicationDetails = () => {
|
||||
currentUser?.role === 'NBH' || currentUser?.role === 'DD Head' ||
|
||||
currentUser?.roleCode === 'NBH' || currentUser?.roleCode === 'DD_HEAD';
|
||||
|
||||
// Fetch document configurations
|
||||
// Fetch document configurations
|
||||
useEffect(() => {
|
||||
const fetchConfigs = async () => {
|
||||
try {
|
||||
const res = await onboardingService.getDocumentConfigs({ limit: 1000 }); // Fetch all for lookup
|
||||
const res = await onboardingService.getDocumentConfigs({ limit: 1000 });
|
||||
const configs = res.data || (Array.isArray(res) ? res : []);
|
||||
setDocumentConfigs(configs);
|
||||
} catch (error) {
|
||||
@ -171,9 +168,8 @@ export const ApplicationDetails = () => {
|
||||
}
|
||||
};
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
}, [setDocumentConfigs]);
|
||||
|
||||
// Auto-select valid interview level based on application status when scheduling
|
||||
useEffect(() => {
|
||||
if (showScheduleModal && application) {
|
||||
if (application.status === 'Shortlisted' || application.status === 'Questionnaire Completed') {
|
||||
@ -184,12 +180,7 @@ export const ApplicationDetails = () => {
|
||||
setInterviewType('level3');
|
||||
}
|
||||
}
|
||||
}, [showScheduleModal, application?.status]);
|
||||
|
||||
// KT Matrix State
|
||||
|
||||
// Payment Details State
|
||||
// Feedback Details Modal State
|
||||
}, [showScheduleModal, application?.status, setInterviewType]);
|
||||
|
||||
const fetchInterviews = async () => {
|
||||
if (applicationId) {
|
||||
@ -206,6 +197,12 @@ export const ApplicationDetails = () => {
|
||||
fetchInterviews();
|
||||
}, [applicationId]);
|
||||
|
||||
const {
|
||||
ktMatrixConfig,
|
||||
level2Config,
|
||||
level3Config,
|
||||
} = useInterviewConfigs();
|
||||
|
||||
const {
|
||||
handleKTMatrixChange,
|
||||
calculateKTScore,
|
||||
@ -214,6 +211,9 @@ export const ApplicationDetails = () => {
|
||||
handleSubmitLevel2Feedback,
|
||||
handleLevel3Change,
|
||||
handleSubmitLevel3Feedback,
|
||||
ktCriteria,
|
||||
l2Fields,
|
||||
l3Fields,
|
||||
} = useApplicationDetailsFeedbackActions({
|
||||
ktMatrixScores,
|
||||
setKtMatrixScores,
|
||||
@ -235,6 +235,9 @@ export const ApplicationDetails = () => {
|
||||
currentUser,
|
||||
fetchInterviews,
|
||||
fetchApplication,
|
||||
ktMatrixConfig,
|
||||
level2Config,
|
||||
level3Config,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -244,7 +247,8 @@ export const ApplicationDetails = () => {
|
||||
if (activeTab === 'fdd' && (currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin')) {
|
||||
fetchFddAgencies();
|
||||
}
|
||||
}, [activeTab, applicationId]);
|
||||
}, [activeTab, applicationId, refreshDocuments, fetchFddAgencies, currentUser?.role]);
|
||||
|
||||
const {
|
||||
handleAddInterviewer,
|
||||
handleRemoveInterviewer,
|
||||
@ -318,23 +322,7 @@ export const ApplicationDetails = () => {
|
||||
|
||||
useEffect(() => {
|
||||
maybeFetchUsersForModal();
|
||||
}, [showScheduleModal, showAssignArchitectureModal, showAssignModal, interviewType, application?.participants]);
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading application details...</div>;
|
||||
}
|
||||
|
||||
if (!application) {
|
||||
return <div>Application not found</div>;
|
||||
}
|
||||
|
||||
const { processStages, eorChecklist, flattenedStages, getDocumentsForStage } = useApplicationDetailsStageData({
|
||||
application,
|
||||
documents,
|
||||
interviews,
|
||||
eorData,
|
||||
getDeposit,
|
||||
});
|
||||
}, [showScheduleModal, showAssignArchitectureModal, showAssignModal, interviewType, application?.participants, maybeFetchUsersForModal]);
|
||||
|
||||
if (loading && !application) {
|
||||
return (
|
||||
@ -348,6 +336,14 @@ export const ApplicationDetails = () => {
|
||||
return <div className="flex justify-center items-center h-96">Application not found</div>;
|
||||
}
|
||||
|
||||
const { processStages, eorChecklist, flattenedStages, getDocumentsForStage } = useApplicationDetailsStageData({
|
||||
application,
|
||||
documents,
|
||||
interviews,
|
||||
eorData,
|
||||
getDeposit,
|
||||
});
|
||||
|
||||
const {
|
||||
activeInterviewForUser,
|
||||
currentUserEvaluation,
|
||||
@ -364,9 +360,6 @@ export const ApplicationDetails = () => {
|
||||
eorProgress,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
const renderFddAuditContent = () => (
|
||||
<ApplicationDetailsFddAuditContent
|
||||
application={application}
|
||||
@ -385,7 +378,6 @@ export const ApplicationDetails = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ApplicationDetailsHeader
|
||||
@ -402,10 +394,7 @@ export const ApplicationDetails = () => {
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<ApplicantInformationCard
|
||||
application={application}
|
||||
@ -423,9 +412,6 @@ export const ApplicationDetails = () => {
|
||||
onStatutoryFormChange={setStatutoryForm}
|
||||
/>
|
||||
|
||||
{/* Tabs Section */}
|
||||
{/* Only show tabs for shortlisted applications (opportunity requests and regular dealership requests) */}
|
||||
{/* Hide tabs for non-opportunity requests (lead generation) */}
|
||||
{application.isShortlisted !== false && (
|
||||
<ApplicationDetailsTabs
|
||||
application={application}
|
||||
@ -463,7 +449,6 @@ export const ApplicationDetails = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Summary and Actions */}
|
||||
<ApplicationDetailsSidebar
|
||||
application={application}
|
||||
permissions={permissions}
|
||||
@ -502,131 +487,134 @@ export const ApplicationDetails = () => {
|
||||
handleAddParticipant={handleAddParticipant}
|
||||
isAssigningParticipant={isAssigningParticipant}
|
||||
/>
|
||||
<ApplicationDetailsActionModals
|
||||
application={application}
|
||||
fetchApplication={fetchApplication}
|
||||
showApproveModal={showApproveModal}
|
||||
setShowApproveModal={setShowApproveModal}
|
||||
approvalRemark={approvalRemark}
|
||||
setApprovalRemark={setApprovalRemark}
|
||||
setApprovalFile={setApprovalFile}
|
||||
isApproving={isApproving}
|
||||
handleApprove={handleApprove}
|
||||
showOnboardModal={showOnboardModal}
|
||||
setShowOnboardModal={setShowOnboardModal}
|
||||
isOnboarding={isOnboarding}
|
||||
setIsOnboarding={setIsOnboarding}
|
||||
showRejectModal={showRejectModal}
|
||||
setShowRejectModal={setShowRejectModal}
|
||||
rejectionReason={rejectionReason}
|
||||
setRejectionReason={setRejectionReason}
|
||||
isRejecting={isRejecting}
|
||||
handleReject={handleReject}
|
||||
showScheduleModal={showScheduleModal}
|
||||
setShowScheduleModal={setShowScheduleModal}
|
||||
interviewType={interviewType}
|
||||
setInterviewType={setInterviewType}
|
||||
interviewMode={interviewMode}
|
||||
setInterviewMode={setInterviewMode}
|
||||
interviewDate={interviewDate}
|
||||
setInterviewDate={setInterviewDate}
|
||||
meetingLink={meetingLink}
|
||||
setMeetingLink={setMeetingLink}
|
||||
location={location}
|
||||
setLocation={setLocation}
|
||||
isInterviewCompleted={isInterviewCompleted}
|
||||
isInterviewActive={isInterviewActive}
|
||||
users={users}
|
||||
selectedInterviewerId={selectedInterviewerId}
|
||||
setSelectedInterviewerId={setSelectedInterviewerId}
|
||||
handleAddInterviewer={handleAddInterviewer}
|
||||
scheduledInterviewParticipants={scheduledInterviewParticipants}
|
||||
handleRemoveInterviewer={handleRemoveInterviewer}
|
||||
isScheduling={isScheduling}
|
||||
handleScheduleInterview={handleScheduleInterview}
|
||||
showAssignArchitectureModal={showAssignArchitectureModal}
|
||||
setShowAssignArchitectureModal={setShowAssignArchitectureModal}
|
||||
architectureLeadId={architectureLeadId}
|
||||
setArchitectureLeadId={setArchitectureLeadId}
|
||||
isAssigningArchitecture={isAssigningArchitecture}
|
||||
handleAssignArchitecture={handleAssignArchitecture}
|
||||
showArchitectureStatusModal={showArchitectureStatusModal}
|
||||
setShowArchitectureStatusModal={setShowArchitectureStatusModal}
|
||||
architectureStatus={architectureStatus}
|
||||
setArchitectureStatus={setArchitectureStatus}
|
||||
architectureRemarks={architectureRemarks}
|
||||
setArchitectureRemarks={setArchitectureRemarks}
|
||||
isUpdatingArchitecture={isUpdatingArchitecture}
|
||||
handleUpdateArchitectureStatus={handleUpdateArchitectureStatus}
|
||||
/>
|
||||
|
||||
<ApplicationDetailsExtendedModals
|
||||
application={application}
|
||||
KT_MATRIX_CRITERIA={KT_MATRIX_CRITERIA}
|
||||
showKTMatrixModal={showKTMatrixModal}
|
||||
setShowKTMatrixModal={setShowKTMatrixModal}
|
||||
ktMatrixSelectedValues={ktMatrixSelectedValues}
|
||||
handleKTMatrixChange={handleKTMatrixChange}
|
||||
ktMatrixRemarks={ktMatrixRemarks}
|
||||
setKtMatrixRemarks={setKtMatrixRemarks}
|
||||
calculateKTScore={calculateKTScore}
|
||||
handleSubmitKTMatrix={handleSubmitKTMatrix}
|
||||
isSubmittingKT={isSubmittingKT}
|
||||
showLevel2FeedbackModal={showLevel2FeedbackModal}
|
||||
setShowLevel2FeedbackModal={setShowLevel2FeedbackModal}
|
||||
level2Feedback={level2Feedback}
|
||||
handleLevel2Change={handleLevel2Change}
|
||||
handleSubmitLevel2Feedback={handleSubmitLevel2Feedback}
|
||||
isSubmittingLevel2={isSubmittingLevel2}
|
||||
showFeedbackDetailsModal={showFeedbackDetailsModal}
|
||||
setShowFeedbackDetailsModal={setShowFeedbackDetailsModal}
|
||||
selectedEvaluationForView={selectedEvaluationForView}
|
||||
showLevel3FeedbackModal={showLevel3FeedbackModal}
|
||||
setShowLevel3FeedbackModal={setShowLevel3FeedbackModal}
|
||||
level3Feedback={level3Feedback}
|
||||
handleLevel3Change={handleLevel3Change}
|
||||
handleSubmitLevel3Feedback={handleSubmitLevel3Feedback}
|
||||
isSubmittingLevel3={isSubmittingLevel3}
|
||||
showDocumentsModal={showDocumentsModal}
|
||||
setShowDocumentsModal={setShowDocumentsModal}
|
||||
showUploadForm={showUploadForm}
|
||||
setShowUploadForm={setShowUploadForm}
|
||||
selectedStage={selectedStage}
|
||||
getDocumentsForStage={getDocumentsForStage}
|
||||
setPreviewDoc={setPreviewDoc}
|
||||
setShowPreviewModal={setShowPreviewModal}
|
||||
flattenedStages={flattenedStages}
|
||||
setSelectedStage={setSelectedStage}
|
||||
uploadDocType={uploadDocType}
|
||||
setUploadDocType={setUploadDocType}
|
||||
setUploadFile={setUploadFile}
|
||||
isUploading={isUploading}
|
||||
handleUpload={handleUpload}
|
||||
uploadFile={uploadFile}
|
||||
documentConfigs={documentConfigs}
|
||||
showPreviewModal={showPreviewModal}
|
||||
previewDoc={previewDoc}
|
||||
showFddFinalizeModal={showFddFinalizeModal}
|
||||
setShowFddFinalizeModal={setShowFddFinalizeModal}
|
||||
currentUser={currentUser}
|
||||
fddAuditRecommendation={fddAuditRecommendation}
|
||||
setFddAuditRecommendation={setFddAuditRecommendation}
|
||||
fddAuditFindings={fddAuditFindings}
|
||||
setFddAuditFindings={setFddAuditFindings}
|
||||
isFinalizingFdd={isFinalizingFdd}
|
||||
setIsFinalizingFdd={setIsFinalizingFdd}
|
||||
fetchApplication={fetchApplication}
|
||||
showFddFlagModal={showFddFlagModal}
|
||||
setShowFddFlagModal={setShowFddFlagModal}
|
||||
isFddFlagging={isFddFlagging}
|
||||
setIsFddFlagging={setIsFddFlagging}
|
||||
showFirmTypeModal={showFirmTypeModal}
|
||||
setShowFirmTypeModal={setShowFirmTypeModal}
|
||||
tempFirmType={tempFirmType}
|
||||
setTempFirmType={setTempFirmType}
|
||||
updatingFirmType={updatingFirmType}
|
||||
handleUpdateFirmType={handleUpdateFirmType}
|
||||
/>
|
||||
<ApplicationDetailsActionModals
|
||||
application={application}
|
||||
fetchApplication={fetchApplication}
|
||||
showApproveModal={showApproveModal}
|
||||
setShowApproveModal={setShowApproveModal}
|
||||
approvalRemark={approvalRemark}
|
||||
setApprovalRemark={setApprovalRemark}
|
||||
setApprovalFile={setApprovalFile}
|
||||
isApproving={isApproving}
|
||||
handleApprove={handleApprove}
|
||||
showOnboardModal={showOnboardModal}
|
||||
setShowOnboardModal={setShowOnboardModal}
|
||||
isOnboarding={isOnboarding}
|
||||
setIsOnboarding={setIsOnboarding}
|
||||
showRejectModal={showRejectModal}
|
||||
setShowRejectModal={setShowRejectModal}
|
||||
rejectionReason={rejectionReason}
|
||||
setRejectionReason={setRejectionReason}
|
||||
isRejecting={isRejecting}
|
||||
handleReject={handleReject}
|
||||
showScheduleModal={showScheduleModal}
|
||||
setShowScheduleModal={setShowScheduleModal}
|
||||
interviewType={interviewType}
|
||||
setInterviewType={setInterviewType}
|
||||
interviewMode={interviewMode}
|
||||
setInterviewMode={setInterviewMode}
|
||||
interviewDate={interviewDate}
|
||||
setInterviewDate={setInterviewDate}
|
||||
meetingLink={meetingLink}
|
||||
setMeetingLink={setMeetingLink}
|
||||
location={location}
|
||||
setLocation={setLocation}
|
||||
isInterviewCompleted={isInterviewCompleted}
|
||||
isInterviewActive={isInterviewActive}
|
||||
users={users}
|
||||
selectedInterviewerId={selectedInterviewerId}
|
||||
setSelectedInterviewerId={setSelectedInterviewerId}
|
||||
handleAddInterviewer={handleAddInterviewer}
|
||||
scheduledInterviewParticipants={scheduledInterviewParticipants}
|
||||
handleRemoveInterviewer={handleRemoveInterviewer}
|
||||
isScheduling={isScheduling}
|
||||
handleScheduleInterview={handleScheduleInterview}
|
||||
showAssignArchitectureModal={showAssignArchitectureModal}
|
||||
setShowAssignArchitectureModal={setShowAssignArchitectureModal}
|
||||
architectureLeadId={architectureLeadId}
|
||||
setArchitectureLeadId={setArchitectureLeadId}
|
||||
isAssigningArchitecture={isAssigningArchitecture}
|
||||
handleAssignArchitecture={handleAssignArchitecture}
|
||||
showArchitectureStatusModal={showArchitectureStatusModal}
|
||||
setShowArchitectureStatusModal={setShowArchitectureStatusModal}
|
||||
architectureStatus={architectureStatus}
|
||||
setArchitectureStatus={setArchitectureStatus}
|
||||
architectureRemarks={architectureRemarks}
|
||||
setArchitectureRemarks={setArchitectureRemarks}
|
||||
isUpdatingArchitecture={isUpdatingArchitecture}
|
||||
handleUpdateArchitectureStatus={handleUpdateArchitectureStatus}
|
||||
/>
|
||||
|
||||
<ApplicationDetailsExtendedModals
|
||||
application={application}
|
||||
ktCriteria={ktCriteria}
|
||||
l2Fields={l2Fields}
|
||||
l3Fields={l3Fields}
|
||||
showKTMatrixModal={showKTMatrixModal}
|
||||
setShowKTMatrixModal={setShowKTMatrixModal}
|
||||
ktMatrixSelectedValues={ktMatrixSelectedValues}
|
||||
handleKTMatrixChange={handleKTMatrixChange}
|
||||
ktMatrixRemarks={ktMatrixRemarks}
|
||||
setKtMatrixRemarks={setKtMatrixRemarks}
|
||||
calculateKTScore={calculateKTScore}
|
||||
handleSubmitKTMatrix={handleSubmitKTMatrix}
|
||||
isSubmittingKT={isSubmittingKT}
|
||||
showLevel2FeedbackModal={showLevel2FeedbackModal}
|
||||
setShowLevel2FeedbackModal={setShowLevel2FeedbackModal}
|
||||
level2Feedback={level2Feedback}
|
||||
handleLevel2Change={handleLevel2Change}
|
||||
handleSubmitLevel2Feedback={handleSubmitLevel2Feedback}
|
||||
isSubmittingLevel2={isSubmittingLevel2}
|
||||
showFeedbackDetailsModal={showFeedbackDetailsModal}
|
||||
setShowFeedbackDetailsModal={setShowFeedbackDetailsModal}
|
||||
selectedEvaluationForView={selectedEvaluationForView}
|
||||
showLevel3FeedbackModal={showLevel3FeedbackModal}
|
||||
setShowLevel3FeedbackModal={setShowLevel3FeedbackModal}
|
||||
level3Feedback={level3Feedback}
|
||||
handleLevel3Change={handleLevel3Change}
|
||||
handleSubmitLevel3Feedback={handleSubmitLevel3Feedback}
|
||||
isSubmittingLevel3={isSubmittingLevel3}
|
||||
showDocumentsModal={showDocumentsModal}
|
||||
setShowDocumentsModal={setShowDocumentsModal}
|
||||
showUploadForm={showUploadForm}
|
||||
setShowUploadForm={setShowUploadForm}
|
||||
selectedStage={selectedStage}
|
||||
getDocumentsForStage={getDocumentsForStage}
|
||||
setPreviewDoc={setPreviewDoc}
|
||||
setShowPreviewModal={setShowPreviewModal}
|
||||
flattenedStages={flattenedStages}
|
||||
setSelectedStage={setSelectedStage}
|
||||
uploadDocType={uploadDocType}
|
||||
setUploadDocType={setUploadDocType}
|
||||
setUploadFile={setUploadFile}
|
||||
isUploading={isUploading}
|
||||
handleUpload={handleUpload}
|
||||
uploadFile={uploadFile}
|
||||
documentConfigs={documentConfigs}
|
||||
showPreviewModal={showPreviewModal}
|
||||
previewDoc={previewDoc}
|
||||
showFddFinalizeModal={showFddFinalizeModal}
|
||||
setShowFddFinalizeModal={setShowFddFinalizeModal}
|
||||
currentUser={currentUser}
|
||||
fddAuditRecommendation={fddAuditRecommendation}
|
||||
setFddAuditRecommendation={setFddAuditRecommendation}
|
||||
fddAuditFindings={fddAuditFindings}
|
||||
setFddAuditFindings={setFddAuditFindings}
|
||||
isFinalizingFdd={isFinalizingFdd}
|
||||
setIsFinalizingFdd={setIsFinalizingFdd}
|
||||
fetchApplication={fetchApplication}
|
||||
showFddFlagModal={showFddFlagModal}
|
||||
setShowFddFlagModal={setShowFddFlagModal}
|
||||
isFddFlagging={isFddFlagging}
|
||||
setIsFddFlagging={setIsFddFlagging}
|
||||
showFirmTypeModal={showFirmTypeModal}
|
||||
setShowFirmTypeModal={setShowFirmTypeModal}
|
||||
tempFirmType={tempFirmType}
|
||||
setTempFirmType={setTempFirmType}
|
||||
updatingFirmType={updatingFirmType}
|
||||
handleUpdateFirmType={handleUpdateFirmType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -31,6 +31,15 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface ApplicationsPageProps {
|
||||
onViewDetails: (id: string) => void;
|
||||
@ -51,91 +60,84 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
||||
// Real Data Integration
|
||||
const [applications, setApplications] = useState<Application[]>([]);
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
const response = await onboardingService.getApplications();
|
||||
// Check if response is array or wrapped in data property
|
||||
const applicationsData = response.data || (Array.isArray(response) ? response : []);
|
||||
|
||||
// Map backend data to frontend Application interface
|
||||
const mappedApps = applicationsData.map((app: any) => ({
|
||||
id: app.id,
|
||||
registrationNumber: app.applicationId || 'N/A',
|
||||
name: app.applicantName,
|
||||
email: app.email,
|
||||
phone: app.phone,
|
||||
age: app.age,
|
||||
education: app.education,
|
||||
residentialAddress: app.address || app.city || '',
|
||||
businessAddress: app.address || '',
|
||||
preferredLocation: app.preferredLocation,
|
||||
state: app.state,
|
||||
ownsBike: app.ownRoyalEnfield === 'yes',
|
||||
pastExperience: app.experienceYears ? `${app.experienceYears} years` : (app.description || ''),
|
||||
status: app.overallStatus as ApplicationStatus,
|
||||
questionnaireMarks: 0,
|
||||
rank: 0,
|
||||
totalApplicantsAtLocation: 0,
|
||||
submissionDate: app.createdAt,
|
||||
assignedUsers: [], // Keeping this for UI compatibility if needed
|
||||
assignedTo: app.assignedTo, // Add this field for filtering
|
||||
progress: app.progressPercentage || 0,
|
||||
isShortlisted: app.ddLeadShortlisted || app.isShortlisted || false, // Use actual backend flags
|
||||
// Add other fields to match interface
|
||||
companyName: app.companyName,
|
||||
source: app.source,
|
||||
existingDealer: app.existingDealer,
|
||||
royalEnfieldModel: app.royalEnfieldModel,
|
||||
description: app.description,
|
||||
pincode: app.pincode,
|
||||
locationType: app.locationType,
|
||||
ownRoyalEnfield: app.ownRoyalEnfield,
|
||||
address: app.address
|
||||
}));
|
||||
setApplications(mappedApps);
|
||||
|
||||
// Extract unique locations for filtering
|
||||
const uniqueLocations = Array.from(new Set(mappedApps.map((app: Application) => app.preferredLocation))).filter(Boolean) as string[];
|
||||
setLocations(uniqueLocations);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch applications', error);
|
||||
} finally {
|
||||
// setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchApplications();
|
||||
}, []);
|
||||
}, [currentPage, locationFilter, statusFilter, searchQuery]);
|
||||
|
||||
// Filter and sort applications - ONLY show shortlisted applications
|
||||
// Exclude specific applications (APP-005, APP-006, APP-007, APP-008) from Dealership Requests page
|
||||
const excludedApplicationIds = ['5', '6', '7', '8'];
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
const response = await onboardingService.getApplications({
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
location: locationFilter !== 'all' ? locationFilter : undefined,
|
||||
search: searchQuery || undefined,
|
||||
ddLeadShortlisted: 'true',
|
||||
isShortlisted: 'true',
|
||||
assignedTo: showMyAssignments ? currentUser?.id : undefined
|
||||
});
|
||||
|
||||
const filteredApplications = applications
|
||||
.filter((app) => {
|
||||
const matchesSearch =
|
||||
app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
app.email.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const applicationsData = response.data || [];
|
||||
setPaginationMeta(response.meta);
|
||||
|
||||
const matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
|
||||
const matchesStatus = statusFilter === 'all' || app.status === statusFilter;
|
||||
const isShortlisted = app.isShortlisted === true;
|
||||
const isNotQuestionnaireStage = !['Questionnaire Pending', 'Questionnaire Completed', 'Submitted'].includes(app.status);
|
||||
const notExcluded = !excludedApplicationIds.includes(app.id);
|
||||
// Map backend data to frontend Application interface
|
||||
const mappedApps = applicationsData.map((app: any) => ({
|
||||
id: app.id,
|
||||
registrationNumber: app.applicationId || 'N/A',
|
||||
name: app.applicantName,
|
||||
email: app.email,
|
||||
phone: app.phone,
|
||||
age: app.age,
|
||||
education: app.education,
|
||||
residentialAddress: app.address || app.city || '',
|
||||
businessAddress: app.address || '',
|
||||
preferredLocation: app.preferredLocation,
|
||||
state: app.state,
|
||||
ownsBike: app.ownRoyalEnfield === 'yes',
|
||||
pastExperience: app.experienceYears ? `${app.experienceYears} years` : (app.description || ''),
|
||||
status: app.overallStatus as ApplicationStatus,
|
||||
questionnaireMarks: 0,
|
||||
rank: 0,
|
||||
totalApplicantsAtLocation: 0,
|
||||
submissionDate: app.createdAt,
|
||||
assignedUsers: [],
|
||||
assignedTo: app.assignedTo,
|
||||
progress: app.progressPercentage || 0,
|
||||
isShortlisted: app.ddLeadShortlisted || app.isShortlisted || false,
|
||||
companyName: app.companyName,
|
||||
source: app.source,
|
||||
existingDealer: app.existingDealer,
|
||||
royalEnfieldModel: app.royalEnfieldModel,
|
||||
description: app.description,
|
||||
pincode: app.pincode,
|
||||
locationType: app.locationType,
|
||||
ownRoyalEnfield: app.ownRoyalEnfield,
|
||||
address: app.address
|
||||
}));
|
||||
setApplications(mappedApps);
|
||||
|
||||
// New Filter: My Assignments
|
||||
const matchesAssignment = !showMyAssignments || ((app as any).assignedTo === currentUser?.id);
|
||||
|
||||
return matchesSearch && matchesLocation && matchesStatus && isShortlisted && isNotQuestionnaireStage && notExcluded && matchesAssignment;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'date') {
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
// Extract unique locations for filtering - could be optimized to fetch once
|
||||
if (locations.length === 0) {
|
||||
const uniqueLocations = Array.from(new Set(mappedApps.map((app: Application) => app.preferredLocation))).filter(Boolean) as string[];
|
||||
setLocations(uniqueLocations);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch applications', error);
|
||||
} finally {
|
||||
// setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredApplications = applications.sort((a, b) => {
|
||||
if (sortBy === 'date') {
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
@ -289,7 +291,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
||||
)}
|
||||
|
||||
<div className="ml-auto text-slate-600" data-testid="onboarding-applications-count-text">
|
||||
{filteredApplications.length} application{filteredApplications.length !== 1 ? 's' : ''}
|
||||
{paginationMeta ? paginationMeta.total : filteredApplications.length} application{paginationMeta?.total !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -300,7 +302,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedIds.length === filteredApplications.length}
|
||||
checked={selectedIds.length === filteredApplications.length && filteredApplications.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
data-testid="onboarding-applications-header-checkbox"
|
||||
/>
|
||||
@ -359,6 +361,57 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-4 border-t px-4 flex justify-center">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{[...Array(paginationMeta.totalPages)].map((_, i) => {
|
||||
const page = i + 1;
|
||||
// Show current, first, last, and pages around current
|
||||
if (
|
||||
page === 1 ||
|
||||
page === paginationMeta.totalPages ||
|
||||
(page >= currentPage - 1 && page <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
if (
|
||||
(page === 2 && currentPage > 3) ||
|
||||
(page === paginationMeta.totalPages - 1 && currentPage < paginationMeta.totalPages - 2)
|
||||
) {
|
||||
return <PaginationItem key={page}><PaginationEllipsis /></PaginationItem>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(prev => Math.min(paginationMeta.totalPages, prev + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showNewApplicationModal} onOpenChange={setShowNewApplicationModal}>
|
||||
|
||||
@ -36,8 +36,8 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await onboardingService.getApplications();
|
||||
setApplications(data);
|
||||
const response = await onboardingService.getApplications();
|
||||
setApplications(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
toast.error('Failed to fetch applications');
|
||||
|
||||
@ -15,9 +15,20 @@ import {
|
||||
Search,
|
||||
Download,
|
||||
Database,
|
||||
Loader2
|
||||
Loader2,
|
||||
CheckSquare,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -37,15 +48,72 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [locationFilter, setLocationFilter] = useState<string>('all');
|
||||
const [stateFilter, setStateFilter] = useState<string>('all');
|
||||
const [fromDate, setFromDate] = useState<string>('');
|
||||
const [toDate, setToDate] = useState<string>('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
|
||||
// Real data integration
|
||||
const [applicationsData, setApplicationsData] = useState<Application[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isGlobalLoading, setIsGlobalLoading] = useState(true);
|
||||
const [states, setStates] = useState<string[]>([]);
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [isBulkConverting, setIsBulkConverting] = useState(false);
|
||||
|
||||
const handleBulkConvert = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
try {
|
||||
setIsBulkConverting(true);
|
||||
const res = await onboardingService.bulkConvertToOpportunity({ ids: selectedIds });
|
||||
if (res?.success) {
|
||||
// If some succeeded, show success message
|
||||
if (res.data?.success > 0 || !res.data) {
|
||||
toast.success(res.message || `Successfully converted ${res.data?.success || selectedIds.length} leads.`);
|
||||
}
|
||||
|
||||
// If some failed, show specific error messages as requested
|
||||
if (res.data?.failed > 0 && res.data?.errors) {
|
||||
res.data.errors.forEach((err: string) => {
|
||||
toast.error(err, { duration: 5000 });
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedIds([]);
|
||||
await fetchApplications();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Bulk conversion error:', error);
|
||||
toast.error(error.message || 'Failed to perform bulk conversion');
|
||||
} finally {
|
||||
setIsBulkConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.length === filteredLeads.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(filteredLeads.map(l => l.id));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectRow = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
}, [fromDate, toDate, searchQuery, currentPage, locationFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [fromDate, toDate, searchQuery, locationFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
@ -62,9 +130,20 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await onboardingService.getApplications();
|
||||
const rawData = response.data || (Array.isArray(response) ? response : []);
|
||||
setIsGlobalLoading(true);
|
||||
const response = await onboardingService.getApplications({
|
||||
fromDate,
|
||||
toDate,
|
||||
search: searchQuery,
|
||||
status: 'Submitted',
|
||||
isShortlisted: 'false',
|
||||
location: locationFilter !== 'all' ? locationFilter : undefined,
|
||||
state: stateFilter !== 'all' ? stateFilter : undefined,
|
||||
page: currentPage,
|
||||
limit: 10
|
||||
});
|
||||
const rawData = response.data || [];
|
||||
setPaginationMeta(response.meta);
|
||||
|
||||
// Map backend data to Application interface
|
||||
const mappedApps: Application[] = rawData.map((app: any) => ({
|
||||
@ -110,27 +189,11 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
console.error('Failed to fetch applications:', error);
|
||||
toast.error('Failed to load non-opportunity requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsGlobalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter non-opportunity leads - These are lead generation submissions
|
||||
// People who expressed interest but received non-opportunity email because
|
||||
// we're currently not offering dealerships in their preferred location
|
||||
// UPDATED LOGIC: 'Submitted' status specifically implies Non-Opportunity (Lead)
|
||||
const filteredLeads = applicationsData.filter((app) => {
|
||||
// Only show applications with 'Submitted' status
|
||||
const isNonOpportunity = app.status === 'Submitted';
|
||||
|
||||
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
app.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
app.phone.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
|
||||
const matchesState = stateFilter === 'all' || app.state === stateFilter;
|
||||
|
||||
return isNonOpportunity && matchesSearch && matchesLocation && matchesState;
|
||||
});
|
||||
const filteredLeads = applicationsData;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -148,7 +211,7 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-600">Total Leads</p>
|
||||
<p className="text-2xl text-slate-900 mt-1">{filteredLeads.length}</p>
|
||||
<p className="text-2xl text-slate-900 mt-1">{paginationMeta?.total || applicationsData.length}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Database className="w-6 h-6 text-blue-600" />
|
||||
@ -158,13 +221,13 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4" data-testid="onboarding-non-opps-stat-locations">
|
||||
<p className="text-slate-600">Unique Locations</p>
|
||||
<p className="text-2xl text-slate-900 mt-1">
|
||||
{new Set(filteredLeads.map(app => app.preferredLocation)).size}
|
||||
{paginationMeta?.stats?.uniqueLocations || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4" data-testid="onboarding-non-opps-stat-exp">
|
||||
<p className="text-slate-600">With Experience</p>
|
||||
<p className="text-2xl text-amber-600 mt-1">
|
||||
{filteredLeads.filter(app => app.pastExperience && app.pastExperience !== 'No').length}
|
||||
{paginationMeta?.stats?.withExperience || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -183,6 +246,32 @@ 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Select value={locationFilter} onValueChange={setLocationFilter}>
|
||||
<SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-location-select">
|
||||
<SelectValue placeholder="All Locations" />
|
||||
@ -210,6 +299,21 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
<Button variant="outline" size="icon" data-testid="onboarding-non-opps-export-btn">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<Button
|
||||
className="bg-amber-600 hover:bg-amber-700 font-bold"
|
||||
onClick={handleBulkConvert}
|
||||
disabled={isBulkConverting}
|
||||
>
|
||||
{isBulkConverting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckSquare className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Convert {selectedIds.length} to Opportunity
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lead Generation Table */}
|
||||
@ -217,6 +321,12 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={filteredLeads.length > 0 && selectedIds.length === filteredLeads.length}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead data-testid="onboarding-non-opps-th-name">Name</TableHead>
|
||||
<TableHead data-testid="onboarding-non-opps-th-phone">Phone</TableHead>
|
||||
<TableHead data-testid="onboarding-non-opps-th-email">Email</TableHead>
|
||||
@ -230,52 +340,126 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLeads.map((lead, idx) => (
|
||||
<TableRow key={lead.id} data-testid={`onboarding-non-opps-row-${idx}`}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-slate-900" data-testid={`onboarding-non-opps-name-${idx}`}>{lead.name}</p>
|
||||
<p className="text-slate-500 text-sm" data-testid={`onboarding-non-opps-id-${idx}`}>{lead.registrationNumber}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-900" data-testid={`onboarding-non-opps-phone-${idx}`}>{lead.phone}</TableCell>
|
||||
<TableCell className="text-slate-600" data-testid={`onboarding-non-opps-email-${idx}`}>{lead.email}</TableCell>
|
||||
<TableCell>
|
||||
<div data-testid={`onboarding-non-opps-pref-loc-${idx}`}>
|
||||
<p className="text-slate-900">{lead.preferredLocation}</p>
|
||||
<p className="text-slate-500 text-sm">{lead.state}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600 max-w-xs truncate" data-testid={`onboarding-non-opps-address-${idx}`}>{lead.residentialAddress}</TableCell>
|
||||
<TableCell className="text-slate-900" data-testid={`onboarding-non-opps-age-${idx}`}>{lead.age}</TableCell>
|
||||
<TableCell className="text-slate-600" data-testid={`onboarding-non-opps-experience-${idx}`}>{lead.pastExperience}</TableCell>
|
||||
<TableCell className="text-slate-900" data-testid={`onboarding-non-opps-education-${idx}`}>{lead.education}</TableCell>
|
||||
<TableCell className="text-slate-600" data-testid={`onboarding-non-opps-date-${idx}`}>
|
||||
{formatDateTime(lead.submissionDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewDetails(lead.id)}
|
||||
data-testid={`onboarding-non-opps-view-btn-${idx}`}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{isGlobalLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center py-20">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-amber-600 mb-2" />
|
||||
<p className="text-slate-500 text-sm">Loading applications...</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredLeads.length === 0 && (
|
||||
) : filteredLeads.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center py-12 text-slate-500" data-testid="onboarding-non-opps-empty-state">
|
||||
<TableCell colSpan={11} className="text-center py-12 text-slate-500" data-testid="onboarding-non-opps-empty-state">
|
||||
<Database className="w-12 h-12 mx-auto mb-4 text-slate-400" />
|
||||
<p className="text-lg mb-2">No lead generation data found</p>
|
||||
<p className="text-sm">Try adjusting your filters</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredLeads.map((lead, idx) => (
|
||||
<TableRow
|
||||
key={lead.id}
|
||||
data-testid={`onboarding-non-opps-row-${idx}`}
|
||||
className={selectedIds.includes(lead.id) ? 'bg-amber-50/50' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(lead.id)}
|
||||
onCheckedChange={() => toggleSelectRow(lead.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-slate-900" data-testid={`onboarding-non-opps-name-${idx}`}>{lead.name}</p>
|
||||
<p className="text-slate-500 text-sm" data-testid={`onboarding-non-opps-id-${idx}`}>{lead.registrationNumber}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-900" data-testid={`onboarding-non-opps-phone-${idx}`}>{lead.phone}</TableCell>
|
||||
<TableCell className="text-slate-600" data-testid={`onboarding-non-opps-email-${idx}`}>{lead.email}</TableCell>
|
||||
<TableCell>
|
||||
<div data-testid={`onboarding-non-opps-pref-loc-${idx}`}>
|
||||
<p className="text-slate-900">{lead.preferredLocation}</p>
|
||||
<p className="text-slate-500 text-sm">{lead.state}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600 max-w-xs truncate" data-testid={`onboarding-non-opps-address-${idx}`}>{lead.residentialAddress}</TableCell>
|
||||
<TableCell className="text-slate-900" data-testid={`onboarding-non-opps-age-${idx}`}>{lead.age}</TableCell>
|
||||
<TableCell className="text-slate-600" data-testid={`onboarding-non-opps-experience-${idx}`}>{lead.pastExperience}</TableCell>
|
||||
<TableCell className="text-slate-900" data-testid={`onboarding-non-opps-education-${idx}`}>{lead.education}</TableCell>
|
||||
<TableCell className="text-slate-600" data-testid={`onboarding-non-opps-date-${idx}`}>
|
||||
{formatDateTime(lead.submissionDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewDetails(lead.id)}
|
||||
data-testid={`onboarding-non-opps-view-btn-${idx}`}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-4 border-t border-slate-200">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<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;
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
} else if (
|
||||
pageNum === currentPage - 2 ||
|
||||
pageNum === currentPage + 2
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(p => Math.min(paginationMeta.totalPages, p + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
||||
import { ApplicationStatus, Application } from '@/lib/mock-data';
|
||||
import { masterService } from '@/services/master.service';
|
||||
import { onboardingService } from '@/services/onboarding.service';
|
||||
import { adminService } from '@/services/admin.service';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@ -12,6 +11,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Search,
|
||||
Download,
|
||||
@ -21,8 +29,8 @@ import {
|
||||
List,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
X,
|
||||
User as UserIcon
|
||||
Calendar,
|
||||
ArrowUpDown
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -41,41 +49,28 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatDateTime } from '@/components/ui/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { ApplicationCard } from '@/features/onboarding/components/ApplicationCard';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
|
||||
|
||||
interface OpportunityRequestsPageProps {
|
||||
onViewDetails: (id: string) => void;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPageProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'table'>('table');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [locationFilter, setLocationFilter] = useState<string>('all');
|
||||
const [stateFilter, setStateFilter] = useState<string>('all');
|
||||
const [fromDate, setFromDate] = useState<string>('');
|
||||
const [toDate, setToDate] = useState<string>('');
|
||||
const [sortBy, setSortBy] = useState<string>('date-desc');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [showShortlistModal, setShowShortlistModal] = useState(false);
|
||||
const [shortlistRemark, setShortlistRemark] = useState('');
|
||||
|
||||
// Assignee Selection
|
||||
const [selectedAssignees, setSelectedAssignees] = useState<User[]>([]);
|
||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
||||
const [openUserSelect, setOpenUserSelect] = useState(false);
|
||||
const [states, setStates] = useState<string[]>([]);
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
|
||||
@ -85,7 +80,13 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
fetchUsers();
|
||||
}, [fromDate, toDate, statusFilter, searchQuery, currentPage, locationFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [fromDate, toDate, statusFilter, searchQuery, locationFilter, stateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
@ -101,26 +102,25 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await adminService.getAllUsers({ isExternal: false });
|
||||
// Defensive check for array data
|
||||
const users = (response && response.success && Array.isArray(response.data))
|
||||
? response.data
|
||||
: (Array.isArray(response) ? response : []);
|
||||
|
||||
// Filter out any invalid user objects
|
||||
setAvailableUsers(users.filter((u: any) => u && u.id));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await onboardingService.getApplications();
|
||||
const rawData = response.data || (Array.isArray(response) ? response : []);
|
||||
const opportunityStatuses = ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'];
|
||||
const response = await onboardingService.getApplications({
|
||||
fromDate,
|
||||
toDate,
|
||||
status: statusFilter === 'all' ? opportunityStatuses.join(',') : statusFilter,
|
||||
location: locationFilter !== 'all' ? locationFilter : undefined,
|
||||
state: stateFilter !== 'all' ? stateFilter : undefined,
|
||||
ddLeadShortlisted: 'false',
|
||||
isShortlisted: 'true',
|
||||
search: searchQuery,
|
||||
page: currentPage,
|
||||
limit: 10
|
||||
});
|
||||
const rawData = response.data || [];
|
||||
setPaginationMeta(response.meta);
|
||||
|
||||
// Map backend data to Application interface
|
||||
const mappedApps: Application[] = rawData.map((app: any) => ({
|
||||
@ -176,23 +176,12 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
// Shows applications shortlisted by DD but NOT yet shortlisted by DD Lead
|
||||
// IMPORTANT: Only shows applications in early stages (before they enter full workflow)
|
||||
// UPDATED LOGIC: Opportunities start at 'Questionnaire Pending'. 'Submitted' means Non-Opportunity.
|
||||
const filteredApplications = applicationsData.filter((app) => {
|
||||
// Only show applications that are:
|
||||
// 1. Not Shortlisted by DD Lead yet (ddLeadShortlisted !== true) - waiting for action
|
||||
const waitingForDDLead = !(app as any).ddLeadShortlisted;
|
||||
|
||||
// Only show applications with Opportunity statuses
|
||||
// 'Submitted' is EXCLUDED because it represents Non-Opportunity (Leads)
|
||||
const validStatuses: ApplicationStatus[] = ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'];
|
||||
const isOpportunityStatus = validStatuses.includes(app.status);
|
||||
|
||||
const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || app.status === statusFilter;
|
||||
const matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
|
||||
const matchesState = stateFilter === 'all' || app.state === stateFilter;
|
||||
|
||||
return waitingForDDLead && isOpportunityStatus && matchesSearch && matchesStatus && matchesLocation && matchesState;
|
||||
const filteredApplications = applicationsData.sort((a, b) => {
|
||||
if (sortBy === 'score-desc') return (b.questionnaireMarks || 0) - (a.questionnaireMarks || 0);
|
||||
if (sortBy === 'score-asc') return (a.questionnaireMarks || 0) - (b.questionnaireMarks || 0);
|
||||
if (sortBy === 'date-desc') return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
if (sortBy === 'date-asc') return new Date(a.submissionDate).getTime() - new Date(b.submissionDate).getTime();
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
@ -220,16 +209,9 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
};
|
||||
|
||||
const confirmShortlist = async () => {
|
||||
if (selectedAssignees.length === 0) {
|
||||
toast.error('Please assign at least one user');
|
||||
return;
|
||||
}
|
||||
|
||||
const assignedUserIds = selectedAssignees.map(u => u.id);
|
||||
|
||||
try {
|
||||
// Call Backend API
|
||||
const response = await onboardingService.shortlistApplications(selectedIds, assignedUserIds, shortlistRemark);
|
||||
// Call Backend API - assignedTo is now empty as it's handled automatically
|
||||
const response = await onboardingService.shortlistApplications(selectedIds, [], shortlistRemark);
|
||||
|
||||
if (response && response.success) {
|
||||
// Update local state and show success only if API succeeded
|
||||
@ -237,8 +219,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
if (selectedIds.includes(app.id)) {
|
||||
return {
|
||||
...app,
|
||||
ddLeadShortlisted: true,
|
||||
assignedTo: assignedUserIds[0] // Optimistically update with first assignee
|
||||
ddLeadShortlisted: true
|
||||
} as any;
|
||||
}
|
||||
return app;
|
||||
@ -248,9 +229,8 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
setSelectedIds([]);
|
||||
setShowShortlistModal(false);
|
||||
setShortlistRemark('');
|
||||
setSelectedAssignees([]);
|
||||
|
||||
toast.success(`${selectedIds.length} application(s) shortlisted and assigned to ${selectedAssignees.length} user(s)`);
|
||||
toast.success(`${selectedIds.length} application(s) shortlisted successfully. Users will be assigned automatically.`);
|
||||
} else {
|
||||
throw new Error(response?.message || 'Failed to process shortlisting');
|
||||
}
|
||||
@ -337,9 +317,9 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
// For Opportunity Requests, only show early-stage statuses
|
||||
// These applications haven't entered the full dealership approval workflow yet
|
||||
const statusOptions: ApplicationStatus[] = [
|
||||
'Submitted',
|
||||
'Questionnaire Pending',
|
||||
'Questionnaire Completed'
|
||||
'Questionnaire Completed',
|
||||
'Shortlisted'
|
||||
];
|
||||
|
||||
const getStatusColor = (status: ApplicationStatus) => {
|
||||
@ -476,6 +456,47 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-full md:w-48" data-testid="onboarding-opp-requests-sort-select">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="w-4 h-4 text-slate-400" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date-desc">Newest Applied</SelectItem>
|
||||
<SelectItem value="date-asc">Oldest Applied</SelectItem>
|
||||
<SelectItem value="score-desc">Highest Score</SelectItem>
|
||||
<SelectItem value="score-asc">Lowest Score</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
@ -534,7 +555,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
|
||||
<div className="ml-auto">
|
||||
<Badge variant="outline" className="text-slate-600" data-testid="onboarding-opp-requests-pending-count">
|
||||
{filteredApplications.length} pending shortlisting
|
||||
{paginationMeta?.total || filteredApplications.length} pending shortlisting
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@ -653,6 +674,61 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-4 border-t border-slate-200">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<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 >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
} else if (
|
||||
pageNum === currentPage - 2 ||
|
||||
pageNum === currentPage + 2
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(p => Math.min(paginationMeta.totalPages, p + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -660,80 +736,12 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
||||
<DialogContent className="overflow-visible" data-testid="onboarding-opp-requests-shortlist-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle data-testid="onboarding-opp-requests-shortlist-modal-title">Shortlist & Assign Applications</DialogTitle>
|
||||
<DialogTitle data-testid="onboarding-opp-requests-shortlist-modal-title">Shortlist Applications</DialogTitle>
|
||||
<DialogDescription>
|
||||
You are about to shortlist {selectedIds.length} application(s). Select users to assign them to.
|
||||
You are about to shortlist {selectedIds.length} application(s). These applications will be moved to the Dealership Requests page and users will be assigned automatically based on the applied location.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Assign to Users *</Label>
|
||||
|
||||
{/* Selected Users Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-2 p-2 border rounded-md min-h-[42px]" data-testid="onboarding-opp-requests-selected-users-container">
|
||||
{(!selectedAssignees || selectedAssignees.length === 0) && <span className="text-slate-400 text-sm py-1">No users selected</span>}
|
||||
{selectedAssignees?.map((user, uIdx) => (
|
||||
user ? (
|
||||
<Badge key={user.id} variant="secondary" className="pl-2 pr-1 py-1 flex items-center gap-1" data-testid={`onboarding-opp-requests-selected-user-${uIdx}`}>
|
||||
{user.fullName || user.email || 'Unknown User'}
|
||||
<button
|
||||
onClick={() => setSelectedAssignees(prev => prev.filter(u => u.id !== user.id))}
|
||||
className="ml-1 hover:bg-slate-200 rounded-full p-0.5"
|
||||
data-testid={`onboarding-opp-requests-remove-selected-user-${uIdx}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* User Search Combobox */}
|
||||
<Popover open={openUserSelect} onOpenChange={setOpenUserSelect}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={openUserSelect} className="justify-between" data-testid="onboarding-opp-requests-assignee-trigger">
|
||||
Select users to add...
|
||||
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[400px]" align="start" data-testid="onboarding-opp-requests-assignee-popover">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search users by name or email..." data-testid="onboarding-opp-requests-assignee-search-input" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandGroup heading="Available Users" data-testid="onboarding-opp-requests-assignee-group">
|
||||
{availableUsers
|
||||
?.filter(user => user && !selectedAssignees.some(selected => selected.id === user.id))
|
||||
.map((user, aIdx) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
onSelect={() => {
|
||||
setSelectedAssignees([...selectedAssignees, user]);
|
||||
setOpenUserSelect(false);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
data-testid={`onboarding-opp-requests-assignee-item-${aIdx}`}
|
||||
>
|
||||
<UserIcon className="mr-2 h-4 w-4 text-slate-500" />
|
||||
<div className="flex flex-col">
|
||||
<span>{user.fullName || 'Unknown Name'}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{user.email || 'No Email'} • {
|
||||
(typeof user.role === 'object' && user.role !== null)
|
||||
? (user.role as any).roleName
|
||||
: (user.role || 'No Role')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-slate-500 text-sm">Use the search to find and add multiple interviewers/assignees.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Shortlisting Remark (Optional)</Label>
|
||||
@ -753,7 +761,6 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowShortlistModal(false);
|
||||
setSelectedAssignees([]);
|
||||
setShortlistRemark('');
|
||||
}}
|
||||
data-testid="onboarding-opp-requests-shortlist-cancel-btn"
|
||||
|
||||
@ -23,8 +23,10 @@ import {
|
||||
Info,
|
||||
Clock as ClockIcon,
|
||||
Activity,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -125,6 +127,8 @@ interface ParticipantUI {
|
||||
color: string;
|
||||
role?: string;
|
||||
isOnline?: boolean;
|
||||
recordId: string;
|
||||
revokedAt?: string;
|
||||
}
|
||||
|
||||
const BACKEND_URL = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
||||
@ -156,6 +160,9 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [participantSearch, setParticipantSearch] = useState('');
|
||||
const [messageSearch, setMessageSearch] = useState('');
|
||||
const [revokeConfirmParticipant, setRevokeConfirmParticipant] = useState<ParticipantUI | null>(null);
|
||||
const [revocationReason, setRevocationReason] = useState('');
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
|
||||
const { socket } = useSocket();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@ -221,20 +228,23 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
externalParticipants.forEach((p: any) => {
|
||||
const id = p.user?.id || p.userId || p.id || '';
|
||||
if (id && !seenIds.has(id)) {
|
||||
seenIds.add(id);
|
||||
const userId = p.user?.id || p.userId || '';
|
||||
const recordId = p.id;
|
||||
if (userId && !seenIds.has(userId)) {
|
||||
seenIds.add(userId);
|
||||
const name = p.user?.fullName || p.user?.name || p.fullName || p.name || 'Unknown User';
|
||||
const email = p.user?.email || p.email || '';
|
||||
const role = p.user?.roleCode || p.roleCode || p.user?.role || p.role || 'Participant';
|
||||
participantsList.push({
|
||||
id,
|
||||
id: userId,
|
||||
recordId,
|
||||
name,
|
||||
email,
|
||||
initials: getInitials(name),
|
||||
color: getAvatarColor(name),
|
||||
role,
|
||||
isOnline: false // Could be linked to socket later
|
||||
isOnline: false,
|
||||
revokedAt: p.metadata?.revokedAt
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -527,6 +537,35 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeConfirm = (participant: ParticipantUI) => {
|
||||
const authorizedRoles = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'];
|
||||
if (!authorizedRoles.includes(currentUser?.roleCode || '')) {
|
||||
toast.error('Only authorized roles can revoke participants');
|
||||
return;
|
||||
}
|
||||
setRevokeConfirmParticipant(participant);
|
||||
setRevocationReason('');
|
||||
};
|
||||
|
||||
const confirmRevocation = async () => {
|
||||
if (!revokeConfirmParticipant) return;
|
||||
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
const res: any = await worknoteService.revokeParticipant(revokeConfirmParticipant.recordId, revocationReason);
|
||||
if (res.success) {
|
||||
toast.success(res.message);
|
||||
setRevokeConfirmParticipant(null);
|
||||
// Refresh details to update UI state
|
||||
setExternalParticipants([]); // Triggers re-fetch in useEffect
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error revoking participant');
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@ -1028,38 +1067,75 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar">
|
||||
{participantsList
|
||||
.filter(p => p.name.toLowerCase().includes(participantSearch.toLowerCase()) || p.role?.toLowerCase().includes(participantSearch.toLowerCase()))
|
||||
.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="group flex items-start gap-3 p-3 rounded-xl hover:bg-white hover:shadow-sm border border-transparent hover:border-slate-100 transition-all cursor-default"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar className="w-10 h-10 ring-2 ring-transparent group-hover:ring-blue-100 transition-all">
|
||||
<AvatarFallback className={`${participant.color} text-white text-xs font-bold`}>
|
||||
{participant.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{participant.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-slate-50 rounded-full shadow-sm"></span>
|
||||
.map((participant) => {
|
||||
const isRevoked = !!participant.revokedAt;
|
||||
const canRevoke = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(currentUser?.roleCode || '') && !isRevoked && participant.id !== currentUser?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={cn(
|
||||
"group flex items-start gap-3 p-3 rounded-xl transition-all cursor-default border border-transparent",
|
||||
isRevoked ? "opacity-50 bg-slate-100 grayscale-[0.5]" : "hover:bg-white hover:shadow-sm hover:border-slate-100"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<p className="text-sm font-semibold text-slate-900 truncate">{participant.name}</p>
|
||||
{participant.id === currentUser?.id && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1 border-blue-200 text-blue-600 bg-blue-50">You</Badge>
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar className={cn(
|
||||
"w-10 h-10 ring-2 ring-transparent transition-all",
|
||||
!isRevoked && "group-hover:ring-blue-100"
|
||||
)}>
|
||||
<AvatarFallback className={`${participant.color} text-white text-xs font-bold`}>
|
||||
{participant.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{participant.isOnline && !isRevoked && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-slate-50 rounded-full shadow-sm"></span>
|
||||
)}
|
||||
{isRevoked && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-100 rounded-full p-0.5 border border-white">
|
||||
<UserX className="w-2.5 h-2.5 text-red-600" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 font-medium uppercase tracking-wider mb-1">
|
||||
{participant.role}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 truncate italic">
|
||||
{participant.email}
|
||||
</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className={cn("text-sm font-semibold truncate", isRevoked ? "text-slate-500 line-through" : "text-slate-900")}>
|
||||
{participant.name}
|
||||
</p>
|
||||
{isRevoked && (
|
||||
<Badge variant="outline" className="text-[8px] h-3.5 px-1 bg-red-50 text-red-600 border-red-100">Revoked</Badge>
|
||||
)}
|
||||
</div>
|
||||
{participant.id === currentUser?.id && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1 border-blue-200 text-blue-600 bg-blue-50">You</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 font-medium uppercase tracking-wider mb-1">
|
||||
{participant.role}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 truncate italic">
|
||||
{participant.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canRevoke && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-slate-300 hover:text-red-600 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-all self-center"
|
||||
onClick={() => handleRevokeConfirm(participant)}
|
||||
>
|
||||
<UserX className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!canRevoke && !isRevoked && (
|
||||
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-slate-400 opacity-0 group-hover:opacity-100 transition-all self-center" />
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-slate-400 opacity-0 group-hover:opacity-100 transition-all self-center" />
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{participantsList.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center opacity-50">
|
||||
@ -1125,6 +1201,50 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Revocation Confirmation Modal */}
|
||||
<Dialog open={!!revokeConfirmParticipant} onOpenChange={(open) => !open && setRevokeConfirmParticipant(null)}>
|
||||
<DialogContent className="max-w-md p-0 overflow-hidden border-none shadow-2xl [&>button]:text-white [&>button]:opacity-100">
|
||||
<div className="bg-gradient-to-br from-red-600 to-red-700 p-6 text-white text-center">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-white/30 backdrop-blur-sm animate-pulse">
|
||||
<UserX className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">Revoke Participant Access?</h3>
|
||||
<p className="text-red-100 text-sm">
|
||||
You are about to revoke access for <span className="font-bold text-white">{revokeConfirmParticipant?.name}</span>. They will no longer be able to view or interact with this request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-white space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest">Reason for Revocation</label>
|
||||
<Input
|
||||
value={revocationReason}
|
||||
onChange={(e) => setRevocationReason(e.target.value)}
|
||||
placeholder="e.g. Roles changed, Case transferred..."
|
||||
className="bg-slate-50 border-slate-200 focus:bg-white transition-all h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-11 border-slate-200 text-slate-600 hover:bg-slate-50"
|
||||
onClick={() => setRevokeConfirmParticipant(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-11 bg-red-600 hover:bg-red-700 text-white font-bold shadow-lg shadow-red-200"
|
||||
onClick={confirmRevocation}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? <RefreshCcw className="w-4 h-4 animate-spin mr-2" /> : null}
|
||||
Revoke Access
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,6 +15,15 @@ import { User } from '@/lib/mock-data';
|
||||
import { toast } from 'sonner';
|
||||
import { API } from '@/api/API';
|
||||
import { formatDateTime } from '@/components/ui/utils';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface RelocationRequestPageProps {
|
||||
currentUser: User | null;
|
||||
@ -35,6 +44,10 @@ const getStatusColor = (status: string) => {
|
||||
export function RelocationRequestPage({ currentUser, onViewDetails }: RelocationRequestPageProps) {
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// Relocation Creation State (for Super Admin)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
@ -73,11 +86,19 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, [currentPage, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
fetchOutlets();
|
||||
fetchMasterData();
|
||||
}
|
||||
}, []);
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const handleTabChange = (val: string) => {
|
||||
setActiveTab(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const fetchOutlets = async () => {
|
||||
try {
|
||||
@ -191,9 +212,14 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await API.getRelocationRequests() as any;
|
||||
const response = await API.getRelocationRequests({
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
status: activeTab === 'all' ? undefined : activeTab
|
||||
}) as any;
|
||||
if (response.data.success) {
|
||||
setRequests(response.data.requests);
|
||||
setPaginationMeta(response.meta);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch relocation requests error:', error);
|
||||
@ -460,11 +486,11 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all">All Requests</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending Review</TabsTrigger>
|
||||
<TabsTrigger value="in-progress">Rejected / Revoked</TabsTrigger>
|
||||
<TabsTrigger value="rejected">Rejected / Revoked</TabsTrigger>
|
||||
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@ -755,6 +781,56 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-4 border-t flex justify-center bg-white rounded-b-lg">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{[...Array(paginationMeta.totalPages)].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
if (
|
||||
(pageNum === 2 && currentPage > 3) ||
|
||||
(pageNum === paginationMeta.totalPages - 1 && currentPage < paginationMeta.totalPages - 2)
|
||||
) {
|
||||
return <PaginationItem key={pageNum}><PaginationEllipsis /></PaginationItem>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(prev => Math.min(paginationMeta.totalPages, prev + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,15 @@ import { API } from '@/api/API';
|
||||
import { toast } from 'sonner';
|
||||
import { User as UserType } from '@/lib/mock-data';
|
||||
import { formatDateTime } from '@/components/ui/utils';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
interface ResignationPageProps {
|
||||
currentUser: UserType | null;
|
||||
@ -24,14 +33,23 @@ const getStatusColor = (status: string) => {
|
||||
export function ResignationPage({ currentUser, onViewDetails }: ResignationPageProps) {
|
||||
const [resignations, setResignations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statusTab, setStatusTab] = useState('all');
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const fetchResignations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await API.getResignations();
|
||||
const response = await API.getResignations({
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
status: statusTab === 'all' ? undefined : statusTab === 'open' ? 'open' : 'Completed,Closed'
|
||||
});
|
||||
const data = response.data as any;
|
||||
if (data?.success) {
|
||||
setResignations(data.resignations.rows || data.resignations);
|
||||
setResignations(data.requests || data.resignations?.rows || data.resignations || []);
|
||||
setPaginationMeta(data.meta);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching resignations:', error);
|
||||
@ -43,44 +61,15 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
||||
|
||||
useEffect(() => {
|
||||
fetchResignations();
|
||||
}, []);
|
||||
}, [currentPage, statusTab]);
|
||||
|
||||
|
||||
|
||||
// Helper function to check if request is at current user's level
|
||||
const isRequestAtMyLevel = (request: any) => {
|
||||
if (!currentUser) return false;
|
||||
|
||||
const roleToStageMapping: Record<string, string[]> = {
|
||||
'DD Lead': ['DD Lead'],
|
||||
'DD-ZM': ['DD-ZM'],
|
||||
'RBM': ['RBM'],
|
||||
'DD AM': ['ASM'],
|
||||
'ZBH': ['ZBH'],
|
||||
'NBH': ['NBH'],
|
||||
'Legal Admin': ['Legal', 'FNF Initiate'],
|
||||
'DD Admin': ['DD Admin', 'FNF Initiate'],
|
||||
'Super Admin': ['DD Admin', 'NBH', 'Legal', 'ZBH', 'RBM', 'ASM', 'DD Lead', 'FNF Initiate']
|
||||
};
|
||||
|
||||
const userStages = roleToStageMapping[currentUser.role] || [];
|
||||
return userStages.some(stage =>
|
||||
(request.currentStage && request.currentStage.includes(stage)) ||
|
||||
(request.status && request.status.includes(stage))
|
||||
);
|
||||
const handleTabChange = (value: string) => {
|
||||
setStatusTab(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const openRequests = resignations.filter(req =>
|
||||
!req.status.includes('Completed') &&
|
||||
!req.status.includes('Closed') &&
|
||||
!req.status.includes('Rejected') &&
|
||||
isRequestAtMyLevel(req)
|
||||
);
|
||||
|
||||
const completedRequests = resignations.filter(req =>
|
||||
req.status.includes('Completed') ||
|
||||
req.status.includes('Closed')
|
||||
);
|
||||
const openRequests = statusTab === 'open' ? resignations : [];
|
||||
const completedRequests = statusTab === 'completed' ? resignations : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -89,7 +78,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>All Requests</CardDescription>
|
||||
<CardTitle className="text-3xl">{resignations.length}</CardTitle>
|
||||
<CardTitle className="text-3xl">{paginationMeta?.total || 0}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-600">Total Requests</p>
|
||||
@ -99,7 +88,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Open</CardDescription>
|
||||
<CardTitle className="text-3xl text-yellow-600">{openRequests.length}</CardTitle>
|
||||
<CardTitle className="text-3xl text-yellow-600">{statusTab === 'open' ? paginationMeta?.total || 0 : '...'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-600">Requires Your Action</p>
|
||||
@ -109,7 +98,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Completed</CardDescription>
|
||||
<CardTitle className="text-3xl text-green-600">{completedRequests.length}</CardTitle>
|
||||
<CardTitle className="text-3xl text-green-600">{statusTab === 'completed' ? paginationMeta?.total || 0 : '...'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-600">Finalized</p>
|
||||
@ -134,7 +123,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<Tabs value={statusTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All Requests</TabsTrigger>
|
||||
<TabsTrigger value="open">Open</TabsTrigger>
|
||||
@ -142,73 +131,125 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all" className="mt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 text-center py-1">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">Loading requests...</div>
|
||||
) : resignations.length > 0 ? (
|
||||
resignations.map((request) => (
|
||||
<Card key={request.id} className="border-slate-200">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="p-3 bg-amber-100 rounded-lg">
|
||||
<FileText className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg">{request.resignationId}</h3>
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
<>
|
||||
{resignations.map((request) => (
|
||||
<Card key={request.id} className="border-slate-200">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="p-3 bg-amber-100 rounded-lg">
|
||||
<FileText className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-slate-600">Dealer Name</p>
|
||||
<p>{request.dealer?.dealerProfile?.businessName || request.outlet?.name || 'N/A'}</p>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg">{request.resignationId}</h3>
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Dealer Code</p>
|
||||
<p>{request.dealer?.dealerProfile?.dealerCode?.dealerCode || request.outlet?.code || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Location</p>
|
||||
<p>{request.dealer?.dealerProfile?.registeredAddress || (request.outlet?.city && request.outlet?.state ? `${request.outlet.city}, ${request.outlet.state}` : 'N/A')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Type</p>
|
||||
<p>{request.resignationType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Reason</p>
|
||||
<p className="truncate max-w-[200px]">{request.reason}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Current Stage</p>
|
||||
<p>{request.currentStage}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Submitted On</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4 text-slate-500" />
|
||||
<p>{formatDateTime(request.submittedOn)}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-slate-600">Dealer Name</p>
|
||||
<p>{request.dealer?.dealerProfile?.businessName || request.outlet?.name || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Dealer Code</p>
|
||||
<p>{request.dealer?.dealerProfile?.dealerCode?.dealerCode || request.outlet?.code || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Location</p>
|
||||
<p>{request.dealer?.dealerProfile?.registeredAddress || (request.outlet?.city && request.outlet?.state ? `${request.outlet.city}, ${request.outlet.state}` : 'N/A')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Type</p>
|
||||
<p>{request.resignationType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Reason</p>
|
||||
<p className="truncate max-w-[200px]">{request.reason}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Current Stage</p>
|
||||
<p>{request.currentStage}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">Submitted On</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4 text-slate-500" />
|
||||
<p>{formatDateTime(request.submittedOn)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onViewDetails(request.id)}
|
||||
className="ml-4"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onViewDetails(request.id)}
|
||||
className="ml-4"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-4 border-t flex justify-center">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{[...Array(paginationMeta.totalPages)].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
if (
|
||||
(pageNum === 2 && currentPage > 3) ||
|
||||
(pageNum === paginationMeta.totalPages - 1 && currentPage < paginationMeta.totalPages - 2)
|
||||
) {
|
||||
return <PaginationItem key={pageNum}><PaginationEllipsis /></PaginationItem>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(prev => Math.min(paginationMeta.totalPages, prev + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<p>No resignation requests found</p>
|
||||
|
||||
@ -12,6 +12,15 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API } from '@/api/API';
|
||||
import { formatDateTime } from '@/components/ui/utils';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { User } from '@/lib/mock-data';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@ -51,6 +60,10 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
const [autoFilledData, setAutoFilledData] = useState<any>(null);
|
||||
const [terminations, setTerminations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const itemsPerPage = 10;
|
||||
const [formData, setFormData] = useState({
|
||||
terminationCategory: '',
|
||||
reason: '',
|
||||
@ -62,10 +75,15 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
const fetchTerminations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await API.getTerminations();
|
||||
const response = await API.getTerminations({
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
status: activeTab === 'all' ? undefined : activeTab
|
||||
});
|
||||
const data = response.data as any;
|
||||
if (data?.success) {
|
||||
setTerminations(data.terminations);
|
||||
setPaginationMeta(data.meta);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching terminations:', error);
|
||||
@ -77,7 +95,12 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
|
||||
useEffect(() => {
|
||||
fetchTerminations();
|
||||
}, []);
|
||||
}, [currentPage, activeTab]);
|
||||
|
||||
const handleTabChange = (val: string) => {
|
||||
setActiveTab(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen || !isDDLead) return;
|
||||
@ -233,45 +256,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
|
||||
const isDDLead = currentUser?.role === 'DD Lead';
|
||||
|
||||
// Helper function to check if request is at current user's level
|
||||
const isRequestAtMyLevel = (request: any) => {
|
||||
if (!currentUser) return false;
|
||||
const userRole = currentUser.role || currentUser.roleCode;
|
||||
|
||||
const roleToStageMapping: Record<string, string[]> = {
|
||||
'RBM': ['RBM Review'],
|
||||
'ZBH': ['ZBH Review'],
|
||||
'DD Lead': ['DD Lead Review'],
|
||||
'DD Head': ['DD Head Review'],
|
||||
'NBH': ['NBH Evaluation', 'NBH Final Approval'],
|
||||
'Legal Admin': ['Legal Verification', 'Legal - Termination Letter'],
|
||||
'Legal': ['Legal Verification'],
|
||||
'DD Admin': ['Show Cause Notice', 'Terminated'],
|
||||
'CCO': ['CCO Approval'],
|
||||
'CEO': ['CEO Final Approval'],
|
||||
'Super Admin': ['RBM Review', 'ZBH Review', 'DD Lead Review', 'DD Head Review', 'NBH Evaluation', 'Legal Verification', 'Show Cause Notice', 'NBH Final Approval', 'CCO Approval', 'CEO Final Approval', 'Legal - Termination Letter', 'Terminated']
|
||||
};
|
||||
|
||||
const userStages = roleToStageMapping[userRole] || [];
|
||||
return userStages.some(stage =>
|
||||
(request.currentStage && request.currentStage.includes(stage)) ||
|
||||
(request.status && request.status.includes(stage))
|
||||
);
|
||||
};
|
||||
|
||||
const openRequests = terminations.filter(req =>
|
||||
!req.status.includes('Terminated') &&
|
||||
!req.status.includes('Completed') &&
|
||||
!req.status.includes('Closed') &&
|
||||
!req.status.includes('Rejected') &&
|
||||
isRequestAtMyLevel(req)
|
||||
);
|
||||
|
||||
const completedRequests = terminations.filter(req =>
|
||||
req.status.includes('Terminated') ||
|
||||
req.status.includes('Completed') ||
|
||||
req.status.includes('Closed')
|
||||
);
|
||||
// Map terminations to tab-specific views (already filtered by backend, but need variables for render)
|
||||
const openRequests = activeTab === 'open' || activeTab === 'all' ? terminations : [];
|
||||
const completedRequests = activeTab === 'completed' || activeTab === 'all' ? terminations : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -289,7 +276,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>All Cases</CardDescription>
|
||||
<CardTitle className="text-3xl">{terminations.length}</CardTitle>
|
||||
<CardTitle className="text-3xl">{paginationMeta?.total || 0}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-600">Total Cases</p>
|
||||
@ -299,7 +286,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Open</CardDescription>
|
||||
<CardTitle className="text-3xl text-orange-600">{openRequests.length}</CardTitle>
|
||||
<CardTitle className="text-3xl text-orange-600">{activeTab === 'open' ? paginationMeta?.total || 0 : '...'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-600">Requires Your Action</p>
|
||||
@ -309,7 +296,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Completed</CardDescription>
|
||||
<CardTitle className="text-3xl text-green-600">{completedRequests.length}</CardTitle>
|
||||
<CardTitle className="text-3xl text-green-600">{activeTab === 'completed' ? paginationMeta?.total || 0 : '...'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-slate-600">Finalized</p>
|
||||
@ -485,7 +472,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All Cases</TabsTrigger>
|
||||
<TabsTrigger value="open">Open</TabsTrigger>
|
||||
@ -692,6 +679,56 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||
<div className="py-4 border-t flex justify-center bg-white rounded-b-lg">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{[...Array(paginationMeta.totalPages)].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === paginationMeta.totalPages ||
|
||||
(pageNum >= currentPage - 1 && pageNum <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
if (
|
||||
(pageNum === 2 && currentPage > 3) ||
|
||||
(pageNum === paginationMeta.totalPages - 1 && currentPage < paginationMeta.totalPages - 2)
|
||||
) {
|
||||
return <PaginationItem key={pageNum}><PaginationEllipsis /></PaginationItem>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage(prev => Math.min(paginationMeta.totalPages, prev + 1))}
|
||||
className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -199,7 +199,7 @@ export const useMasterData = () => {
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const fetchAreas = useCallback(async (params?: { search?: string; page?: number; limit?: number; stateId?: string; isActive?: string }) => {
|
||||
const fetchAreas = useCallback(async (params?: { search?: string; page?: number; limit?: number; stateId?: string; isOpportunity?: string }) => {
|
||||
try {
|
||||
dispatch(setAreasLoading(true));
|
||||
const res = await masterService.getAreas(params) as any;
|
||||
|
||||
@ -6,10 +6,10 @@ export const onboardingService = {
|
||||
if (!response.ok) throw new Error(response.data?.message || 'Failed to submit application');
|
||||
return response.data;
|
||||
},
|
||||
getApplications: async () => {
|
||||
const response: any = await API.getApplications();
|
||||
getApplications: async (params?: any) => {
|
||||
const response: any = await API.getApplications(params);
|
||||
if (!response.ok) throw new Error(response.data?.message || 'Failed to fetch applications');
|
||||
return response.data?.data || response.data;
|
||||
return response.data;
|
||||
},
|
||||
shortlistApplications: async (applicationIds: string[], assignedTo: string[], remarks?: string) => {
|
||||
const response: any = await API.shortlistApplications({ applicationIds, assignedTo, remarks });
|
||||
@ -105,6 +105,16 @@ export const onboardingService = {
|
||||
if (!response.ok) throw new Error(response.data?.message || 'Failed to update application status');
|
||||
return response.data;
|
||||
},
|
||||
convertToOpportunity: async (id: string, data?: any) => {
|
||||
const response: any = await API.convertToOpportunity(id, data);
|
||||
if (!response.ok) throw new Error(response.data?.message || 'Failed to convert application to opportunity');
|
||||
return response.data;
|
||||
},
|
||||
bulkConvertToOpportunity: async (data: any) => {
|
||||
const response: any = await API.bulkConvertToOpportunity(data);
|
||||
if (!response.ok) throw new Error(response.data?.message || 'Failed to perform bulk conversion');
|
||||
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');
|
||||
|
||||
@ -30,6 +30,11 @@ export const worknoteService = {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
revokeParticipant: async (participantId: string, reason?: string) => {
|
||||
const response = await client.delete(`${API_BASE}/participants/${participantId}`, { data: { reason } });
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ export interface MasterState {
|
||||
regionName: string,
|
||||
zoneName: string,
|
||||
asmName?: string,
|
||||
isActive: boolean,
|
||||
isOpportunity: boolean,
|
||||
city?: string,
|
||||
openFrom?: string | Date | null,
|
||||
openTo?: string | Date | null
|
||||
|
||||
Loading…
Reference in New Issue
Block a user