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 { DealerRelocationPage } from '@/features/relocation/pages/DealerRelocationPage';
|
||||||
import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder';
|
import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder';
|
||||||
import QuestionnaireList from '@/components/admin/QuestionnaireList';
|
import QuestionnaireList from '@/components/admin/QuestionnaireList';
|
||||||
|
import InterviewConfigManagement from '@/features/master/components/InterviewConfigManagement';
|
||||||
import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage';
|
import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage';
|
||||||
import { NotificationsPage } from '@/pages/NotificationsPage';
|
import { NotificationsPage } from '@/pages/NotificationsPage';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
@ -265,6 +266,11 @@ export default function App() {
|
|||||||
<Route path="/questionnaire-builder" element={<QuestionnaireBuilder />} />
|
<Route path="/questionnaire-builder" element={<QuestionnaireBuilder />} />
|
||||||
<Route path="/questionnaire-builder/:id" element={<QuestionnaireBuilder />} />
|
<Route path="/questionnaire-builder/:id" element={<QuestionnaireBuilder />} />
|
||||||
<Route path="/questionnaires" element={<QuestionnaireList />} />
|
<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) */}
|
{/* HR/Finance Modules (Simplified for brevity, following pattern) */}
|
||||||
<Route path="/resignation" element={
|
<Route path="/resignation" element={
|
||||||
|
|||||||
@ -33,13 +33,13 @@ export const API = {
|
|||||||
saveZonalManager: (data: any) => client.post('/master/zonal-managers', data),
|
saveZonalManager: (data: any) => client.post('/master/zonal-managers', data),
|
||||||
getDDLeads: () => client.get('/master/dd-leads'),
|
getDDLeads: () => client.get('/master/dd-leads'),
|
||||||
saveDDLead: (data: any) => client.post('/master/dd-leads', data),
|
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
|
// Onboarding
|
||||||
submitApplication: (data: any) => client.post('/onboarding/apply', data),
|
submitApplication: (data: any) => client.post('/onboarding/apply', data),
|
||||||
exportApplicationResponses: (params: { applicationIds: string }) => client.get('/onboarding/applications/export-responses', params),
|
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),
|
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data),
|
||||||
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
|
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
|
||||||
updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data),
|
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 }),
|
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`),
|
generateDealerCodes: (applicationId: string) => client.post(`/onboarding/applications/${applicationId}/generate-codes`),
|
||||||
updateApplicationStatus: (id: string, data: any) => client.put(`/onboarding/applications/${id}/status`, data),
|
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`),
|
retriggerEvaluators: (id: string) => client.post(`/onboarding/applications/${id}/retrigger-evaluators`),
|
||||||
getSecurityDeposit: (applicationId: string) => client.get(`/loa/security-deposit/${applicationId}`),
|
getSecurityDeposit: (applicationId: string) => client.get(`/loa/security-deposit/${applicationId}`),
|
||||||
updateSecurityDeposit: (data: any) => client.post('/loa/security-deposit', data),
|
updateSecurityDeposit: (data: any) => client.post('/loa/security-deposit', data),
|
||||||
@ -100,7 +102,7 @@ export const API = {
|
|||||||
deleteUser: (id: string) => client.delete(`/admin/users/${id}`),
|
deleteUser: (id: string) => client.delete(`/admin/users/${id}`),
|
||||||
|
|
||||||
// Dealer & Outlets
|
// 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),
|
createDealer: (data: any) => client.post('/dealer', data),
|
||||||
getDealerById: (id: string) => client.get(`/dealer/${id}`),
|
getDealerById: (id: string) => client.get(`/dealer/${id}`),
|
||||||
updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data),
|
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),
|
rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data),
|
||||||
withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }),
|
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),
|
createTermination: (data: any) => client.post('/termination', data),
|
||||||
updateTermination: (id: string, data: any) => client.post(`/termination/${id}/status`, 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),
|
updateLineItem: (itemId: string, data: any) => client.put(`/settlement/fnf/line-items/${itemId}`, data),
|
||||||
deleteLineItem: (itemId: string) => client.delete(`/settlement/fnf/line-items/${itemId}`),
|
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}`),
|
getRelocationRequestById: (id: string) => client.get(`/relocation/${id}`),
|
||||||
createRelocationRequest: (data: any) => client.post('/relocation', data),
|
createRelocationRequest: (data: any) => client.post('/relocation', data),
|
||||||
updateRelocationRequest: (id: string, action: string, data?: any) => client.post(`/relocation/${id}/action`, { action, ...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) =>
|
rejectRelocationDocument: (id: string, documentId: string, data?: any) =>
|
||||||
client.post(`/relocation/${id}/documents/${documentId}/reject`, data || {}),
|
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'),
|
getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'),
|
||||||
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
|
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
|
||||||
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
|
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
|
||||||
@ -208,6 +210,15 @@ export const API = {
|
|||||||
saveSlaConfig: (data: any) => client.post('/master/sla-configs', data),
|
saveSlaConfig: (data: any) => client.post('/master/sla-configs', data),
|
||||||
initializeDefaultSlas: () => client.post('/master/sla-configs/initialize'),
|
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
|
// System Configs
|
||||||
getSystemConfigs: (params?: any) => client.get('/master/system-configs', params),
|
getSystemConfigs: (params?: any) => client.get('/master/system-configs', params),
|
||||||
saveSystemConfig: (data: any) => client.post('/master/system-configs', data),
|
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 { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Label } from '../ui/label';
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
||||||
import { Badge } from '../ui/badge';
|
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';
|
import { approvalPolicyService } from '../../services/approvalPolicy.service';
|
||||||
|
|
||||||
type ApprovalMode = 'ALL' | 'MIN_N' | 'ROLE_MANDATORY';
|
type ApprovalMode = 'ALL' | 'MIN_N' | 'ROLE_MANDATORY';
|
||||||
@ -19,13 +36,64 @@ interface Policy {
|
|||||||
isActive: boolean;
|
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() {
|
export function ApprovalPoliciesPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [policies, setPolicies] = useState<Policy[]>([]);
|
const [policies, setPolicies] = useState<Policy[]>([]);
|
||||||
const [editingCode, setEditingCode] = useState<string | null>(null);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [draft, setDraft] = useState<Policy | null>(null);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [isCustomStage, setIsCustomStage] = useState(false);
|
||||||
const [newPolicy, setNewPolicy] = useState<Policy>({
|
const [draft, setDraft] = useState<Policy>({
|
||||||
stageCode: '',
|
stageCode: '',
|
||||||
minApprovals: 1,
|
minApprovals: 1,
|
||||||
approvalMode: 'MIN_N',
|
approvalMode: 'MIN_N',
|
||||||
@ -52,26 +120,37 @@ export function ApprovalPoliciesPage() {
|
|||||||
fetchPolicies();
|
fetchPolicies();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startEdit = (policy: Policy) => {
|
const openCreateModal = () => {
|
||||||
setEditingCode(policy.stageCode);
|
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({
|
setDraft({
|
||||||
stageCode: policy.stageCode,
|
stageCode: policy.stageCode,
|
||||||
minApprovals: policy.minApprovals || 1,
|
minApprovals: policy.minApprovals || 1,
|
||||||
approvalMode: policy.approvalMode || 'MIN_N',
|
approvalMode: (policy.approvalMode as ApprovalMode) || 'MIN_N',
|
||||||
requiredRoles: Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [],
|
requiredRoles: Array.isArray(policy.requiredRoles) ? [...policy.requiredRoles] : [],
|
||||||
isActive: policy.isActive !== false
|
isActive: policy.isActive !== false
|
||||||
});
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const savePolicy = async () => {
|
||||||
setEditingCode(null);
|
if (!draft.stageCode.trim()) return;
|
||||||
setDraft(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveEdit = async () => {
|
|
||||||
if (!draft) return;
|
|
||||||
|
|
||||||
if (draft.approvalMode === 'ROLE_MANDATORY' && draft.requiredRoles.length > 0 && draft.minApprovals > draft.requiredRoles.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,42 +160,13 @@ export function ApprovalPoliciesPage() {
|
|||||||
requiredRoles: draft.requiredRoles,
|
requiredRoles: draft.requiredRoles,
|
||||||
isActive: draft.isActive
|
isActive: draft.isActive
|
||||||
};
|
};
|
||||||
const res = await approvalPolicyService.savePolicy(draft.stageCode, payload);
|
|
||||||
if (res?.success) {
|
|
||||||
await fetchPolicies();
|
|
||||||
cancelEdit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetNewPolicy = () => {
|
const stageCode = draft.stageCode.trim().toUpperCase().replace(/\s+/g, '_');
|
||||||
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 res = await approvalPolicyService.savePolicy(stageCode, payload);
|
const res = await approvalPolicyService.savePolicy(stageCode, payload);
|
||||||
|
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
await fetchPolicies();
|
await fetchPolicies();
|
||||||
resetNewPolicy();
|
setIsModalOpen(false);
|
||||||
setCreating(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -130,207 +180,278 @@ export function ApprovalPoliciesPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-500">Configure stage-level approvers, mode, and minimum approvals.</p>
|
<p className="text-slate-500">Configure stage-level approvers, mode, and minimum approvals.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={fetchPolicies} disabled={loading}>
|
<div className="flex gap-2">
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<Button variant="outline" onClick={fetchPolicies} disabled={loading}>
|
||||||
Refresh
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
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>
|
</div>
|
||||||
|
|
||||||
<Card className="border-slate-200 overflow-hidden">
|
<Card className="border-slate-200 overflow-hidden shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="bg-slate-50 px-6 py-4 border-b">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<CardTitle className="text-lg font-semibold text-slate-800">Configured Stages</CardTitle>
|
||||||
<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>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{creating && (
|
<div className="overflow-x-auto">
|
||||||
<div className="border-b bg-amber-50/40 p-4 grid grid-cols-1 md:grid-cols-5 gap-3">
|
<Table>
|
||||||
<div>
|
<TableHeader className="bg-slate-50/50">
|
||||||
<Label>Stage Code</Label>
|
<TableRow>
|
||||||
<Input
|
<TableHead className="w-[200px]">Stage Code</TableHead>
|
||||||
placeholder="e.g. ARCHITECTURE_APPROVAL"
|
<TableHead>Approval Mode</TableHead>
|
||||||
value={newPolicy.stageCode}
|
<TableHead>Min Appr.</TableHead>
|
||||||
onChange={(e) => setNewPolicy({ ...newPolicy, stageCode: e.target.value })}
|
<TableHead className="min-w-[300px]">Required Roles</TableHead>
|
||||||
/>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedPolicies.map((policy) => (
|
||||||
|
<TableRow key={policy.stageCode} className="hover:bg-slate-50/50 transition-colors">
|
||||||
|
<TableCell className="font-mono text-xs font-semibold text-slate-700">{policy.stageCode}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-medium px-2 py-0.5 text-slate-600 border-slate-300 uppercase">
|
||||||
|
{policy.approvalMode}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full text-xs">
|
||||||
|
{policy.minApprovals}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1.5 py-1">
|
||||||
|
{(policy.requiredRoles || []).map((role) => (
|
||||||
|
<Badge key={role} variant="secondary" className="bg-slate-100 text-slate-600 border-transparent hover:bg-slate-200 text-[11px] font-normal">
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${policy.isActive ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]' : 'bg-slate-400'}`} />
|
||||||
|
<span className={`text-xs font-medium ${policy.isActive ? 'text-green-700' : 'text-slate-500'}`}>
|
||||||
|
{policy.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-amber-600 hover:text-amber-700 hover:bg-amber-50 h-8 px-2"
|
||||||
|
onClick={() => openEditModal(policy)}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 mr-1.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Unified Edit/Create Modal */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[480px] overflow-visible">
|
||||||
|
<DialogHeader className="gap-1 pb-2 border-b">
|
||||||
|
<DialogTitle className="text-base flex items-center gap-2">
|
||||||
|
{isEditMode ? <Edit2 className="w-4 h-4 text-amber-600" /> : <Plus className="w-4 h-4 text-amber-600" />}
|
||||||
|
{isEditMode ? 'Edit Policy' : 'Create New Policy'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-[11px]">
|
||||||
|
{isEditMode
|
||||||
|
? `Update configuration for stage ${draft.stageCode}.`
|
||||||
|
: 'Define approval requirements for a workflow stage.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-start gap-4">
|
||||||
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight pt-2">Stage</Label>
|
||||||
|
<div className="col-span-3 space-y-2">
|
||||||
|
{!isCustomStage && !isEditMode ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
value={draft.stageCode}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val === 'CUSTOM') {
|
||||||
|
setIsCustomStage(true);
|
||||||
|
setDraft({ ...draft, stageCode: '' });
|
||||||
|
} else {
|
||||||
|
setDraft({ ...draft, stageCode: val });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-8 text-[11px] font-medium border-slate-200">
|
||||||
|
<SelectValue placeholder="Select a workflow stage..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[300px]">
|
||||||
|
{STAGE_OPTIONS.map((group) => (
|
||||||
|
<SelectGroup key={group.label}>
|
||||||
|
<SelectLabel className="text-[10px] uppercase text-slate-400 font-bold bg-slate-50 px-2 py-1">{group.label}</SelectLabel>
|
||||||
|
{group.stages.map((stage) => (
|
||||||
|
<SelectItem key={stage.value} value={stage.value} className="text-xs">
|
||||||
|
{stage.label} <span className="text-[10px] text-slate-400 font-mono ml-1">({stage.value})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
<SelectGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<SelectItem value="CUSTOM" className="text-xs font-semibold text-amber-600 italic">
|
||||||
|
+ Enter Custom Stage Code
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
className="w-full font-mono text-xs h-8 pr-8"
|
||||||
|
placeholder="e.g. FNF_SETTLEMENT"
|
||||||
|
disabled={isEditMode}
|
||||||
|
value={draft.stageCode}
|
||||||
|
onChange={(e) => setDraft({ ...draft, stageCode: e.target.value.toUpperCase() })}
|
||||||
|
/>
|
||||||
|
{!isEditMode && (
|
||||||
|
<X
|
||||||
|
className="w-3 h-3 absolute right-2.5 top-2.5 text-slate-400 cursor-pointer hover:text-slate-600"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCustomStage(false);
|
||||||
|
setDraft({ ...draft, stageCode: '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<Label>Approval Mode</Label>
|
|
||||||
|
<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
|
<Select
|
||||||
value={newPolicy.approvalMode}
|
value={draft.approvalMode}
|
||||||
onValueChange={(val: ApprovalMode) => setNewPolicy({ ...newPolicy, approvalMode: val })}
|
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>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">ALL</SelectItem>
|
<SelectItem value="ALL" className="text-xs">ALL (Everyone must approve)</SelectItem>
|
||||||
<SelectItem value="MIN_N">MIN_N</SelectItem>
|
<SelectItem value="MIN_N" className="text-xs">MIN_N (First N approvals count)</SelectItem>
|
||||||
<SelectItem value="ROLE_MANDATORY">ROLE_MANDATORY</SelectItem>
|
<SelectItem value="ROLE_MANDATORY" className="text-xs">ROLE_MANDATORY (Hierarchy base)</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>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<Table>
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<TableHeader className="bg-slate-50">
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Min Appr.</Label>
|
||||||
<TableRow>
|
<div className="col-span-3">
|
||||||
<TableHead>Stage Code</TableHead>
|
<Input
|
||||||
<TableHead>Approval Mode</TableHead>
|
type="number"
|
||||||
<TableHead>Min Approvals</TableHead>
|
min={1}
|
||||||
<TableHead>Required Roles</TableHead>
|
value={draft.minApprovals}
|
||||||
<TableHead>Status</TableHead>
|
onChange={(e) => setDraft({ ...draft, minApprovals: Number(e.target.value || 1) })}
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
className="w-20 h-8 text-xs border-slate-200"
|
||||||
</TableRow>
|
/>
|
||||||
</TableHeader>
|
</div>
|
||||||
<TableBody>
|
</div>
|
||||||
{sortedPolicies.map((policy) => {
|
|
||||||
const isEditing = editingCode === policy.stageCode && draft;
|
<div className="grid grid-cols-4 items-start gap-4">
|
||||||
return (
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight pt-2">Roles</Label>
|
||||||
<TableRow key={policy.stageCode}>
|
<div className="col-span-3 space-y-2">
|
||||||
<TableCell className="font-medium">{policy.stageCode}</TableCell>
|
<DropdownMenu>
|
||||||
<TableCell>
|
<DropdownMenuTrigger asChild>
|
||||||
{isEditing ? (
|
<Button variant="outline" size="sm" className="w-full justify-between h-8 text-[11px] text-slate-500 font-normal border-slate-200">
|
||||||
<Select
|
<div className="flex items-center gap-1.5">
|
||||||
value={draft.approvalMode}
|
<Plus className="w-3 h-3 text-amber-600" />
|
||||||
onValueChange={(val: ApprovalMode) => setDraft({ ...draft, approvalMode: val })}
|
<span>Add Roles...</span>
|
||||||
>
|
</div>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-transparent text-[9px] px-1 h-4">
|
||||||
<SelectContent>
|
{draft.requiredRoles.length}
|
||||||
<SelectItem value="ALL">ALL</SelectItem>
|
</Badge>
|
||||||
<SelectItem value="MIN_N">MIN_N</SelectItem>
|
</Button>
|
||||||
<SelectItem value="ROLE_MANDATORY">ROLE_MANDATORY</SelectItem>
|
</DropdownMenuTrigger>
|
||||||
</SelectContent>
|
<DropdownMenuContent className="w-[280px] max-h-[250px] overflow-y-auto shadow-xl" align="start">
|
||||||
</Select>
|
<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) => (
|
||||||
<Badge variant="outline">{policy.approvalMode}</Badge>
|
<DropdownMenuCheckboxItem
|
||||||
)}
|
key={role}
|
||||||
</TableCell>
|
className="text-xs"
|
||||||
<TableCell>
|
checked={draft.requiredRoles.includes(role)}
|
||||||
{isEditing ? (
|
onCheckedChange={(checked) => {
|
||||||
<Input
|
if (checked) {
|
||||||
type="number"
|
setDraft({ ...draft, requiredRoles: [...draft.requiredRoles, role] });
|
||||||
min={1}
|
} else {
|
||||||
value={draft.minApprovals}
|
setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) });
|
||||||
onChange={(e) => setDraft({ ...draft, minApprovals: Number(e.target.value || 1) })}
|
}
|
||||||
className="w-28"
|
}}
|
||||||
/>
|
>
|
||||||
) : (
|
{role}
|
||||||
policy.minApprovals
|
</DropdownMenuCheckboxItem>
|
||||||
)}
|
))}
|
||||||
</TableCell>
|
</DropdownMenuContent>
|
||||||
<TableCell className="max-w-[420px]">
|
</DropdownMenu>
|
||||||
{isEditing ? (
|
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap gap-1 min-h-[32px] p-2 rounded-sm bg-slate-50/50 border border-slate-100">
|
||||||
<Label>Comma separated roles</Label>
|
{draft.requiredRoles.map((role) => (
|
||||||
<Input
|
<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">
|
||||||
value={draft.requiredRoles.join(', ')}
|
{role}
|
||||||
onChange={(e) =>
|
<X
|
||||||
setDraft({
|
className="w-2.5 h-2.5 cursor-pointer text-slate-400 hover:text-red-500"
|
||||||
...draft,
|
onClick={() => setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) })}
|
||||||
requiredRoles: e.target.value
|
/>
|
||||||
.split(',')
|
</Badge>
|
||||||
.map((r) => r.trim())
|
))}
|
||||||
.filter(Boolean)
|
{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>
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{(policy.requiredRoles || []).map((role) => (
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<Badge key={role} variant="secondary">{role}</Badge>
|
<Label className="text-right text-[11px] font-semibold text-slate-500 uppercase tracking-tight">Status</Label>
|
||||||
))}
|
<div className="col-span-3">
|
||||||
</div>
|
<Select
|
||||||
)}
|
value={draft.isActive ? 'active' : 'inactive'}
|
||||||
</TableCell>
|
onValueChange={(val) => setDraft({ ...draft, isActive: val === 'active' })}
|
||||||
<TableCell>
|
>
|
||||||
{isEditing ? (
|
<SelectTrigger className="w-24 h-8 text-[11px] font-medium border-slate-200">
|
||||||
<Select
|
<SelectValue />
|
||||||
value={draft.isActive ? 'active' : 'inactive'}
|
</SelectTrigger>
|
||||||
onValueChange={(val) => setDraft({ ...draft, isActive: val === 'active' })}
|
<SelectContent>
|
||||||
>
|
<SelectItem value="active" className="text-xs">Active</SelectItem>
|
||||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
<SelectItem value="inactive" className="text-xs">Inactive</SelectItem>
|
||||||
<SelectContent>
|
</SelectContent>
|
||||||
<SelectItem value="active">Active</SelectItem>
|
</Select>
|
||||||
<SelectItem value="inactive">Inactive</SelectItem>
|
</div>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
</div>
|
||||||
) : (
|
|
||||||
<Badge className={policy.isActive ? 'bg-green-600' : 'bg-slate-500'}>
|
<DialogFooter className="gap-2 pt-3 border-t">
|
||||||
{policy.isActive ? 'Active' : 'Inactive'}
|
<Button variant="outline" size="sm" className="text-xs h-8" onClick={() => setIsModalOpen(false)}>
|
||||||
</Badge>
|
Cancel
|
||||||
)}
|
</Button>
|
||||||
</TableCell>
|
<Button className="bg-amber-600 hover:bg-amber-700 h-8 text-xs font-semibold" onClick={savePolicy}>
|
||||||
<TableCell className="text-right">
|
<Save className="w-3 h-3 mr-1.5" />
|
||||||
{isEditing ? (
|
{isEditMode ? 'Save Changes' : 'Create Policy'}
|
||||||
<div className="flex justify-end gap-2">
|
</Button>
|
||||||
<Button size="sm" className="bg-amber-600 hover:bg-amber-700" onClick={saveEdit}>
|
</DialogFooter>
|
||||||
<Save className="w-4 h-4 mr-1" /> Save
|
</DialogContent>
|
||||||
</Button>
|
</Dialog>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
MapPin,
|
MapPin,
|
||||||
ClipboardList
|
ClipboardList,
|
||||||
|
ListChecks
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
@ -109,6 +110,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
|
|||||||
if (hasRole(['Super Admin'])) {
|
if (hasRole(['Super Admin'])) {
|
||||||
menuItems.push({ id: 'users', label: 'User Management', icon: Users });
|
menuItems.push({ id: 'users', label: 'User Management', icon: Users });
|
||||||
menuItems.push({ id: 'questionnaires', label: 'Questionnaire Templates', icon: ClipboardList });
|
menuItems.push({ id: 'questionnaires', label: 'Questionnaire Templates', icon: ClipboardList });
|
||||||
|
menuItems.push({ id: 'interview-configs', label: 'Interview Configs', icon: ListChecks });
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
@ -152,7 +154,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
|
|||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
/* Collapsed header: logo + toggle stacked, centered */
|
/* Collapsed header: logo + toggle stacked, centered */
|
||||||
<div className="flex flex-col items-center py-3 gap-3">
|
<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
|
<img
|
||||||
src="/assets/images/Re_Logo.png"
|
src="/assets/images/Re_Logo.png"
|
||||||
alt="RE"
|
alt="RE"
|
||||||
@ -171,7 +173,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
|
|||||||
/* Expanded header: logo + subtitle + collapse toggle */
|
/* Expanded header: logo + subtitle + collapse toggle */
|
||||||
<div className="flex items-center justify-between px-4 py-4">
|
<div className="flex items-center justify-between px-4 py-4">
|
||||||
<div className="flex flex-col min-w-0">
|
<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">
|
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-slate-400 mt-1 whitespace-nowrap">
|
||||||
Dealer Onboarding
|
Dealer Onboarding
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -290,7 +290,7 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-10 h-[44px]">
|
<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">
|
<div className="flex gap-8">
|
||||||
{['yes', 'no'].map(val => (
|
{['yes', 'no'].map(val => (
|
||||||
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
||||||
@ -349,14 +349,14 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
|
|||||||
value={formData.source}
|
value={formData.source}
|
||||||
onChange={(e) => setFormData({...formData, source: e.target.value})}
|
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>)}
|
{sourceOptions.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-center h-auto min-h-[44px] space-y-1">
|
<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">
|
<div className="flex gap-8">
|
||||||
{['yes', 'no'].map(val => (
|
{['yes', 'no'].map(val => (
|
||||||
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
<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 { API } from '@/api/API';
|
||||||
import { formatDateTime } from '@/components/ui/utils';
|
import { formatDateTime } from '@/components/ui/utils';
|
||||||
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
|
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
interface ConstitutionalChangePageProps {
|
interface ConstitutionalChangePageProps {
|
||||||
currentUser?: UserType | null;
|
currentUser?: UserType | null;
|
||||||
@ -87,6 +96,10 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
const [requiredDocs, setRequiredDocs] = useState<number[]>([]);
|
const [requiredDocs, setRequiredDocs] = useState<number[]>([]);
|
||||||
const [requests, setRequests] = useState<any[]>([]);
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [dialogDataLoading, setDialogDataLoading] = useState(false);
|
const [dialogDataLoading, setDialogDataLoading] = useState(false);
|
||||||
|
|
||||||
@ -102,9 +115,14 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
const isSubmittedRequest = (request: any) =>
|
const isSubmittedRequest = (request: any) =>
|
||||||
request.status === 'Submitted' || request.currentStage === 'Submitted';
|
request.status === 'Submitted' || request.currentStage === 'Submitted';
|
||||||
|
|
||||||
|
const handleTabChange = (val: string) => {
|
||||||
|
setActiveTab(val);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRequests();
|
fetchRequests();
|
||||||
}, []);
|
}, [currentPage, activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDialogOpen) return;
|
if (!isDialogOpen) return;
|
||||||
@ -140,11 +158,16 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
const fetchRequests = async () => {
|
const fetchRequests = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
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) {
|
if (response.data.success) {
|
||||||
setRequests(response.data.requests || []);
|
setRequests(response.data.requests || []);
|
||||||
|
setPaginationMeta(response.meta);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Fetch requests error:', error);
|
console.error('Fetch requests error:', error);
|
||||||
toast.error('Failed to fetch requests');
|
toast.error('Failed to fetch requests');
|
||||||
} finally {
|
} finally {
|
||||||
@ -258,25 +281,25 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
title: 'Total Requests',
|
title: 'Total Requests',
|
||||||
value: requests.length,
|
value: paginationMeta?.stats?.total || 0,
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
color: 'bg-blue-500',
|
color: 'bg-blue-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Submitted / Review',
|
title: 'Submitted / Review',
|
||||||
value: requests.filter(r => isSubmittedRequest(r) || isPendingReviewRequest(r)).length,
|
value: paginationMeta?.stats?.pending || 0,
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
color: 'bg-yellow-500',
|
color: 'bg-yellow-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Completed',
|
title: 'Completed',
|
||||||
value: requests.filter(r => isCompletedRequest(r)).length,
|
value: paginationMeta?.stats?.completed || 0,
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
color: 'bg-green-500',
|
color: 'bg-green-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Rejected / Revoked',
|
title: 'Rejected / Revoked',
|
||||||
value: requests.filter(r => isRejectedRequest(r)).length,
|
value: paginationMeta?.stats?.rejected || 0,
|
||||||
icon: Building,
|
icon: Building,
|
||||||
color: 'bg-red-500',
|
color: 'bg-red-500',
|
||||||
},
|
},
|
||||||
@ -519,11 +542,11 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="all" className="w-full">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
<TabsTrigger value="all">All Requests</TabsTrigger>
|
<TabsTrigger value="all">All Requests</TabsTrigger>
|
||||||
<TabsTrigger value="pending">Submitted / Review</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>
|
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@ -839,6 +862,56 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -45,10 +45,11 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [settlements, apps] = await Promise.all([
|
const [settlements, response] = await Promise.all([
|
||||||
settlementService.getFnFSettlements(),
|
settlementService.getFnFSettlements(),
|
||||||
onboardingService.getApplications()
|
onboardingService.getApplications()
|
||||||
]);
|
]);
|
||||||
|
const apps = response.data || [];
|
||||||
|
|
||||||
// Derive Onboarding Payments from Application + SecurityDeposit (Standardized nomenclature)
|
// Derive Onboarding Payments from Application + SecurityDeposit (Standardized nomenclature)
|
||||||
// This ensures applications in "Payment Pending" / "Security Details" are visible
|
// 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>
|
||||||
|
|
||||||
<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}>
|
<Select value={locationStatus} onValueChange={setLocationStatus}>
|
||||||
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
|
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
|
||||||
<SelectValue placeholder="Select Status" />
|
<SelectValue placeholder="Is this an active Opportunity?" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="active">Active</SelectItem>
|
<SelectItem value="active">Yes</SelectItem>
|
||||||
<SelectItem value="inactive">Inactive</SelectItem>
|
<SelectItem value="inactive">No</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,13 +70,13 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-44">
|
||||||
<SelectValue placeholder="All Status" />
|
<SelectValue placeholder="Opportunity Filter" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">All Opportunities</SelectItem>
|
||||||
<SelectItem value="active">Active Only</SelectItem>
|
<SelectItem value="active">Opportunity: Yes</SelectItem>
|
||||||
<SelectItem value="inactive">Inactive Only</SelectItem>
|
<SelectItem value="inactive">Opportunity: No</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
|
|||||||
<TableHead>City</TableHead>
|
<TableHead>City</TableHead>
|
||||||
<TableHead>District</TableHead>
|
<TableHead>District</TableHead>
|
||||||
<TableHead>Active Period</TableHead>
|
<TableHead>Active Period</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Opportunity</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@ -140,10 +140,10 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={district.isActive ? 'default' : 'secondary'}
|
variant={district.isOpportunity ? 'default' : 'secondary'}
|
||||||
className={district.isActive ? 'bg-green-600 hover:bg-green-700 text-white border-transparent' : ''}
|
className={district.isOpportunity ? 'bg-green-600 hover:bg-green-700 text-white border-transparent' : ''}
|
||||||
>
|
>
|
||||||
{district.isActive ? 'Active' : 'Inactive'}
|
{district.isOpportunity ? 'Yes' : 'No'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
|
|||||||
import {
|
import {
|
||||||
Tabs, TabsContent, TabsList, TabsTrigger
|
Tabs, TabsContent, TabsList, TabsTrigger
|
||||||
} from '@/components/ui/tabs';
|
} 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 { Badge } from '@/components/ui/badge';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -31,6 +31,7 @@ import { TemplateDialog } from '@/features/master/components/TemplateDialog';
|
|||||||
import { LocationDialog } from '@/features/master/components/LocationDialog';
|
import { LocationDialog } from '@/features/master/components/LocationDialog';
|
||||||
import { SecurityDepositMaster } from '@/features/master/components/SecurityDepositMaster';
|
import { SecurityDepositMaster } from '@/features/master/components/SecurityDepositMaster';
|
||||||
import { DocumentConfigManagement } from '@/features/master/components/DocumentConfigManagement';
|
import { DocumentConfigManagement } from '@/features/master/components/DocumentConfigManagement';
|
||||||
|
import { AutoAssignmentSettings } from '@/features/master/components/AutoAssignmentSettings';
|
||||||
import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage';
|
import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage';
|
||||||
import { RootState } from '@/store';
|
import { RootState } from '@/store';
|
||||||
|
|
||||||
@ -377,7 +378,7 @@ export const MasterPage: React.FC = () => {
|
|||||||
setLocationDistrict(loc.districtId || '');
|
setLocationDistrict(loc.districtId || '');
|
||||||
setLocationActiveFrom(loc.openFrom ? new Date(loc.openFrom).toISOString().split('T')[0] : '');
|
setLocationActiveFrom(loc.openFrom ? new Date(loc.openFrom).toISOString().split('T')[0] : '');
|
||||||
setLocationActiveTo(loc.openTo ? new Date(loc.openTo).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);
|
setShowLocationDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -404,7 +405,7 @@ export const MasterPage: React.FC = () => {
|
|||||||
status: locationStatus,
|
status: locationStatus,
|
||||||
openFrom: locationActiveFrom,
|
openFrom: locationActiveFrom,
|
||||||
openTo: locationActiveTo,
|
openTo: locationActiveTo,
|
||||||
isActive: locationStatus === 'active'
|
isOpportunity: locationStatus === 'active'
|
||||||
};
|
};
|
||||||
const res = await (editingLocationId
|
const res = await (editingLocationId
|
||||||
? masterService.updateArea(editingLocationId, payload)
|
? masterService.updateArea(editingLocationId, payload)
|
||||||
@ -423,11 +424,11 @@ export const MasterPage: React.FC = () => {
|
|||||||
search: districtsSearch,
|
search: districtsSearch,
|
||||||
page: districtsPage,
|
page: districtsPage,
|
||||||
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter,
|
stateId: locationStateFilter === 'all' ? undefined : locationStateFilter,
|
||||||
isActive: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
|
isOpportunity: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false')
|
||||||
});
|
});
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(handler);
|
return () => clearTimeout(handler);
|
||||||
}, [districtsSearch, districtsPage, locationStateFilter, fetchAreas]);
|
}, [districtsSearch, districtsPage, locationStateFilter, locationStatusFilter, fetchAreas]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -446,7 +447,7 @@ export const MasterPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<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">
|
<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
|
<Globe className="w-4 h-4" /> Organisation
|
||||||
</TabsTrigger>
|
</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]">
|
<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
|
<FileText className="w-4 h-4" /> Docs Config
|
||||||
</TabsTrigger>
|
</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]">
|
<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
|
<Settings className="w-4 h-4" /> App Settings
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@ -581,6 +585,10 @@ export const MasterPage: React.FC = () => {
|
|||||||
<TabsContent value="documents" className="animate-in fade-in duration-300">
|
<TabsContent value="documents" className="animate-in fade-in duration-300">
|
||||||
<DocumentConfigManagement />
|
<DocumentConfigManagement />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="governance" className="animate-in fade-in duration-300">
|
||||||
|
<AutoAssignmentSettings />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="settings" className="animate-in fade-in duration-300">
|
<TabsContent value="settings" className="animate-in fade-in duration-300">
|
||||||
<SecurityDepositMaster />
|
<SecurityDepositMaster />
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Remark (Required)</Label>
|
<Label>Remark <span className="text-red-500">*</span></Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter approval remarks..."
|
placeholder="Enter approval remarks..."
|
||||||
value={approvalRemark}
|
value={approvalRemark}
|
||||||
@ -214,7 +214,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Reason for Rejection (Required)</Label>
|
<Label>Reason for Rejection <span className="text-red-500">*</span></Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter rejection reason..."
|
placeholder="Enter rejection reason..."
|
||||||
value={rejectionReason}
|
value={rejectionReason}
|
||||||
@ -253,17 +253,17 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Interview Mode</Label>
|
<Label>Interview Mode <span className="text-red-500">*</span></Label>
|
||||||
<Select value={interviewMode} onValueChange={setInterviewMode}>
|
<Select value={interviewMode} onValueChange={setInterviewMode}>
|
||||||
<SelectTrigger className="mt-2" data-testid="onboarding-schedule-mode-select"><SelectValue placeholder="Select interview mode" /></SelectTrigger>
|
<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>
|
<SelectContent><SelectItem value="virtual">Virtual</SelectItem><SelectItem value="physical">Physical</SelectItem></SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 === '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>}
|
{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>
|
<div>
|
||||||
<Label>Interviewers</Label>
|
<Label>Interviewers <span className="text-red-500">*</span></Label>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<Select value={selectedInterviewerId} onValueChange={setSelectedInterviewerId}>
|
<Select value={selectedInterviewerId} onValueChange={setSelectedInterviewerId}>
|
||||||
<SelectTrigger className="flex-1" data-testid="onboarding-schedule-interviewer-select"><SelectValue placeholder="Select interviewer" /></SelectTrigger>
|
<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>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Select Architecture Lead</Label>
|
<Label>Select Architecture Lead <span className="text-red-500">*</span></Label>
|
||||||
<Select value={architectureLeadId} onValueChange={setArchitectureLeadId}>
|
<Select value={architectureLeadId} onValueChange={setArchitectureLeadId}>
|
||||||
<SelectTrigger className="mt-2" data-testid="onboarding-architecture-lead-select"><SelectValue placeholder="Search users..." /></SelectTrigger>
|
<SelectTrigger className="mt-2" data-testid="onboarding-architecture-lead-select"><SelectValue placeholder="Search users..." /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -330,7 +330,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Status</Label>
|
<Label>Status <span className="text-red-500">*</span></Label>
|
||||||
<Select value={architectureStatus} onValueChange={setArchitectureStatus}>
|
<Select value={architectureStatus} onValueChange={setArchitectureStatus}>
|
||||||
<SelectTrigger className="mt-2" data-testid="onboarding-architecture-status-select"><SelectValue placeholder="Select status" /></SelectTrigger>
|
<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>
|
<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) {
|
export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtendedModalsProps) {
|
||||||
const {
|
const {
|
||||||
application,
|
application,
|
||||||
KT_MATRIX_CRITERIA,
|
ktCriteria,
|
||||||
|
l2Fields,
|
||||||
|
l3Fields,
|
||||||
showKTMatrixModal,
|
showKTMatrixModal,
|
||||||
setShowKTMatrixModal,
|
setShowKTMatrixModal,
|
||||||
ktMatrixSelectedValues,
|
ktMatrixSelectedValues,
|
||||||
@ -105,16 +107,16 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
|||||||
<DialogDescription className="text-sm leading-relaxed">
|
<DialogDescription className="text-sm leading-relaxed">
|
||||||
Level 1 interview · {application.name}
|
Level 1 interview · {application.name}
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="custom-scrollbar-slim min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
<div className="custom-scrollbar-slim min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||||
<div className="space-y-6">
|
<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">
|
<div key={criterion.name} className="space-y-2">
|
||||||
<Label htmlFor={`kt-matrix-${idx}`} className="block text-sm font-medium leading-relaxed text-foreground">
|
<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>
|
<span className="font-normal text-muted-foreground">({criterion.weight}%)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<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>
|
<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">
|
<div className="flex gap-2 sm:shrink-0">
|
||||||
<Button variant="outline" onClick={() => setShowKTMatrixModal(false)} data-testid="onboarding-kt-matrix-cancel">Cancel</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@ -167,22 +169,44 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
|||||||
<DialogDescription>Provide detailed feedback from the Level 2 interview (DD Lead + ZBH evaluation).</DialogDescription>
|
<DialogDescription>Provide detailed feedback from the Level 2 interview (DD Lead + ZBH evaluation).</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div><Label>Interview Date</Label><Input type="date" className="mt-2" value={level2Feedback.interviewDate} 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</Label><Input placeholder="Enter your name" className="mt-2" value={level2Feedback.interviewerName} 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>
|
<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)}>
|
<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>
|
<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>
|
<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>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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>
|
{(l2Fields || []).map((field: any, idx: number) => (
|
||||||
<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 key={field.itemKey || idx}>
|
||||||
<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>
|
<Label>
|
||||||
<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>
|
{field.label}
|
||||||
<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>
|
{field.isRequired && <span className="text-red-500">*</span>}
|
||||||
<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>
|
</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">
|
<div className="flex gap-3">
|
||||||
<Button variant="outline" className="flex-1" onClick={() => setShowLevel2FeedbackModal(false)} data-testid="onboarding-level2-feedback-cancel">Cancel</Button>
|
<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>
|
<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>
|
<DialogDescription>Provide detailed feedback from the Level 3 interview (NBH + DD-Head evaluation).</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div><Label>Interview Date</Label><Input type="date" className="mt-2" value={level3Feedback.interviewDate} 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</Label><Input placeholder="Enter your name" className="mt-2" value={level3Feedback.interviewerName} 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>
|
<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)}>
|
<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>
|
<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>
|
<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>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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>
|
{(l3Fields || []).map((field: any, idx: number) => (
|
||||||
<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 key={field.itemKey || idx}>
|
||||||
<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>
|
<Label>
|
||||||
<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>
|
{field.label}
|
||||||
<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>
|
{field.isRequired && <span className="text-red-500">*</span>}
|
||||||
<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>
|
</Label>
|
||||||
<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>
|
{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">
|
<div className="flex gap-3">
|
||||||
<Button variant="outline" className="flex-1" onClick={() => setShowLevel3FeedbackModal(false)} data-testid="onboarding-level3-feedback-cancel">Cancel</Button>
|
<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>
|
<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">
|
<TableCell className="text-right py-3">
|
||||||
<div className="flex gap-1 justify-end">
|
<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-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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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 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="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<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)}>
|
<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>
|
<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>
|
<SelectContent>
|
||||||
@ -316,7 +361,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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}>
|
<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>
|
<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>
|
<SelectContent>
|
||||||
@ -350,7 +395,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -377,7 +422,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{(currentUser?.role !== 'FDD' && currentUser?.roleCode !== 'FDD') && (
|
{(currentUser?.role !== 'FDD' && currentUser?.roleCode !== 'FDD') && (
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="flex gap-2">
|
||||||
{['Recommended', 'Qualified with Observations', 'Not Recommended'].map((rec) => (
|
{['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>
|
<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>
|
||||||
<div className="p-8 space-y-6 bg-white">
|
<div className="p-8 space-y-6 bg-white">
|
||||||
<div className="space-y-2">
|
<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}>
|
<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>
|
<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>
|
<SelectContent>
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export function ApplicationDetailsFddAuditContent({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<div className="flex-1">
|
<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
|
<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"
|
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}
|
value={selectedAgencyId}
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
@ -148,8 +147,8 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{application.isShortlisted !== false && (
|
{(application.isShortlisted !== false || application.status === 'Submitted') && (
|
||||||
<Card data-testid="onboarding-details-actions-card">
|
<Card data-testid="onboarding-details-actions-card">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Actions</CardTitle>
|
<CardTitle>Actions</CardTitle>
|
||||||
@ -412,4 +411,3 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -548,4 +548,3 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
|
|||||||
handleRetriggerEvaluators,
|
handleRetriggerEvaluators,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { toast } from 'sonner';
|
|||||||
import { Dispatch, SetStateAction } from 'react';
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
import { onboardingService } from '@/services/onboarding.service';
|
import { onboardingService } from '@/services/onboarding.service';
|
||||||
import { KT_MATRIX_CRITERIA } from '@/features/onboarding/components/application-details/applicationDetails.shared';
|
import { KT_MATRIX_CRITERIA } from '@/features/onboarding/components/application-details/applicationDetails.shared';
|
||||||
|
import type { InterviewConfig } from './useInterviewConfigs';
|
||||||
|
|
||||||
interface UseApplicationDetailsFeedbackActionsParams {
|
interface UseApplicationDetailsFeedbackActionsParams {
|
||||||
ktMatrixScores: Record<string, number>;
|
ktMatrixScores: Record<string, number>;
|
||||||
@ -24,6 +25,9 @@ interface UseApplicationDetailsFeedbackActionsParams {
|
|||||||
currentUser: any;
|
currentUser: any;
|
||||||
fetchInterviews: () => Promise<void>;
|
fetchInterviews: () => Promise<void>;
|
||||||
fetchApplication: (silent?: boolean) => Promise<void>;
|
fetchApplication: (silent?: boolean) => Promise<void>;
|
||||||
|
ktMatrixConfig: InterviewConfig | null;
|
||||||
|
level2Config: InterviewConfig | null;
|
||||||
|
level3Config: InterviewConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = () => new Date().toISOString().split('T')[0];
|
const today = () => new Date().toISOString().split('T')[0];
|
||||||
@ -75,7 +79,61 @@ export function useApplicationDetailsFeedbackActions({
|
|||||||
currentUser,
|
currentUser,
|
||||||
fetchInterviews,
|
fetchInterviews,
|
||||||
fetchApplication,
|
fetchApplication,
|
||||||
|
ktMatrixConfig,
|
||||||
|
level2Config,
|
||||||
|
level3Config,
|
||||||
}: UseApplicationDetailsFeedbackActionsParams) {
|
}: 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) => {
|
const handleKTMatrixChange = (criterionName: string, value: string, score: number) => {
|
||||||
setKtMatrixScores((prev) => ({ ...prev, [criterionName]: score }));
|
setKtMatrixScores((prev) => ({ ...prev, [criterionName]: score }));
|
||||||
setKtMatrixSelectedValues((prev) => ({ ...prev, [criterionName]: value }));
|
setKtMatrixSelectedValues((prev) => ({ ...prev, [criterionName]: value }));
|
||||||
@ -83,15 +141,17 @@ export function useApplicationDetailsFeedbackActions({
|
|||||||
|
|
||||||
const calculateKTScore = () => {
|
const calculateKTScore = () => {
|
||||||
let totalWeightedScore = 0;
|
let totalWeightedScore = 0;
|
||||||
KT_MATRIX_CRITERIA.forEach((criterion) => {
|
ktCriteria.forEach((criterion: any) => {
|
||||||
const score = ktMatrixScores[criterion.name] || 0;
|
const score = ktMatrixScores[criterion.name || criterion.label] || 0;
|
||||||
totalWeightedScore += (score / criterion.maxScore) * criterion.weight;
|
const maxScore = criterion.maxScore || 10;
|
||||||
|
const weight = criterion.weight || 0;
|
||||||
|
totalWeightedScore += (score / maxScore) * weight;
|
||||||
});
|
});
|
||||||
return totalWeightedScore.toFixed(2);
|
return totalWeightedScore.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitKTMatrix = async () => {
|
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');
|
toast.warning('Please fill all fields in the KT Matrix');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -102,11 +162,11 @@ export function useApplicationDetailsFeedbackActions({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setIsSubmittingKT(true);
|
setIsSubmittingKT(true);
|
||||||
const criteriaScores = KT_MATRIX_CRITERIA.map((c) => ({
|
const criteriaScores = ktCriteria.map((c: any) => ({
|
||||||
criterionName: c.name,
|
criterionName: c.name || c.label,
|
||||||
score: ktMatrixScores[c.name] || 0,
|
score: ktMatrixScores[c.name || c.label] || 0,
|
||||||
maxScore: c.maxScore,
|
maxScore: c.maxScore || 10,
|
||||||
weightage: c.weight,
|
weightage: c.weight || 0,
|
||||||
}));
|
}));
|
||||||
await onboardingService.submitKTMatrix({ interviewId, criteriaScores, feedback: ktMatrixRemarks, recommendation: null });
|
await onboardingService.submitKTMatrix({ interviewId, criteriaScores, feedback: ktMatrixRemarks, recommendation: null });
|
||||||
toast.success('KT Matrix submitted successfully');
|
toast.success('KT Matrix submitted successfully');
|
||||||
@ -139,14 +199,9 @@ export function useApplicationDetailsFeedbackActions({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setIsSubmittingLevel2(true);
|
setIsSubmittingLevel2(true);
|
||||||
const feedbackItems = [
|
const feedbackItems = l2Fields
|
||||||
{ type: 'Strategic Vision', comments: level2Feedback.strategicVision },
|
.map((f: any) => ({ type: f.label, comments: level2Feedback[f.itemKey] || '' }))
|
||||||
{ type: 'Management Capabilities', comments: level2Feedback.managementCapabilities },
|
.filter((item) => item.comments.trim() !== '');
|
||||||
{ 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() !== '');
|
|
||||||
await onboardingService.submitLevel2Feedback({ interviewId, overallScore: Number(level2Feedback.overallScore), feedbackItems });
|
await onboardingService.submitLevel2Feedback({ interviewId, overallScore: Number(level2Feedback.overallScore), feedbackItems });
|
||||||
toast.success('Level 2 Feedback submitted successfully');
|
toast.success('Level 2 Feedback submitted successfully');
|
||||||
setShowLevel2FeedbackModal(false);
|
setShowLevel2FeedbackModal(false);
|
||||||
@ -176,16 +231,9 @@ export function useApplicationDetailsFeedbackActions({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setIsSubmittingLevel3(true);
|
setIsSubmittingLevel3(true);
|
||||||
const feedbackItems = [
|
const feedbackItems = l3Fields
|
||||||
{ type: 'Business Vision & Strategy', comments: level3Feedback.strategicVision },
|
.map((f: any) => ({ type: f.label, comments: level3Feedback[f.itemKey] || '' }))
|
||||||
{ type: 'Leadership & Decision Making', comments: level3Feedback.managementCapabilities },
|
.filter((item) => item.comments.trim() !== '');
|
||||||
{ 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() !== '');
|
|
||||||
await onboardingService.submitLevel2Feedback({ interviewId, overallScore: Number(level3Feedback.overallScore), feedbackItems });
|
await onboardingService.submitLevel2Feedback({ interviewId, overallScore: Number(level3Feedback.overallScore), feedbackItems });
|
||||||
toast.success('Level 3 Feedback submitted successfully');
|
toast.success('Level 3 Feedback submitted successfully');
|
||||||
setShowLevel3FeedbackModal(false);
|
setShowLevel3FeedbackModal(false);
|
||||||
@ -207,6 +255,9 @@ export function useApplicationDetailsFeedbackActions({
|
|||||||
handleSubmitLevel2Feedback,
|
handleSubmitLevel2Feedback,
|
||||||
handleLevel3Change,
|
handleLevel3Change,
|
||||||
handleSubmitLevel3Feedback,
|
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');
|
const ddHeadLoaApproved = application.stageApprovals?.some((a: any) => a.stageCode === 'LOA_APPROVAL' && a.actorRole === 'DD Head' && a.decision === 'Approved');
|
||||||
|
|
||||||
let sequenceMet = true;
|
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 === '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 === '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';
|
if (application.status === 'LOA Pending') sequenceMet = currentUser.role === 'NBH' ? !!ddHeadLoaApproved : currentUser.role === 'DD Head';
|
||||||
|
|||||||
@ -5,16 +5,10 @@ interface UseApplicationDetailsUIStateParams {
|
|||||||
initialTab?: string;
|
initialTab?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().split('T')[0];
|
export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: UseApplicationDetailsUIStateParams) {
|
||||||
|
|
||||||
export function useApplicationDetailsUIState({
|
|
||||||
currentUser,
|
|
||||||
initialTab = 'questionnaire',
|
|
||||||
}: UseApplicationDetailsUIStateParams) {
|
|
||||||
const [showFirmTypeModal, setShowFirmTypeModal] = useState(false);
|
const [showFirmTypeModal, setShowFirmTypeModal] = useState(false);
|
||||||
const [updatingFirmType, setUpdatingFirmType] = useState(false);
|
const [updatingFirmType, setUpdatingFirmType] = useState(false);
|
||||||
const [tempFirmType, setTempFirmType] = useState('');
|
const [tempFirmType, setTempFirmType] = useState('');
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(initialTab);
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||||
const [showOnboardModal, setShowOnboardModal] = useState(false);
|
const [showOnboardModal, setShowOnboardModal] = useState(false);
|
||||||
@ -29,15 +23,12 @@ export function useApplicationDetailsUIState({
|
|||||||
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
|
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
|
||||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||||
const [selectedStage, setSelectedStage] = useState<string | null>(null);
|
const [selectedStage, setSelectedStage] = useState<string | null>(null);
|
||||||
const [interviewMode, setInterviewMode] = useState('virtual');
|
const [interviewMode, setInterviewMode] = useState('physical');
|
||||||
const [approvalRemark, setApprovalRemark] = useState('');
|
const [approvalRemark, setApprovalRemark] = useState('');
|
||||||
const [expandedBranches, setExpandedBranches] = useState<{ [key: string]: boolean }>({
|
const [expandedBranches, setExpandedBranches] = useState<Record<string, boolean>>({});
|
||||||
'architectural-work': true,
|
|
||||||
'statutory-documents': true,
|
|
||||||
});
|
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = useState<string>('');
|
const [selectedUser, setSelectedUser] = useState('');
|
||||||
const [participantType, setParticipantType] = useState<string>('contributor');
|
const [participantType, setParticipantType] = useState('contributor');
|
||||||
const [interviewDate, setInterviewDate] = useState('');
|
const [interviewDate, setInterviewDate] = useState('');
|
||||||
const [interviewType, setInterviewType] = useState('level1');
|
const [interviewType, setInterviewType] = useState('level1');
|
||||||
const [meetingLink, setMeetingLink] = useState('');
|
const [meetingLink, setMeetingLink] = useState('');
|
||||||
@ -49,77 +40,41 @@ export function useApplicationDetailsUIState({
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [previewDoc, setPreviewDoc] = useState<any>(null);
|
const [previewDoc, setPreviewDoc] = useState<any>(null);
|
||||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||||
const [selectedInterviewerId, setSelectedInterviewerId] = useState<string>('');
|
const [selectedInterviewerId, setSelectedInterviewerId] = useState('');
|
||||||
const [isEditingStatutory, setIsEditingStatutory] = useState(false);
|
const [isEditingStatutory, setIsEditingStatutory] = useState(false);
|
||||||
const [statutoryForm, setStatutoryForm] = useState({
|
const [statutoryForm, setStatutoryForm] = useState<any>({});
|
||||||
accountHolderName: '',
|
|
||||||
panNumber: '',
|
|
||||||
gstNumber: '',
|
|
||||||
bankName: '',
|
|
||||||
accountNumber: '',
|
|
||||||
ifscCode: '',
|
|
||||||
registeredAddress: '',
|
|
||||||
});
|
|
||||||
const [isSavingStatutory, setIsSavingStatutory] = useState(false);
|
const [isSavingStatutory, setIsSavingStatutory] = useState(false);
|
||||||
|
|
||||||
const [interviews, setInterviews] = useState<any[]>([]);
|
const [interviews, setInterviews] = useState<any[]>([]);
|
||||||
const [isScheduling, setIsScheduling] = useState(false);
|
const [isScheduling, setIsScheduling] = useState(false);
|
||||||
const [showAssignArchitectureModal, setShowAssignArchitectureModal] = useState(false);
|
const [showAssignArchitectureModal, setShowAssignArchitectureModal] = useState(false);
|
||||||
const [architectureLeadId, setArchitectureLeadId] = useState<string>('');
|
const [architectureLeadId, setArchitectureLeadId] = useState('');
|
||||||
const [isAssigningArchitecture, setIsAssigningArchitecture] = useState(false);
|
const [isAssigningArchitecture, setIsAssigningArchitecture] = useState(false);
|
||||||
const [showArchitectureStatusModal, setShowArchitectureStatusModal] = useState(false);
|
const [showArchitectureStatusModal, setShowArchitectureStatusModal] = useState(false);
|
||||||
const [architectureStatus, setArchitectureStatus] = useState<string>('COMPLETED');
|
const [architectureStatus, setArchitectureStatus] = useState('');
|
||||||
const [architectureRemarks, setArchitectureRemarks] = useState<string>('');
|
const [architectureRemarks, setArchitectureRemarks] = useState('');
|
||||||
const [isUpdatingArchitecture, setIsUpdatingArchitecture] = useState(false);
|
const [isUpdatingArchitecture, setIsUpdatingArchitecture] = useState(false);
|
||||||
const [isAssigningParticipant, setIsAssigningParticipant] = useState(false);
|
const [isAssigningParticipant, setIsAssigningParticipant] = useState(false);
|
||||||
const [documentConfigs, setDocumentConfigs] = useState<any[]>([]);
|
const [documentConfigs, setDocumentConfigs] = useState<any[]>([]);
|
||||||
const [fddAgencies, setFddAgencies] = useState<any[]>([]);
|
const [fddAgencies, setFddAgencies] = useState<any[]>([]);
|
||||||
const [selectedAgencyId, setSelectedAgencyId] = useState<string>('');
|
const [selectedAgencyId, setSelectedAgencyId] = useState('');
|
||||||
const [isAssigningAgency, setIsAssigningAgency] = useState(false);
|
const [isAssigningAgency, setIsAssigningAgency] = useState(false);
|
||||||
const [isApproving, setIsApproving] = useState(false);
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
const [isRejecting, setIsRejecting] = useState(false);
|
const [isRejecting, setIsRejecting] = useState(false);
|
||||||
|
|
||||||
const [ktMatrixScores, setKtMatrixScores] = useState<Record<string, number>>({});
|
const [ktMatrixScores, setKtMatrixScores] = useState<Record<string, number>>({});
|
||||||
const [ktMatrixSelectedValues, setKtMatrixSelectedValues] = useState<Record<string, string>>({});
|
const [ktMatrixSelectedValues, setKtMatrixSelectedValues] = useState<Record<string, string>>({});
|
||||||
const [ktMatrixRemarks, setKtMatrixRemarks] = useState('');
|
const [ktMatrixRemarks, setKtMatrixRemarks] = useState<string>('');
|
||||||
const [isSubmittingKT, setIsSubmittingKT] = useState(false);
|
const [isSubmittingKT, setIsSubmittingKT] = useState(false);
|
||||||
const [selectedInterviewForFeedback, setSelectedInterviewForFeedback] = useState<any>(null);
|
const [selectedInterviewForFeedback, setSelectedInterviewForFeedback] = useState<any>(null);
|
||||||
|
|
||||||
const [showFddFinalizeModal, setShowFddFinalizeModal] = useState(false);
|
const [showFddFinalizeModal, setShowFddFinalizeModal] = useState(false);
|
||||||
const [showFddFlagModal, setShowFddFlagModal] = useState(false);
|
const [showFddFlagModal, setShowFddFlagModal] = useState(false);
|
||||||
const [fddAuditRecommendation, setFddAuditRecommendation] = useState<string>('Recommended');
|
const [fddAuditRecommendation, setFddAuditRecommendation] = useState<string>('Recommended');
|
||||||
const [fddAuditFindings, setFddAuditFindings] = useState<string>('');
|
const [fddAuditFindings, setFddAuditFindings] = useState<string>('');
|
||||||
const [isFinalizingFdd, setIsFinalizingFdd] = useState(false);
|
const [isFinalizingFdd, setIsFinalizingFdd] = useState(false);
|
||||||
const [isFddFlagging, setIsFddFlagging] = useState(false);
|
const [isFddFlagging, setIsFddFlagging] = useState(false);
|
||||||
|
const [level2Feedback, setLevel2Feedback] = useState<any>({});
|
||||||
const [level2Feedback, setLevel2Feedback] = useState({
|
|
||||||
strategicVision: '',
|
|
||||||
managementCapabilities: '',
|
|
||||||
operationalUnderstanding: '',
|
|
||||||
keyStrengths: '',
|
|
||||||
areasOfConcern: '',
|
|
||||||
additionalComments: '',
|
|
||||||
overallScore: '',
|
|
||||||
interviewerName: currentUser?.name || '',
|
|
||||||
interviewDate: getToday(),
|
|
||||||
});
|
|
||||||
const [isSubmittingLevel2, setIsSubmittingLevel2] = useState(false);
|
const [isSubmittingLevel2, setIsSubmittingLevel2] = useState(false);
|
||||||
|
const [level3Feedback, setLevel3Feedback] = useState<any>({});
|
||||||
const [level3Feedback, setLevel3Feedback] = useState({
|
|
||||||
strategicVision: '',
|
|
||||||
managementCapabilities: '',
|
|
||||||
operationalUnderstanding: '',
|
|
||||||
brandAlignment: '',
|
|
||||||
executiveSummary: '',
|
|
||||||
keyStrengths: '',
|
|
||||||
areasOfConcern: '',
|
|
||||||
additionalComments: '',
|
|
||||||
overallScore: '',
|
|
||||||
interviewerName: currentUser?.name || '',
|
|
||||||
interviewDate: getToday(),
|
|
||||||
});
|
|
||||||
const [isSubmittingLevel3, setIsSubmittingLevel3] = useState(false);
|
const [isSubmittingLevel3, setIsSubmittingLevel3] = useState(false);
|
||||||
|
|
||||||
const [selectedEvaluationForView, setSelectedEvaluationForView] = useState<any>(null);
|
const [selectedEvaluationForView, setSelectedEvaluationForView] = useState<any>(null);
|
||||||
const [showFeedbackDetailsModal, setShowFeedbackDetailsModal] = useState(false);
|
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,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
@ -19,7 +28,8 @@ import {
|
|||||||
List,
|
List,
|
||||||
Mail,
|
Mail,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@ -56,10 +66,18 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [states, setStates] = useState<string[]>([]);
|
const [states, setStates] = useState<string[]>([]);
|
||||||
const [locations, setLocations] = useState<string[]>([]);
|
const [locations, setLocations] = useState<string[]>([]);
|
||||||
const [initialFetchDone, setInitialFetchDone] = useState(false);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchApplications();
|
fetchApplications();
|
||||||
|
}, [currentPage, searchQuery, statusFilter, locationFilter, stateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery, statusFilter, locationFilter, stateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchStates();
|
fetchStates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -77,8 +95,17 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
|||||||
const fetchApplications = async () => {
|
const fetchApplications = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await onboardingService.getApplications();
|
const response = await onboardingService.getApplications({
|
||||||
const rawData = response.data || (Array.isArray(response) ? response : []);
|
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
|
// Map backend data to Application interface
|
||||||
const mappedApps: Application[] = rawData.map((app: any) => ({
|
const mappedApps: Application[] = rawData.map((app: any) => ({
|
||||||
@ -128,23 +155,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter applications
|
const filteredApplications = applicationsData;
|
||||||
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 handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@ -369,7 +380,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
|||||||
|
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<Badge variant="outline" className="text-slate-600" data-testid="onboarding-all-apps-pending-badge">
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -377,7 +388,11 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Applications Grid/Table */}
|
{/* 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">
|
<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) => (
|
{filteredApplications.map((app, idx) => (
|
||||||
<div key={app.id} className="relative" data-testid={`onboarding-all-apps-grid-item-${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>
|
</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 */}
|
{/* Shortlist Modal */}
|
||||||
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
||||||
<DialogContent data-testid="onboarding-all-apps-shortlist-modal">
|
<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 { ApplicationDetailsActionModals } from '@/features/onboarding/components/application-details/ApplicationDetailsActionModals';
|
||||||
import { ApplicationDetailsExtendedModals } from '@/features/onboarding/components/application-details/ApplicationDetailsExtendedModals';
|
import { ApplicationDetailsExtendedModals } from '@/features/onboarding/components/application-details/ApplicationDetailsExtendedModals';
|
||||||
import { ApplicationDetailsFddAuditContent } from '@/features/onboarding/components/application-details/ApplicationDetailsFddAuditContent';
|
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 { useApplicationDetailsPermissions } from '@/features/onboarding/hooks/useApplicationDetailsPermissions';
|
||||||
import { useApplicationDetailsUIState } from '@/features/onboarding/hooks/useApplicationDetailsUIState';
|
import { useApplicationDetailsUIState } from '@/features/onboarding/hooks/useApplicationDetailsUIState';
|
||||||
import { useApplicationDetailsFeedbackActions } from '@/features/onboarding/hooks/useApplicationDetailsFeedbackActions';
|
import { useApplicationDetailsFeedbackActions } from '@/features/onboarding/hooks/useApplicationDetailsFeedbackActions';
|
||||||
|
import { useInterviewConfigs } from '@/features/onboarding/hooks/useInterviewConfigs';
|
||||||
import { useApplicationDetailsAdminActions } from '@/features/onboarding/hooks/useApplicationDetailsAdminActions';
|
import { useApplicationDetailsAdminActions } from '@/features/onboarding/hooks/useApplicationDetailsAdminActions';
|
||||||
import { useApplicationDetailsData } from '@/features/onboarding/hooks/useApplicationDetailsData';
|
import { useApplicationDetailsData } from '@/features/onboarding/hooks/useApplicationDetailsData';
|
||||||
import { useApplicationDetailsLocalActions } from '@/features/onboarding/hooks/useApplicationDetailsLocalActions';
|
import { useApplicationDetailsLocalActions } from '@/features/onboarding/hooks/useApplicationDetailsLocalActions';
|
||||||
@ -20,15 +21,13 @@ import { useSelector } from 'react-redux';
|
|||||||
import { RootState } from '@/store';
|
import { RootState } from '@/store';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const ApplicationDetails = () => {
|
export const ApplicationDetails = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||||
const applicationId = id || '';
|
const applicationId = id || '';
|
||||||
const onBack = () => navigate(-1);
|
const onBack = () => navigate(-1);
|
||||||
// const application = mockApplications.find(app => app.id === applicationId);
|
|
||||||
const {
|
const {
|
||||||
application,
|
application,
|
||||||
loading,
|
loading,
|
||||||
@ -119,8 +118,8 @@ export const ApplicationDetails = () => {
|
|||||||
isSubmittingLevel2, setIsSubmittingLevel2,
|
isSubmittingLevel2, setIsSubmittingLevel2,
|
||||||
level3Feedback, setLevel3Feedback,
|
level3Feedback, setLevel3Feedback,
|
||||||
isSubmittingLevel3, setIsSubmittingLevel3,
|
isSubmittingLevel3, setIsSubmittingLevel3,
|
||||||
selectedEvaluationForView, setSelectedEvaluationForView,
|
|
||||||
showFeedbackDetailsModal, setShowFeedbackDetailsModal,
|
showFeedbackDetailsModal, setShowFeedbackDetailsModal,
|
||||||
|
selectedEvaluationForView, setSelectedEvaluationForView,
|
||||||
} = useApplicationDetailsUIState({
|
} = useApplicationDetailsUIState({
|
||||||
currentUser,
|
currentUser,
|
||||||
initialTab: routerLocation.state?.activeTab || 'questionnaire',
|
initialTab: routerLocation.state?.activeTab || 'questionnaire',
|
||||||
@ -158,12 +157,10 @@ export const ApplicationDetails = () => {
|
|||||||
currentUser?.role === 'NBH' || currentUser?.role === 'DD Head' ||
|
currentUser?.role === 'NBH' || currentUser?.role === 'DD Head' ||
|
||||||
currentUser?.roleCode === 'NBH' || currentUser?.roleCode === 'DD_HEAD';
|
currentUser?.roleCode === 'NBH' || currentUser?.roleCode === 'DD_HEAD';
|
||||||
|
|
||||||
// Fetch document configurations
|
|
||||||
// Fetch document configurations
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConfigs = async () => {
|
const fetchConfigs = async () => {
|
||||||
try {
|
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 : []);
|
const configs = res.data || (Array.isArray(res) ? res : []);
|
||||||
setDocumentConfigs(configs);
|
setDocumentConfigs(configs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -171,9 +168,8 @@ export const ApplicationDetails = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchConfigs();
|
fetchConfigs();
|
||||||
}, []);
|
}, [setDocumentConfigs]);
|
||||||
|
|
||||||
// Auto-select valid interview level based on application status when scheduling
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showScheduleModal && application) {
|
if (showScheduleModal && application) {
|
||||||
if (application.status === 'Shortlisted' || application.status === 'Questionnaire Completed') {
|
if (application.status === 'Shortlisted' || application.status === 'Questionnaire Completed') {
|
||||||
@ -184,12 +180,7 @@ export const ApplicationDetails = () => {
|
|||||||
setInterviewType('level3');
|
setInterviewType('level3');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showScheduleModal, application?.status]);
|
}, [showScheduleModal, application?.status, setInterviewType]);
|
||||||
|
|
||||||
// KT Matrix State
|
|
||||||
|
|
||||||
// Payment Details State
|
|
||||||
// Feedback Details Modal State
|
|
||||||
|
|
||||||
const fetchInterviews = async () => {
|
const fetchInterviews = async () => {
|
||||||
if (applicationId) {
|
if (applicationId) {
|
||||||
@ -206,6 +197,12 @@ export const ApplicationDetails = () => {
|
|||||||
fetchInterviews();
|
fetchInterviews();
|
||||||
}, [applicationId]);
|
}, [applicationId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
ktMatrixConfig,
|
||||||
|
level2Config,
|
||||||
|
level3Config,
|
||||||
|
} = useInterviewConfigs();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleKTMatrixChange,
|
handleKTMatrixChange,
|
||||||
calculateKTScore,
|
calculateKTScore,
|
||||||
@ -214,6 +211,9 @@ export const ApplicationDetails = () => {
|
|||||||
handleSubmitLevel2Feedback,
|
handleSubmitLevel2Feedback,
|
||||||
handleLevel3Change,
|
handleLevel3Change,
|
||||||
handleSubmitLevel3Feedback,
|
handleSubmitLevel3Feedback,
|
||||||
|
ktCriteria,
|
||||||
|
l2Fields,
|
||||||
|
l3Fields,
|
||||||
} = useApplicationDetailsFeedbackActions({
|
} = useApplicationDetailsFeedbackActions({
|
||||||
ktMatrixScores,
|
ktMatrixScores,
|
||||||
setKtMatrixScores,
|
setKtMatrixScores,
|
||||||
@ -235,6 +235,9 @@ export const ApplicationDetails = () => {
|
|||||||
currentUser,
|
currentUser,
|
||||||
fetchInterviews,
|
fetchInterviews,
|
||||||
fetchApplication,
|
fetchApplication,
|
||||||
|
ktMatrixConfig,
|
||||||
|
level2Config,
|
||||||
|
level3Config,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -244,7 +247,8 @@ export const ApplicationDetails = () => {
|
|||||||
if (activeTab === 'fdd' && (currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin')) {
|
if (activeTab === 'fdd' && (currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin')) {
|
||||||
fetchFddAgencies();
|
fetchFddAgencies();
|
||||||
}
|
}
|
||||||
}, [activeTab, applicationId]);
|
}, [activeTab, applicationId, refreshDocuments, fetchFddAgencies, currentUser?.role]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleAddInterviewer,
|
handleAddInterviewer,
|
||||||
handleRemoveInterviewer,
|
handleRemoveInterviewer,
|
||||||
@ -318,23 +322,7 @@ export const ApplicationDetails = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
maybeFetchUsersForModal();
|
maybeFetchUsersForModal();
|
||||||
}, [showScheduleModal, showAssignArchitectureModal, showAssignModal, interviewType, application?.participants]);
|
}, [showScheduleModal, showAssignArchitectureModal, showAssignModal, interviewType, application?.participants, maybeFetchUsersForModal]);
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loading && !application) {
|
if (loading && !application) {
|
||||||
return (
|
return (
|
||||||
@ -348,6 +336,14 @@ export const ApplicationDetails = () => {
|
|||||||
return <div className="flex justify-center items-center h-96">Application not found</div>;
|
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 {
|
const {
|
||||||
activeInterviewForUser,
|
activeInterviewForUser,
|
||||||
currentUserEvaluation,
|
currentUserEvaluation,
|
||||||
@ -364,9 +360,6 @@ export const ApplicationDetails = () => {
|
|||||||
eorProgress,
|
eorProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const renderFddAuditContent = () => (
|
const renderFddAuditContent = () => (
|
||||||
<ApplicationDetailsFddAuditContent
|
<ApplicationDetailsFddAuditContent
|
||||||
application={application}
|
application={application}
|
||||||
@ -385,7 +378,6 @@ export const ApplicationDetails = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ApplicationDetailsHeader
|
<ApplicationDetailsHeader
|
||||||
@ -402,10 +394,7 @@ export const ApplicationDetails = () => {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Main Content */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<ApplicantInformationCard
|
<ApplicantInformationCard
|
||||||
application={application}
|
application={application}
|
||||||
@ -423,9 +412,6 @@ export const ApplicationDetails = () => {
|
|||||||
onStatutoryFormChange={setStatutoryForm}
|
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 && (
|
{application.isShortlisted !== false && (
|
||||||
<ApplicationDetailsTabs
|
<ApplicationDetailsTabs
|
||||||
application={application}
|
application={application}
|
||||||
@ -463,7 +449,6 @@ export const ApplicationDetails = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar - Summary and Actions */}
|
|
||||||
<ApplicationDetailsSidebar
|
<ApplicationDetailsSidebar
|
||||||
application={application}
|
application={application}
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
@ -502,131 +487,134 @@ export const ApplicationDetails = () => {
|
|||||||
handleAddParticipant={handleAddParticipant}
|
handleAddParticipant={handleAddParticipant}
|
||||||
isAssigningParticipant={isAssigningParticipant}
|
isAssigningParticipant={isAssigningParticipant}
|
||||||
/>
|
/>
|
||||||
<ApplicationDetailsActionModals
|
|
||||||
application={application}
|
<ApplicationDetailsActionModals
|
||||||
fetchApplication={fetchApplication}
|
application={application}
|
||||||
showApproveModal={showApproveModal}
|
fetchApplication={fetchApplication}
|
||||||
setShowApproveModal={setShowApproveModal}
|
showApproveModal={showApproveModal}
|
||||||
approvalRemark={approvalRemark}
|
setShowApproveModal={setShowApproveModal}
|
||||||
setApprovalRemark={setApprovalRemark}
|
approvalRemark={approvalRemark}
|
||||||
setApprovalFile={setApprovalFile}
|
setApprovalRemark={setApprovalRemark}
|
||||||
isApproving={isApproving}
|
setApprovalFile={setApprovalFile}
|
||||||
handleApprove={handleApprove}
|
isApproving={isApproving}
|
||||||
showOnboardModal={showOnboardModal}
|
handleApprove={handleApprove}
|
||||||
setShowOnboardModal={setShowOnboardModal}
|
showOnboardModal={showOnboardModal}
|
||||||
isOnboarding={isOnboarding}
|
setShowOnboardModal={setShowOnboardModal}
|
||||||
setIsOnboarding={setIsOnboarding}
|
isOnboarding={isOnboarding}
|
||||||
showRejectModal={showRejectModal}
|
setIsOnboarding={setIsOnboarding}
|
||||||
setShowRejectModal={setShowRejectModal}
|
showRejectModal={showRejectModal}
|
||||||
rejectionReason={rejectionReason}
|
setShowRejectModal={setShowRejectModal}
|
||||||
setRejectionReason={setRejectionReason}
|
rejectionReason={rejectionReason}
|
||||||
isRejecting={isRejecting}
|
setRejectionReason={setRejectionReason}
|
||||||
handleReject={handleReject}
|
isRejecting={isRejecting}
|
||||||
showScheduleModal={showScheduleModal}
|
handleReject={handleReject}
|
||||||
setShowScheduleModal={setShowScheduleModal}
|
showScheduleModal={showScheduleModal}
|
||||||
interviewType={interviewType}
|
setShowScheduleModal={setShowScheduleModal}
|
||||||
setInterviewType={setInterviewType}
|
interviewType={interviewType}
|
||||||
interviewMode={interviewMode}
|
setInterviewType={setInterviewType}
|
||||||
setInterviewMode={setInterviewMode}
|
interviewMode={interviewMode}
|
||||||
interviewDate={interviewDate}
|
setInterviewMode={setInterviewMode}
|
||||||
setInterviewDate={setInterviewDate}
|
interviewDate={interviewDate}
|
||||||
meetingLink={meetingLink}
|
setInterviewDate={setInterviewDate}
|
||||||
setMeetingLink={setMeetingLink}
|
meetingLink={meetingLink}
|
||||||
location={location}
|
setMeetingLink={setMeetingLink}
|
||||||
setLocation={setLocation}
|
location={location}
|
||||||
isInterviewCompleted={isInterviewCompleted}
|
setLocation={setLocation}
|
||||||
isInterviewActive={isInterviewActive}
|
isInterviewCompleted={isInterviewCompleted}
|
||||||
users={users}
|
isInterviewActive={isInterviewActive}
|
||||||
selectedInterviewerId={selectedInterviewerId}
|
users={users}
|
||||||
setSelectedInterviewerId={setSelectedInterviewerId}
|
selectedInterviewerId={selectedInterviewerId}
|
||||||
handleAddInterviewer={handleAddInterviewer}
|
setSelectedInterviewerId={setSelectedInterviewerId}
|
||||||
scheduledInterviewParticipants={scheduledInterviewParticipants}
|
handleAddInterviewer={handleAddInterviewer}
|
||||||
handleRemoveInterviewer={handleRemoveInterviewer}
|
scheduledInterviewParticipants={scheduledInterviewParticipants}
|
||||||
isScheduling={isScheduling}
|
handleRemoveInterviewer={handleRemoveInterviewer}
|
||||||
handleScheduleInterview={handleScheduleInterview}
|
isScheduling={isScheduling}
|
||||||
showAssignArchitectureModal={showAssignArchitectureModal}
|
handleScheduleInterview={handleScheduleInterview}
|
||||||
setShowAssignArchitectureModal={setShowAssignArchitectureModal}
|
showAssignArchitectureModal={showAssignArchitectureModal}
|
||||||
architectureLeadId={architectureLeadId}
|
setShowAssignArchitectureModal={setShowAssignArchitectureModal}
|
||||||
setArchitectureLeadId={setArchitectureLeadId}
|
architectureLeadId={architectureLeadId}
|
||||||
isAssigningArchitecture={isAssigningArchitecture}
|
setArchitectureLeadId={setArchitectureLeadId}
|
||||||
handleAssignArchitecture={handleAssignArchitecture}
|
isAssigningArchitecture={isAssigningArchitecture}
|
||||||
showArchitectureStatusModal={showArchitectureStatusModal}
|
handleAssignArchitecture={handleAssignArchitecture}
|
||||||
setShowArchitectureStatusModal={setShowArchitectureStatusModal}
|
showArchitectureStatusModal={showArchitectureStatusModal}
|
||||||
architectureStatus={architectureStatus}
|
setShowArchitectureStatusModal={setShowArchitectureStatusModal}
|
||||||
setArchitectureStatus={setArchitectureStatus}
|
architectureStatus={architectureStatus}
|
||||||
architectureRemarks={architectureRemarks}
|
setArchitectureStatus={setArchitectureStatus}
|
||||||
setArchitectureRemarks={setArchitectureRemarks}
|
architectureRemarks={architectureRemarks}
|
||||||
isUpdatingArchitecture={isUpdatingArchitecture}
|
setArchitectureRemarks={setArchitectureRemarks}
|
||||||
handleUpdateArchitectureStatus={handleUpdateArchitectureStatus}
|
isUpdatingArchitecture={isUpdatingArchitecture}
|
||||||
/>
|
handleUpdateArchitectureStatus={handleUpdateArchitectureStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
<ApplicationDetailsExtendedModals
|
<ApplicationDetailsExtendedModals
|
||||||
application={application}
|
application={application}
|
||||||
KT_MATRIX_CRITERIA={KT_MATRIX_CRITERIA}
|
ktCriteria={ktCriteria}
|
||||||
showKTMatrixModal={showKTMatrixModal}
|
l2Fields={l2Fields}
|
||||||
setShowKTMatrixModal={setShowKTMatrixModal}
|
l3Fields={l3Fields}
|
||||||
ktMatrixSelectedValues={ktMatrixSelectedValues}
|
showKTMatrixModal={showKTMatrixModal}
|
||||||
handleKTMatrixChange={handleKTMatrixChange}
|
setShowKTMatrixModal={setShowKTMatrixModal}
|
||||||
ktMatrixRemarks={ktMatrixRemarks}
|
ktMatrixSelectedValues={ktMatrixSelectedValues}
|
||||||
setKtMatrixRemarks={setKtMatrixRemarks}
|
handleKTMatrixChange={handleKTMatrixChange}
|
||||||
calculateKTScore={calculateKTScore}
|
ktMatrixRemarks={ktMatrixRemarks}
|
||||||
handleSubmitKTMatrix={handleSubmitKTMatrix}
|
setKtMatrixRemarks={setKtMatrixRemarks}
|
||||||
isSubmittingKT={isSubmittingKT}
|
calculateKTScore={calculateKTScore}
|
||||||
showLevel2FeedbackModal={showLevel2FeedbackModal}
|
handleSubmitKTMatrix={handleSubmitKTMatrix}
|
||||||
setShowLevel2FeedbackModal={setShowLevel2FeedbackModal}
|
isSubmittingKT={isSubmittingKT}
|
||||||
level2Feedback={level2Feedback}
|
showLevel2FeedbackModal={showLevel2FeedbackModal}
|
||||||
handleLevel2Change={handleLevel2Change}
|
setShowLevel2FeedbackModal={setShowLevel2FeedbackModal}
|
||||||
handleSubmitLevel2Feedback={handleSubmitLevel2Feedback}
|
level2Feedback={level2Feedback}
|
||||||
isSubmittingLevel2={isSubmittingLevel2}
|
handleLevel2Change={handleLevel2Change}
|
||||||
showFeedbackDetailsModal={showFeedbackDetailsModal}
|
handleSubmitLevel2Feedback={handleSubmitLevel2Feedback}
|
||||||
setShowFeedbackDetailsModal={setShowFeedbackDetailsModal}
|
isSubmittingLevel2={isSubmittingLevel2}
|
||||||
selectedEvaluationForView={selectedEvaluationForView}
|
showFeedbackDetailsModal={showFeedbackDetailsModal}
|
||||||
showLevel3FeedbackModal={showLevel3FeedbackModal}
|
setShowFeedbackDetailsModal={setShowFeedbackDetailsModal}
|
||||||
setShowLevel3FeedbackModal={setShowLevel3FeedbackModal}
|
selectedEvaluationForView={selectedEvaluationForView}
|
||||||
level3Feedback={level3Feedback}
|
showLevel3FeedbackModal={showLevel3FeedbackModal}
|
||||||
handleLevel3Change={handleLevel3Change}
|
setShowLevel3FeedbackModal={setShowLevel3FeedbackModal}
|
||||||
handleSubmitLevel3Feedback={handleSubmitLevel3Feedback}
|
level3Feedback={level3Feedback}
|
||||||
isSubmittingLevel3={isSubmittingLevel3}
|
handleLevel3Change={handleLevel3Change}
|
||||||
showDocumentsModal={showDocumentsModal}
|
handleSubmitLevel3Feedback={handleSubmitLevel3Feedback}
|
||||||
setShowDocumentsModal={setShowDocumentsModal}
|
isSubmittingLevel3={isSubmittingLevel3}
|
||||||
showUploadForm={showUploadForm}
|
showDocumentsModal={showDocumentsModal}
|
||||||
setShowUploadForm={setShowUploadForm}
|
setShowDocumentsModal={setShowDocumentsModal}
|
||||||
selectedStage={selectedStage}
|
showUploadForm={showUploadForm}
|
||||||
getDocumentsForStage={getDocumentsForStage}
|
setShowUploadForm={setShowUploadForm}
|
||||||
setPreviewDoc={setPreviewDoc}
|
selectedStage={selectedStage}
|
||||||
setShowPreviewModal={setShowPreviewModal}
|
getDocumentsForStage={getDocumentsForStage}
|
||||||
flattenedStages={flattenedStages}
|
setPreviewDoc={setPreviewDoc}
|
||||||
setSelectedStage={setSelectedStage}
|
setShowPreviewModal={setShowPreviewModal}
|
||||||
uploadDocType={uploadDocType}
|
flattenedStages={flattenedStages}
|
||||||
setUploadDocType={setUploadDocType}
|
setSelectedStage={setSelectedStage}
|
||||||
setUploadFile={setUploadFile}
|
uploadDocType={uploadDocType}
|
||||||
isUploading={isUploading}
|
setUploadDocType={setUploadDocType}
|
||||||
handleUpload={handleUpload}
|
setUploadFile={setUploadFile}
|
||||||
uploadFile={uploadFile}
|
isUploading={isUploading}
|
||||||
documentConfigs={documentConfigs}
|
handleUpload={handleUpload}
|
||||||
showPreviewModal={showPreviewModal}
|
uploadFile={uploadFile}
|
||||||
previewDoc={previewDoc}
|
documentConfigs={documentConfigs}
|
||||||
showFddFinalizeModal={showFddFinalizeModal}
|
showPreviewModal={showPreviewModal}
|
||||||
setShowFddFinalizeModal={setShowFddFinalizeModal}
|
previewDoc={previewDoc}
|
||||||
currentUser={currentUser}
|
showFddFinalizeModal={showFddFinalizeModal}
|
||||||
fddAuditRecommendation={fddAuditRecommendation}
|
setShowFddFinalizeModal={setShowFddFinalizeModal}
|
||||||
setFddAuditRecommendation={setFddAuditRecommendation}
|
currentUser={currentUser}
|
||||||
fddAuditFindings={fddAuditFindings}
|
fddAuditRecommendation={fddAuditRecommendation}
|
||||||
setFddAuditFindings={setFddAuditFindings}
|
setFddAuditRecommendation={setFddAuditRecommendation}
|
||||||
isFinalizingFdd={isFinalizingFdd}
|
fddAuditFindings={fddAuditFindings}
|
||||||
setIsFinalizingFdd={setIsFinalizingFdd}
|
setFddAuditFindings={setFddAuditFindings}
|
||||||
fetchApplication={fetchApplication}
|
isFinalizingFdd={isFinalizingFdd}
|
||||||
showFddFlagModal={showFddFlagModal}
|
setIsFinalizingFdd={setIsFinalizingFdd}
|
||||||
setShowFddFlagModal={setShowFddFlagModal}
|
fetchApplication={fetchApplication}
|
||||||
isFddFlagging={isFddFlagging}
|
showFddFlagModal={showFddFlagModal}
|
||||||
setIsFddFlagging={setIsFddFlagging}
|
setShowFddFlagModal={setShowFddFlagModal}
|
||||||
showFirmTypeModal={showFirmTypeModal}
|
isFddFlagging={isFddFlagging}
|
||||||
setShowFirmTypeModal={setShowFirmTypeModal}
|
setIsFddFlagging={setIsFddFlagging}
|
||||||
tempFirmType={tempFirmType}
|
showFirmTypeModal={showFirmTypeModal}
|
||||||
setTempFirmType={setTempFirmType}
|
setShowFirmTypeModal={setShowFirmTypeModal}
|
||||||
updatingFirmType={updatingFirmType}
|
tempFirmType={tempFirmType}
|
||||||
handleUpdateFirmType={handleUpdateFirmType}
|
setTempFirmType={setTempFirmType}
|
||||||
/>
|
updatingFirmType={updatingFirmType}
|
||||||
|
handleUpdateFirmType={handleUpdateFirmType}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -31,6 +31,15 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from '@/store';
|
import { RootState } from '@/store';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
interface ApplicationsPageProps {
|
interface ApplicationsPageProps {
|
||||||
onViewDetails: (id: string) => void;
|
onViewDetails: (id: string) => void;
|
||||||
@ -51,91 +60,84 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
|||||||
// Real Data Integration
|
// Real Data Integration
|
||||||
const [applications, setApplications] = useState<Application[]>([]);
|
const [applications, setApplications] = useState<Application[]>([]);
|
||||||
const [locations, setLocations] = useState<string[]>([]);
|
const [locations, setLocations] = useState<string[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
fetchApplications();
|
||||||
}, []);
|
}, [currentPage, locationFilter, statusFilter, searchQuery]);
|
||||||
|
|
||||||
// Filter and sort applications - ONLY show shortlisted applications
|
const fetchApplications = async () => {
|
||||||
// Exclude specific applications (APP-005, APP-006, APP-007, APP-008) from Dealership Requests page
|
try {
|
||||||
const excludedApplicationIds = ['5', '6', '7', '8'];
|
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 applicationsData = response.data || [];
|
||||||
|
setPaginationMeta(response.meta);
|
||||||
|
|
||||||
const filteredApplications = applications
|
// Map backend data to frontend Application interface
|
||||||
.filter((app) => {
|
const mappedApps = applicationsData.map((app: any) => ({
|
||||||
const matchesSearch =
|
id: app.id,
|
||||||
app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
registrationNumber: app.applicationId || 'N/A',
|
||||||
app.registrationNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
name: app.applicantName,
|
||||||
app.email.toLowerCase().includes(searchQuery.toLowerCase());
|
email: app.email,
|
||||||
|
phone: app.phone,
|
||||||
const matchesLocation = locationFilter === 'all' || app.preferredLocation === locationFilter;
|
age: app.age,
|
||||||
const matchesStatus = statusFilter === 'all' || app.status === statusFilter;
|
education: app.education,
|
||||||
const isShortlisted = app.isShortlisted === true;
|
residentialAddress: app.address || app.city || '',
|
||||||
const isNotQuestionnaireStage = !['Questionnaire Pending', 'Questionnaire Completed', 'Submitted'].includes(app.status);
|
businessAddress: app.address || '',
|
||||||
const notExcluded = !excludedApplicationIds.includes(app.id);
|
preferredLocation: app.preferredLocation,
|
||||||
|
state: app.state,
|
||||||
// New Filter: My Assignments
|
ownsBike: app.ownRoyalEnfield === 'yes',
|
||||||
const matchesAssignment = !showMyAssignments || ((app as any).assignedTo === currentUser?.id);
|
pastExperience: app.experienceYears ? `${app.experienceYears} years` : (app.description || ''),
|
||||||
|
status: app.overallStatus as ApplicationStatus,
|
||||||
return matchesSearch && matchesLocation && matchesStatus && isShortlisted && isNotQuestionnaireStage && notExcluded && matchesAssignment;
|
questionnaireMarks: 0,
|
||||||
})
|
rank: 0,
|
||||||
.sort((a, b) => {
|
totalApplicantsAtLocation: 0,
|
||||||
if (sortBy === 'date') {
|
submissionDate: app.createdAt,
|
||||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
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);
|
||||||
|
|
||||||
|
// 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) => {
|
const toggleSelection = (id: string) => {
|
||||||
setSelectedIds(prev =>
|
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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -300,7 +302,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-12">
|
<TableHead className="w-12">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.length === filteredApplications.length}
|
checked={selectedIds.length === filteredApplications.length && filteredApplications.length > 0}
|
||||||
onCheckedChange={toggleSelectAll}
|
onCheckedChange={toggleSelectAll}
|
||||||
data-testid="onboarding-applications-header-checkbox"
|
data-testid="onboarding-applications-header-checkbox"
|
||||||
/>
|
/>
|
||||||
@ -359,6 +361,57 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showNewApplicationModal} onOpenChange={setShowNewApplicationModal}>
|
<Dialog open={showNewApplicationModal} onOpenChange={setShowNewApplicationModal}>
|
||||||
|
|||||||
@ -36,8 +36,8 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
|
|||||||
const fetchApplications = async () => {
|
const fetchApplications = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await onboardingService.getApplications();
|
const response = await onboardingService.getApplications();
|
||||||
setApplications(data);
|
setApplications(response.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch error:', error);
|
console.error('Fetch error:', error);
|
||||||
toast.error('Failed to fetch applications');
|
toast.error('Failed to fetch applications');
|
||||||
|
|||||||
@ -15,9 +15,20 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
Database,
|
Database,
|
||||||
Loader2
|
Loader2,
|
||||||
|
CheckSquare,
|
||||||
|
Calendar
|
||||||
} from 'lucide-react';
|
} 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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -37,15 +48,72 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [locationFilter, setLocationFilter] = useState<string>('all');
|
const [locationFilter, setLocationFilter] = useState<string>('all');
|
||||||
const [stateFilter, setStateFilter] = 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
|
// Real data integration
|
||||||
const [applicationsData, setApplicationsData] = useState<Application[]>([]);
|
const [applicationsData, setApplicationsData] = useState<Application[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [isGlobalLoading, setIsGlobalLoading] = useState(true);
|
||||||
const [states, setStates] = useState<string[]>([]);
|
const [states, setStates] = useState<string[]>([]);
|
||||||
const [locations, setLocations] = 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(() => {
|
useEffect(() => {
|
||||||
fetchApplications();
|
fetchApplications();
|
||||||
|
}, [fromDate, toDate, searchQuery, currentPage, locationFilter, stateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [fromDate, toDate, searchQuery, locationFilter, stateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchStates();
|
fetchStates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -62,9 +130,20 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
|||||||
|
|
||||||
const fetchApplications = async () => {
|
const fetchApplications = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setIsGlobalLoading(true);
|
||||||
const response = await onboardingService.getApplications();
|
const response = await onboardingService.getApplications({
|
||||||
const rawData = response.data || (Array.isArray(response) ? response : []);
|
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
|
// Map backend data to Application interface
|
||||||
const mappedApps: Application[] = rawData.map((app: any) => ({
|
const mappedApps: Application[] = rawData.map((app: any) => ({
|
||||||
@ -110,27 +189,11 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
|||||||
console.error('Failed to fetch applications:', error);
|
console.error('Failed to fetch applications:', error);
|
||||||
toast.error('Failed to load non-opportunity requests');
|
toast.error('Failed to load non-opportunity requests');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setIsGlobalLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter non-opportunity leads - These are lead generation submissions
|
const filteredLeads = applicationsData;
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -148,7 +211,7 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-600">Total Leads</p>
|
<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>
|
||||||
<div className="p-3 bg-blue-100 rounded-lg">
|
<div className="p-3 bg-blue-100 rounded-lg">
|
||||||
<Database className="w-6 h-6 text-blue-600" />
|
<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">
|
<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-slate-600">Unique Locations</p>
|
||||||
<p className="text-2xl text-slate-900 mt-1">
|
<p className="text-2xl text-slate-900 mt-1">
|
||||||
{new Set(filteredLeads.map(app => app.preferredLocation)).size}
|
{paginationMeta?.stats?.uniqueLocations || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-4" data-testid="onboarding-non-opps-stat-exp">
|
<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-slate-600">With Experience</p>
|
||||||
<p className="text-2xl text-amber-600 mt-1">
|
<p className="text-2xl text-amber-600 mt-1">
|
||||||
{filteredLeads.filter(app => app.pastExperience && app.pastExperience !== 'No').length}
|
{paginationMeta?.stats?.withExperience || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -183,6 +246,32 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<Select value={locationFilter} onValueChange={setLocationFilter}>
|
||||||
<SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-location-select">
|
<SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-location-select">
|
||||||
<SelectValue placeholder="All Locations" />
|
<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">
|
<Button variant="outline" size="icon" data-testid="onboarding-non-opps-export-btn">
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Lead Generation Table */}
|
{/* Lead Generation Table */}
|
||||||
@ -217,6 +321,12 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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-name">Name</TableHead>
|
||||||
<TableHead data-testid="onboarding-non-opps-th-phone">Phone</TableHead>
|
<TableHead data-testid="onboarding-non-opps-th-phone">Phone</TableHead>
|
||||||
<TableHead data-testid="onboarding-non-opps-th-email">Email</TableHead>
|
<TableHead data-testid="onboarding-non-opps-th-email">Email</TableHead>
|
||||||
@ -230,52 +340,126 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredLeads.map((lead, idx) => (
|
{isGlobalLoading ? (
|
||||||
<TableRow key={lead.id} data-testid={`onboarding-non-opps-row-${idx}`}>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell colSpan={11} className="text-center py-20">
|
||||||
<div>
|
<Loader2 className="w-8 h-8 mx-auto animate-spin text-amber-600 mb-2" />
|
||||||
<p className="text-slate-900" data-testid={`onboarding-non-opps-name-${idx}`}>{lead.name}</p>
|
<p className="text-slate-500 text-sm">Loading applications...</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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
) : filteredLeads.length === 0 ? (
|
||||||
{filteredLeads.length === 0 && (
|
|
||||||
<TableRow>
|
<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" />
|
<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-lg mb-2">No lead generation data found</p>
|
||||||
<p className="text-sm">Try adjusting your filters</p>
|
<p className="text-sm">Try adjusting your filters</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
|||||||
import { ApplicationStatus, Application } from '@/lib/mock-data';
|
import { ApplicationStatus, Application } from '@/lib/mock-data';
|
||||||
import { masterService } from '@/services/master.service';
|
import { masterService } from '@/services/master.service';
|
||||||
import { onboardingService } from '@/services/onboarding.service';
|
import { onboardingService } from '@/services/onboarding.service';
|
||||||
import { adminService } from '@/services/admin.service';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@ -12,6 +11,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
@ -21,8 +29,8 @@ import {
|
|||||||
List,
|
List,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
X,
|
Calendar,
|
||||||
User as UserIcon
|
ArrowUpDown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -41,41 +49,28 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { formatDateTime } from '@/components/ui/utils';
|
import { formatDateTime } from '@/components/ui/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ApplicationCard } from '@/features/onboarding/components/ApplicationCard';
|
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 {
|
interface OpportunityRequestsPageProps {
|
||||||
onViewDetails: (id: string) => void;
|
onViewDetails: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
fullName: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPageProps) {
|
export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPageProps) {
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'table'>('table');
|
const [viewMode, setViewMode] = useState<'grid' | 'table'>('table');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [locationFilter, setLocationFilter] = useState<string>('all');
|
const [locationFilter, setLocationFilter] = useState<string>('all');
|
||||||
const [stateFilter, setStateFilter] = 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 [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [showShortlistModal, setShowShortlistModal] = useState(false);
|
const [showShortlistModal, setShowShortlistModal] = useState(false);
|
||||||
const [shortlistRemark, setShortlistRemark] = useState('');
|
const [shortlistRemark, setShortlistRemark] = useState('');
|
||||||
|
|
||||||
// Assignee Selection
|
// Assignee Selection
|
||||||
const [selectedAssignees, setSelectedAssignees] = useState<User[]>([]);
|
|
||||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
|
||||||
const [openUserSelect, setOpenUserSelect] = useState(false);
|
|
||||||
const [states, setStates] = useState<string[]>([]);
|
const [states, setStates] = useState<string[]>([]);
|
||||||
const [locations, setLocations] = useState<string[]>([]);
|
const [locations, setLocations] = useState<string[]>([]);
|
||||||
|
|
||||||
@ -85,7 +80,13 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchApplications();
|
fetchApplications();
|
||||||
fetchUsers();
|
}, [fromDate, toDate, statusFilter, searchQuery, currentPage, locationFilter, stateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [fromDate, toDate, statusFilter, searchQuery, locationFilter, stateFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchStates();
|
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 () => {
|
const fetchApplications = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await onboardingService.getApplications();
|
const opportunityStatuses = ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'];
|
||||||
const rawData = response.data || (Array.isArray(response) ? response : []);
|
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
|
// Map backend data to Application interface
|
||||||
const mappedApps: Application[] = rawData.map((app: any) => ({
|
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
|
// Shows applications shortlisted by DD but NOT yet shortlisted by DD Lead
|
||||||
// IMPORTANT: Only shows applications in early stages (before they enter full workflow)
|
// IMPORTANT: Only shows applications in early stages (before they enter full workflow)
|
||||||
// UPDATED LOGIC: Opportunities start at 'Questionnaire Pending'. 'Submitted' means Non-Opportunity.
|
// UPDATED LOGIC: Opportunities start at 'Questionnaire Pending'. 'Submitted' means Non-Opportunity.
|
||||||
const filteredApplications = applicationsData.filter((app) => {
|
const filteredApplications = applicationsData.sort((a, b) => {
|
||||||
// Only show applications that are:
|
if (sortBy === 'score-desc') return (b.questionnaireMarks || 0) - (a.questionnaireMarks || 0);
|
||||||
// 1. Not Shortlisted by DD Lead yet (ddLeadShortlisted !== true) - waiting for action
|
if (sortBy === 'score-asc') return (a.questionnaireMarks || 0) - (b.questionnaireMarks || 0);
|
||||||
const waitingForDDLead = !(app as any).ddLeadShortlisted;
|
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();
|
||||||
// Only show applications with Opportunity statuses
|
return 0;
|
||||||
// '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 handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
@ -220,16 +209,9 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmShortlist = async () => {
|
const confirmShortlist = async () => {
|
||||||
if (selectedAssignees.length === 0) {
|
|
||||||
toast.error('Please assign at least one user');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assignedUserIds = selectedAssignees.map(u => u.id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call Backend API
|
// Call Backend API - assignedTo is now empty as it's handled automatically
|
||||||
const response = await onboardingService.shortlistApplications(selectedIds, assignedUserIds, shortlistRemark);
|
const response = await onboardingService.shortlistApplications(selectedIds, [], shortlistRemark);
|
||||||
|
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
// Update local state and show success only if API succeeded
|
// Update local state and show success only if API succeeded
|
||||||
@ -237,8 +219,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
if (selectedIds.includes(app.id)) {
|
if (selectedIds.includes(app.id)) {
|
||||||
return {
|
return {
|
||||||
...app,
|
...app,
|
||||||
ddLeadShortlisted: true,
|
ddLeadShortlisted: true
|
||||||
assignedTo: assignedUserIds[0] // Optimistically update with first assignee
|
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
return app;
|
return app;
|
||||||
@ -248,9 +229,8 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setShowShortlistModal(false);
|
setShowShortlistModal(false);
|
||||||
setShortlistRemark('');
|
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 {
|
} else {
|
||||||
throw new Error(response?.message || 'Failed to process shortlisting');
|
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
|
// For Opportunity Requests, only show early-stage statuses
|
||||||
// These applications haven't entered the full dealership approval workflow yet
|
// These applications haven't entered the full dealership approval workflow yet
|
||||||
const statusOptions: ApplicationStatus[] = [
|
const statusOptions: ApplicationStatus[] = [
|
||||||
'Submitted',
|
|
||||||
'Questionnaire Pending',
|
'Questionnaire Pending',
|
||||||
'Questionnaire Completed'
|
'Questionnaire Completed',
|
||||||
|
'Shortlisted'
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusColor = (status: ApplicationStatus) => {
|
const getStatusColor = (status: ApplicationStatus) => {
|
||||||
@ -476,6 +456,47 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
@ -534,7 +555,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
|
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<Badge variant="outline" className="text-slate-600" data-testid="onboarding-opp-requests-pending-count">
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -653,6 +674,61 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -660,80 +736,12 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
|
||||||
<DialogContent className="overflow-visible" data-testid="onboarding-opp-requests-shortlist-modal">
|
<DialogContent className="overflow-visible" data-testid="onboarding-opp-requests-shortlist-modal">
|
||||||
<DialogHeader>
|
<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>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<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>
|
<div>
|
||||||
<Label>Shortlisting Remark (Optional)</Label>
|
<Label>Shortlisting Remark (Optional)</Label>
|
||||||
@ -753,7 +761,6 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowShortlistModal(false);
|
setShowShortlistModal(false);
|
||||||
setSelectedAssignees([]);
|
|
||||||
setShortlistRemark('');
|
setShortlistRemark('');
|
||||||
}}
|
}}
|
||||||
data-testid="onboarding-opp-requests-shortlist-cancel-btn"
|
data-testid="onboarding-opp-requests-shortlist-cancel-btn"
|
||||||
|
|||||||
@ -23,8 +23,10 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Clock as ClockIcon,
|
Clock as ClockIcon,
|
||||||
Activity,
|
Activity,
|
||||||
|
UserX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/components/ui/utils';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -125,6 +127,8 @@ interface ParticipantUI {
|
|||||||
color: string;
|
color: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
isOnline?: boolean;
|
isOnline?: boolean;
|
||||||
|
recordId: string;
|
||||||
|
revokedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKEND_URL = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
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 [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
const [participantSearch, setParticipantSearch] = useState('');
|
const [participantSearch, setParticipantSearch] = useState('');
|
||||||
const [messageSearch, setMessageSearch] = 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 { socket } = useSocket();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -221,20 +228,23 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
externalParticipants.forEach((p: any) => {
|
externalParticipants.forEach((p: any) => {
|
||||||
const id = p.user?.id || p.userId || p.id || '';
|
const userId = p.user?.id || p.userId || '';
|
||||||
if (id && !seenIds.has(id)) {
|
const recordId = p.id;
|
||||||
seenIds.add(id);
|
if (userId && !seenIds.has(userId)) {
|
||||||
|
seenIds.add(userId);
|
||||||
const name = p.user?.fullName || p.user?.name || p.fullName || p.name || 'Unknown User';
|
const name = p.user?.fullName || p.user?.name || p.fullName || p.name || 'Unknown User';
|
||||||
const email = p.user?.email || p.email || '';
|
const email = p.user?.email || p.email || '';
|
||||||
const role = p.user?.roleCode || p.roleCode || p.user?.role || p.role || 'Participant';
|
const role = p.user?.roleCode || p.roleCode || p.user?.role || p.role || 'Participant';
|
||||||
participantsList.push({
|
participantsList.push({
|
||||||
id,
|
id: userId,
|
||||||
|
recordId,
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
initials: getInitials(name),
|
initials: getInitials(name),
|
||||||
color: getAvatarColor(name),
|
color: getAvatarColor(name),
|
||||||
role,
|
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) => {
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
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">
|
<div className="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar">
|
||||||
{participantsList
|
{participantsList
|
||||||
.filter(p => p.name.toLowerCase().includes(participantSearch.toLowerCase()) || p.role?.toLowerCase().includes(participantSearch.toLowerCase()))
|
.filter(p => p.name.toLowerCase().includes(participantSearch.toLowerCase()) || p.role?.toLowerCase().includes(participantSearch.toLowerCase()))
|
||||||
.map((participant) => (
|
.map((participant) => {
|
||||||
<div
|
const isRevoked = !!participant.revokedAt;
|
||||||
key={participant.id}
|
const canRevoke = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(currentUser?.roleCode || '') && !isRevoked && participant.id !== currentUser?.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"
|
|
||||||
>
|
return (
|
||||||
<div className="relative">
|
<div
|
||||||
<Avatar className="w-10 h-10 ring-2 ring-transparent group-hover:ring-blue-100 transition-all">
|
key={participant.id}
|
||||||
<AvatarFallback className={`${participant.color} text-white text-xs font-bold`}>
|
className={cn(
|
||||||
{participant.initials}
|
"group flex items-start gap-3 p-3 rounded-xl transition-all cursor-default border border-transparent",
|
||||||
</AvatarFallback>
|
isRevoked ? "opacity-50 bg-slate-100 grayscale-[0.5]" : "hover:bg-white hover:shadow-sm hover:border-slate-100"
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="relative">
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
<Avatar className={cn(
|
||||||
<p className="text-sm font-semibold text-slate-900 truncate">{participant.name}</p>
|
"w-10 h-10 ring-2 ring-transparent transition-all",
|
||||||
{participant.id === currentUser?.id && (
|
!isRevoked && "group-hover:ring-blue-100"
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1 border-blue-200 text-blue-600 bg-blue-50">You</Badge>
|
)}>
|
||||||
|
<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>
|
</div>
|
||||||
<p className="text-[11px] text-slate-500 font-medium uppercase tracking-wider mb-1">
|
<div className="flex-1 min-w-0">
|
||||||
{participant.role}
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
</p>
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<p className="text-[11px] text-slate-400 truncate italic">
|
<p className={cn("text-sm font-semibold truncate", isRevoked ? "text-slate-500 line-through" : "text-slate-900")}>
|
||||||
{participant.email}
|
{participant.name}
|
||||||
</p>
|
</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>
|
</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 && (
|
{participantsList.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center opacity-50">
|
<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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,15 @@ import { User } from '@/lib/mock-data';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { API } from '@/api/API';
|
import { API } from '@/api/API';
|
||||||
import { formatDateTime } from '@/components/ui/utils';
|
import { formatDateTime } from '@/components/ui/utils';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
interface RelocationRequestPageProps {
|
interface RelocationRequestPageProps {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
@ -35,6 +44,10 @@ const getStatusColor = (status: string) => {
|
|||||||
export function RelocationRequestPage({ currentUser, onViewDetails }: RelocationRequestPageProps) {
|
export function RelocationRequestPage({ currentUser, onViewDetails }: RelocationRequestPageProps) {
|
||||||
const [requests, setRequests] = useState<any[]>([]);
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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)
|
// Relocation Creation State (for Super Admin)
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@ -73,11 +86,19 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRequests();
|
fetchRequests();
|
||||||
|
}, [currentPage, activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
fetchOutlets();
|
fetchOutlets();
|
||||||
fetchMasterData();
|
fetchMasterData();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
const handleTabChange = (val: string) => {
|
||||||
|
setActiveTab(val);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchOutlets = async () => {
|
const fetchOutlets = async () => {
|
||||||
try {
|
try {
|
||||||
@ -191,9 +212,14 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
|||||||
const fetchRequests = async () => {
|
const fetchRequests = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
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) {
|
if (response.data.success) {
|
||||||
setRequests(response.data.requests);
|
setRequests(response.data.requests);
|
||||||
|
setPaginationMeta(response.meta);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch relocation requests error:', error);
|
console.error('Fetch relocation requests error:', error);
|
||||||
@ -460,11 +486,11 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="all" className="w-full">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
<TabsTrigger value="all">All Requests</TabsTrigger>
|
<TabsTrigger value="all">All Requests</TabsTrigger>
|
||||||
<TabsTrigger value="pending">Pending Review</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>
|
<TabsTrigger value="completed">Completed</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@ -755,6 +781,56 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,15 @@ import { API } from '@/api/API';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { User as UserType } from '@/lib/mock-data';
|
import { User as UserType } from '@/lib/mock-data';
|
||||||
import { formatDateTime } from '@/components/ui/utils';
|
import { formatDateTime } from '@/components/ui/utils';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
interface ResignationPageProps {
|
interface ResignationPageProps {
|
||||||
currentUser: UserType | null;
|
currentUser: UserType | null;
|
||||||
@ -24,14 +33,23 @@ const getStatusColor = (status: string) => {
|
|||||||
export function ResignationPage({ currentUser, onViewDetails }: ResignationPageProps) {
|
export function ResignationPage({ currentUser, onViewDetails }: ResignationPageProps) {
|
||||||
const [resignations, setResignations] = useState<any[]>([]);
|
const [resignations, setResignations] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 () => {
|
const fetchResignations = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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;
|
const data = response.data as any;
|
||||||
if (data?.success) {
|
if (data?.success) {
|
||||||
setResignations(data.resignations.rows || data.resignations);
|
setResignations(data.requests || data.resignations?.rows || data.resignations || []);
|
||||||
|
setPaginationMeta(data.meta);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching resignations:', error);
|
console.error('Error fetching resignations:', error);
|
||||||
@ -43,44 +61,15 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchResignations();
|
fetchResignations();
|
||||||
}, []);
|
}, [currentPage, statusTab]);
|
||||||
|
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setStatusTab(value);
|
||||||
// Helper function to check if request is at current user's level
|
setCurrentPage(1);
|
||||||
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 openRequests = resignations.filter(req =>
|
const openRequests = statusTab === 'open' ? resignations : [];
|
||||||
!req.status.includes('Completed') &&
|
const completedRequests = statusTab === 'completed' ? resignations : [];
|
||||||
!req.status.includes('Closed') &&
|
|
||||||
!req.status.includes('Rejected') &&
|
|
||||||
isRequestAtMyLevel(req)
|
|
||||||
);
|
|
||||||
|
|
||||||
const completedRequests = resignations.filter(req =>
|
|
||||||
req.status.includes('Completed') ||
|
|
||||||
req.status.includes('Closed')
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -89,7 +78,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>All Requests</CardDescription>
|
<CardDescription>All Requests</CardDescription>
|
||||||
<CardTitle className="text-3xl">{resignations.length}</CardTitle>
|
<CardTitle className="text-3xl">{paginationMeta?.total || 0}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-slate-600">Total Requests</p>
|
<p className="text-slate-600">Total Requests</p>
|
||||||
@ -99,7 +88,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Open</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-slate-600">Requires Your Action</p>
|
<p className="text-slate-600">Requires Your Action</p>
|
||||||
@ -109,7 +98,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Completed</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-slate-600">Finalized</p>
|
<p className="text-slate-600">Finalized</p>
|
||||||
@ -134,7 +123,7 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="all" className="w-full">
|
<Tabs value={statusTab} onValueChange={handleTabChange} className="w-full">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">All Requests</TabsTrigger>
|
<TabsTrigger value="all">All Requests</TabsTrigger>
|
||||||
<TabsTrigger value="open">Open</TabsTrigger>
|
<TabsTrigger value="open">Open</TabsTrigger>
|
||||||
@ -142,73 +131,125 @@ export function ResignationPage({ currentUser, onViewDetails }: ResignationPageP
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="all" className="mt-6">
|
<TabsContent value="all" className="mt-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 text-center py-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12">Loading requests...</div>
|
<div className="text-center py-12">Loading requests...</div>
|
||||||
) : resignations.length > 0 ? (
|
) : resignations.length > 0 ? (
|
||||||
resignations.map((request) => (
|
<>
|
||||||
<Card key={request.id} className="border-slate-200">
|
{resignations.map((request) => (
|
||||||
<CardContent className="pt-6">
|
<Card key={request.id} className="border-slate-200">
|
||||||
<div className="flex items-start justify-between">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-start gap-4 flex-1">
|
<div className="flex items-start justify-between">
|
||||||
<div className="p-3 bg-amber-100 rounded-lg">
|
<div className="flex items-start gap-4 flex-1">
|
||||||
<FileText className="w-6 h-6 text-amber-600" />
|
<div className="p-3 bg-amber-100 rounded-lg">
|
||||||
</div>
|
<FileText className="w-6 h-6 text-amber-600" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
<div className="flex-1 text-left">
|
||||||
<div>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<p className="text-slate-600">Dealer Name</p>
|
<h3 className="text-lg">{request.resignationId}</h3>
|
||||||
<p>{request.dealer?.dealerProfile?.businessName || request.outlet?.name || 'N/A'}</p>
|
<Badge className={getStatusColor(request.status)}>
|
||||||
|
{request.status}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<p className="text-slate-600">Dealer Code</p>
|
<div>
|
||||||
<p>{request.dealer?.dealerProfile?.dealerCode?.dealerCode || request.outlet?.code || 'N/A'}</p>
|
<p className="text-slate-600">Dealer Name</p>
|
||||||
</div>
|
<p>{request.dealer?.dealerProfile?.businessName || request.outlet?.name || 'N/A'}</p>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-slate-600">Location</p>
|
<div>
|
||||||
<p>{request.dealer?.dealerProfile?.registeredAddress || (request.outlet?.city && request.outlet?.state ? `${request.outlet.city}, ${request.outlet.state}` : 'N/A')}</p>
|
<p className="text-slate-600">Dealer Code</p>
|
||||||
</div>
|
<p>{request.dealer?.dealerProfile?.dealerCode?.dealerCode || request.outlet?.code || 'N/A'}</p>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-slate-600">Type</p>
|
<div>
|
||||||
<p>{request.resignationType}</p>
|
<p className="text-slate-600">Location</p>
|
||||||
</div>
|
<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">Reason</p>
|
<div>
|
||||||
<p className="truncate max-w-[200px]">{request.reason}</p>
|
<p className="text-slate-600">Type</p>
|
||||||
</div>
|
<p>{request.resignationType}</p>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-slate-600">Current Stage</p>
|
<div>
|
||||||
<p>{request.currentStage}</p>
|
<p className="text-slate-600">Reason</p>
|
||||||
</div>
|
<p className="truncate max-w-[200px]">{request.reason}</p>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-slate-600">Submitted On</p>
|
<div>
|
||||||
<div className="flex items-center gap-1">
|
<p className="text-slate-600">Current Stage</p>
|
||||||
<Calendar className="w-4 h-4 text-slate-500" />
|
<p>{request.currentStage}</p>
|
||||||
<p>{formatDateTime(request.submittedOn)}</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>
|
||||||
</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>
|
</div>
|
||||||
<Button
|
</CardContent>
|
||||||
size="sm"
|
</Card>
|
||||||
variant="outline"
|
))}
|
||||||
onClick={() => onViewDetails(request.id)}
|
|
||||||
className="ml-4"
|
{paginationMeta && paginationMeta.totalPages > 1 && (
|
||||||
>
|
<div className="py-4 border-t flex justify-center">
|
||||||
<Eye className="w-4 h-4 mr-2" />
|
<Pagination>
|
||||||
View Details
|
<PaginationContent>
|
||||||
</Button>
|
<PaginationItem>
|
||||||
</div>
|
<PaginationPrevious
|
||||||
</CardContent>
|
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||||
</Card>
|
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">
|
<div className="text-center py-12 text-slate-500">
|
||||||
<p>No resignation requests found</p>
|
<p>No resignation requests found</p>
|
||||||
|
|||||||
@ -12,6 +12,15 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { API } from '@/api/API';
|
import { API } from '@/api/API';
|
||||||
import { formatDateTime } from '@/components/ui/utils';
|
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 { User } from '@/lib/mock-data';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -51,6 +60,10 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
const [autoFilledData, setAutoFilledData] = useState<any>(null);
|
const [autoFilledData, setAutoFilledData] = useState<any>(null);
|
||||||
const [terminations, setTerminations] = useState<any[]>([]);
|
const [terminations, setTerminations] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
terminationCategory: '',
|
terminationCategory: '',
|
||||||
reason: '',
|
reason: '',
|
||||||
@ -62,10 +75,15 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
const fetchTerminations = async () => {
|
const fetchTerminations = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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;
|
const data = response.data as any;
|
||||||
if (data?.success) {
|
if (data?.success) {
|
||||||
setTerminations(data.terminations);
|
setTerminations(data.terminations);
|
||||||
|
setPaginationMeta(data.meta);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching terminations:', error);
|
console.error('Error fetching terminations:', error);
|
||||||
@ -77,7 +95,12 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTerminations();
|
fetchTerminations();
|
||||||
}, []);
|
}, [currentPage, activeTab]);
|
||||||
|
|
||||||
|
const handleTabChange = (val: string) => {
|
||||||
|
setActiveTab(val);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDialogOpen || !isDDLead) return;
|
if (!isDialogOpen || !isDDLead) return;
|
||||||
@ -233,45 +256,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
|
|
||||||
const isDDLead = currentUser?.role === 'DD Lead';
|
const isDDLead = currentUser?.role === 'DD Lead';
|
||||||
|
|
||||||
// Helper function to check if request is at current user's level
|
// Map terminations to tab-specific views (already filtered by backend, but need variables for render)
|
||||||
const isRequestAtMyLevel = (request: any) => {
|
const openRequests = activeTab === 'open' || activeTab === 'all' ? terminations : [];
|
||||||
if (!currentUser) return false;
|
const completedRequests = activeTab === 'completed' || activeTab === 'all' ? terminations : [];
|
||||||
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')
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -289,7 +276,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>All Cases</CardDescription>
|
<CardDescription>All Cases</CardDescription>
|
||||||
<CardTitle className="text-3xl">{terminations.length}</CardTitle>
|
<CardTitle className="text-3xl">{paginationMeta?.total || 0}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-slate-600">Total Cases</p>
|
<p className="text-slate-600">Total Cases</p>
|
||||||
@ -299,7 +286,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Open</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-slate-600">Requires Your Action</p>
|
<p className="text-slate-600">Requires Your Action</p>
|
||||||
@ -309,7 +296,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Completed</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-slate-600">Finalized</p>
|
<p className="text-slate-600">Finalized</p>
|
||||||
@ -485,7 +472,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Tabs defaultValue="all" className="w-full">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">All Cases</TabsTrigger>
|
<TabsTrigger value="all">All Cases</TabsTrigger>
|
||||||
<TabsTrigger value="open">Open</TabsTrigger>
|
<TabsTrigger value="open">Open</TabsTrigger>
|
||||||
@ -692,6 +679,56 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -199,7 +199,7 @@ export const useMasterData = () => {
|
|||||||
}
|
}
|
||||||
}, [dispatch]);
|
}, [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 {
|
try {
|
||||||
dispatch(setAreasLoading(true));
|
dispatch(setAreasLoading(true));
|
||||||
const res = await masterService.getAreas(params) as any;
|
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');
|
if (!response.ok) throw new Error(response.data?.message || 'Failed to submit application');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
getApplications: async () => {
|
getApplications: async (params?: any) => {
|
||||||
const response: any = await API.getApplications();
|
const response: any = await API.getApplications(params);
|
||||||
if (!response.ok) throw new Error(response.data?.message || 'Failed to fetch applications');
|
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) => {
|
shortlistApplications: async (applicationIds: string[], assignedTo: string[], remarks?: string) => {
|
||||||
const response: any = await API.shortlistApplications({ applicationIds, assignedTo, remarks });
|
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');
|
if (!response.ok) throw new Error(response.data?.message || 'Failed to update application status');
|
||||||
return response.data;
|
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) => {
|
createDealer: async (data: any) => {
|
||||||
const response: any = await API.createDealer(data);
|
const response: any = await API.createDealer(data);
|
||||||
if (!response.ok) throw new Error(response.data?.message || 'Failed to create dealer profile');
|
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' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
});
|
});
|
||||||
return response.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,
|
regionName: string,
|
||||||
zoneName: string,
|
zoneName: string,
|
||||||
asmName?: string,
|
asmName?: string,
|
||||||
isActive: boolean,
|
isOpportunity: boolean,
|
||||||
city?: string,
|
city?: string,
|
||||||
openFrom?: string | Date | null,
|
openFrom?: string | Date | null,
|
||||||
openTo?: string | Date | null
|
openTo?: string | Date | null
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user