From 5fbf06d827088b102c1bd071479869247253e8ea Mon Sep 17 00:00:00 2001 From: laxman h Date: Fri, 24 Apr 2026 19:31:04 +0530 Subject: [PATCH] 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 --- src/App.tsx | 6 + src/api/API.ts | 23 +- src/components/admin/ApprovalPoliciesPage.tsx | 597 ++++++++++------- src/components/layout/Sidebar.tsx | 8 +- src/components/public/ApplicationFormPage.tsx | 6 +- .../pages/ConstitutionalChangePage.tsx | 91 ++- .../dashboard/pages/FinanceDashboard.tsx | 3 +- .../components/AutoAssignmentSettings.tsx | 178 +++++ .../components/InterviewConfigManagement.tsx | 618 ++++++++++++++++++ .../master/components/LocationDialog.tsx | 8 +- .../master/components/LocationManagement.tsx | 18 +- src/features/master/pages/MasterPage.tsx | 20 +- .../ApplicationDetailsActionModals.tsx | 14 +- .../ApplicationDetailsExtendedModals.tsx | 105 ++- .../ApplicationDetailsFddAuditContent.tsx | 2 +- .../ApplicationDetailsSidebar.tsx | 6 +- .../useApplicationDetailsAdminActions.ts | 1 - .../useApplicationDetailsFeedbackActions.ts | 105 ++- .../hooks/useApplicationDetailsPermissions.ts | 2 +- .../hooks/useApplicationDetailsUIState.ts | 73 +-- .../onboarding/hooks/useInterviewConfigs.ts | 88 +++ .../onboarding/pages/AllApplicationsPage.tsx | 116 +++- .../onboarding/pages/ApplicationDetails.tsx | 326 +++++---- .../onboarding/pages/ApplicationsPage.tsx | 215 +++--- .../pages/FinanceOnboardingPage.tsx | 4 +- .../onboarding/pages/NonOpportunitiesPage.tsx | 308 +++++++-- .../pages/OpportunityRequestsPage.tsx | 293 +++++---- .../onboarding/pages/WorkNotesPage.tsx | 186 +++++- .../pages/RelocationRequestPage.tsx | 84 ++- .../resignation/pages/ResignationPage.tsx | 237 ++++--- .../termination/pages/TerminationPage.tsx | 127 ++-- src/hooks/useMasterData.ts | 2 +- src/services/onboarding.service.ts | 16 +- src/services/worknote.service.ts | 5 + src/store/slices/masterSlice.ts | 2 +- 35 files changed, 2819 insertions(+), 1074 deletions(-) create mode 100644 src/features/master/components/AutoAssignmentSettings.tsx create mode 100644 src/features/master/components/InterviewConfigManagement.tsx create mode 100644 src/features/onboarding/hooks/useInterviewConfigs.ts diff --git a/src/App.tsx b/src/App.tsx index f5a6990..6df7194 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,7 @@ import { DealerConstitutionalChangePage } from '@/features/constitutional/pages/ import { DealerRelocationPage } from '@/features/relocation/pages/DealerRelocationPage'; import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder'; import QuestionnaireList from '@/components/admin/QuestionnaireList'; +import InterviewConfigManagement from '@/features/master/components/InterviewConfigManagement'; import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage'; import { NotificationsPage } from '@/pages/NotificationsPage'; import { Toaster } from '@/components/ui/sonner'; @@ -265,6 +266,11 @@ export default function App() { } /> } /> } /> + + : + } /> {/* HR/Finance Modules (Simplified for brevity, following pattern) */} client.post('/master/zonal-managers', data), getDDLeads: () => client.get('/master/dd-leads'), saveDDLead: (data: any) => client.post('/master/dd-leads', data), - getManagersByRole: (params: any) => client.get('/master/managers', { params }), + getManagersByRole: (params: any) => client.get('/master/managers', params), // Onboarding submitApplication: (data: any) => client.post('/onboarding/apply', data), exportApplicationResponses: (params: { applicationIds: string }) => client.get('/onboarding/applications/export-responses', params), - getApplications: () => client.get('/onboarding/applications'), + getApplications: (params?: any) => client.get('/onboarding/applications', params), shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data), getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`), updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data), @@ -52,6 +52,8 @@ export const API = { updateArchitectureStatus: (applicationId: string, status: string, remarks?: string) => client.post(`/onboarding/applications/${applicationId}/architecture-status`, { status, remarks }), generateDealerCodes: (applicationId: string) => client.post(`/onboarding/applications/${applicationId}/generate-codes`), updateApplicationStatus: (id: string, data: any) => client.put(`/onboarding/applications/${id}/status`, data), + convertToOpportunity: (id: string, data?: any) => client.post(`/onboarding/applications/${id}/convert-to-opportunity`, data), + bulkConvertToOpportunity: (data: any) => client.post('/onboarding/applications/bulk-convert-to-opportunity', data), retriggerEvaluators: (id: string) => client.post(`/onboarding/applications/${id}/retrigger-evaluators`), getSecurityDeposit: (applicationId: string) => client.get(`/loa/security-deposit/${applicationId}`), updateSecurityDeposit: (data: any) => client.post('/loa/security-deposit', data), @@ -100,7 +102,7 @@ export const API = { deleteUser: (id: string) => client.delete(`/admin/users/${id}`), // Dealer & Outlets - getDealers: (params?: { onboarded?: string; activeOnly?: string }) => client.get('/dealer', { params }), + getDealers: (params?: { onboarded?: string; activeOnly?: string }) => client.get('/dealer', params), createDealer: (data: any) => client.post('/dealer', data), getDealerById: (id: string) => client.get(`/dealer/${id}`), updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data), @@ -156,7 +158,7 @@ export const API = { rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data), withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }), - getTerminations: () => client.get('/termination'), + getTerminations: (params?: any) => client.get('/termination', params), createTermination: (data: any) => client.post('/termination', data), updateTermination: (id: string, data: any) => client.post(`/termination/${id}/status`, data), @@ -179,7 +181,7 @@ export const API = { updateLineItem: (itemId: string, data: any) => client.put(`/settlement/fnf/line-items/${itemId}`, data), deleteLineItem: (itemId: string) => client.delete(`/settlement/fnf/line-items/${itemId}`), - getRelocationRequests: () => client.get('/relocation'), + getRelocationRequests: (params?: any) => client.get('/relocation', params), getRelocationRequestById: (id: string) => client.get(`/relocation/${id}`), createRelocationRequest: (data: any) => client.post('/relocation', data), updateRelocationRequest: (id: string, action: string, data?: any) => client.post(`/relocation/${id}/action`, { action, ...data }), @@ -190,7 +192,7 @@ export const API = { rejectRelocationDocument: (id: string, documentId: string, data?: any) => client.post(`/relocation/${id}/documents/${documentId}/reject`, data || {}), - getConstitutionalChanges: () => client.get('/constitutional-change'), + getConstitutionalChanges: (params?: any) => client.get('/constitutional-change', params), getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'), getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`), createConstitutionalChange: (data: any) => client.post('/constitutional-change', data), @@ -208,6 +210,15 @@ export const API = { saveSlaConfig: (data: any) => client.post('/master/sla-configs', data), initializeDefaultSlas: () => client.post('/master/sla-configs/initialize'), + // Interview Configs + getInterviewConfigs: (params?: any) => client.get('/master/interview-configs', params), + getInterviewConfigById: (id: string) => client.get(`/master/interview-configs/${id}`), + getInterviewConfigByType: (configType: string) => client.get(`/master/interview-configs/active/${configType}`), + createInterviewConfig: (data: any) => client.post('/master/interview-configs', data), + updateInterviewConfig: (id: string, data: any) => client.put(`/master/interview-configs/${id}`, data), + deleteInterviewConfig: (id: string) => client.delete(`/master/interview-configs/${id}`), + initializeDefaultInterviewConfigs: () => client.post('/master/interview-configs/initialize'), + // System Configs getSystemConfigs: (params?: any) => client.get('/master/system-configs', params), saveSystemConfig: (data: any) => client.post('/master/system-configs', data), diff --git a/src/components/admin/ApprovalPoliciesPage.tsx b/src/components/admin/ApprovalPoliciesPage.tsx index 920ab69..7b91a5e 100644 --- a/src/components/admin/ApprovalPoliciesPage.tsx +++ b/src/components/admin/ApprovalPoliciesPage.tsx @@ -3,10 +3,27 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '../ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Badge } from '../ui/badge'; -import { RefreshCw, Settings2, Edit2, Save, X } from 'lucide-react'; +import { RefreshCw, Settings2, Edit2, Save, X, Plus } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription +} from '../ui/dialog'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu'; +import { ROLES } from '../../lib/constants'; import { approvalPolicyService } from '../../services/approvalPolicy.service'; type ApprovalMode = 'ALL' | 'MIN_N' | 'ROLE_MANDATORY'; @@ -19,13 +36,64 @@ interface Policy { isActive: boolean; } +const AVAILABLE_ROLES = Object.values(ROLES).sort(); + +const STAGE_OPTIONS = [ + { + label: 'Onboarding', + stages: [ + { label: 'General Info', value: 'ONBOARDING_GENERAL' }, + { label: 'KYC Verification', value: 'ONBOARDING_KYC' }, + { label: 'Level 1 Interview', value: 'LEVEL_1_INTERVIEW' }, + { label: 'Level 2 Interview', value: 'LEVEL_2_INTERVIEW' }, + { label: 'Level 3 Interview', value: 'LEVEL_3_INTERVIEW' }, + { label: 'FDD Verification', value: 'FDD_VERIFICATION' }, + { label: 'LOI Approval', value: 'LOI_APPROVAL' }, + { label: 'LOA Approval', value: 'LOA_APPROVAL' }, + { label: 'Architecture Team Assigned', value: 'ARCHITECTURE_ASSIGNMENT' }, + { label: 'Architecture Doc Upload', value: 'ARCHITECTURE_DOCUMENT_UPLOAD' }, + { label: 'Statutory Verification', value: 'STATUTORY_CHECK' }, + { label: 'EOR Verification', value: 'EOR_VERIFICATION' }, + ] + }, + { + label: 'Offboarding (Resignation)', + stages: [ + { label: 'Regional Review', value: 'RESIGNATION_REGIONAL_REVIEW' }, + { label: 'ZM Review', value: 'RESIGNATION_ZM_REVIEW' }, + { label: 'ZBH Review', value: 'RESIGNATION_ZBH_REVIEW' }, + { label: 'Finance Clearance', value: 'RESIGNATION_FINANCE_REVIEW' }, + { label: 'DDL Review', value: 'RESIGNATION_DDL_REVIEW' }, + { label: 'Final Approval', value: 'RESIGNATION_APPROVED' }, + ] + }, + { + label: 'Termination', + stages: [ + { label: 'RBM Review', value: 'TERMINATION_HEARING' }, + { label: 'DDL Evaluation', value: 'TERMINATION_REVIEW' }, + { label: 'Legal Verification', value: 'TERMINATION_LEGAL_VERIFICATION' }, + { label: 'Final NBH Approval', value: 'TERMINATION_CLOSED' }, + ] + }, + { + label: 'Relocation & CC', + stages: [ + { label: 'Relocation ASM Review', value: 'RELOCATION_ASM_REVIEW' }, + { label: 'Relocation Head Approval', value: 'RELOCATION_COMPLETED' }, + { label: 'CC Legal Review', value: 'CONSTITUTIONAL_LEGAL_REVIEW' }, + { label: 'CC Head Approval', value: 'CONSTITUTIONAL_APPROVED' }, + ] + } +]; + export function ApprovalPoliciesPage() { const [loading, setLoading] = useState(false); const [policies, setPolicies] = useState([]); - const [editingCode, setEditingCode] = useState(null); - const [draft, setDraft] = useState(null); - const [creating, setCreating] = useState(false); - const [newPolicy, setNewPolicy] = useState({ + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [isCustomStage, setIsCustomStage] = useState(false); + const [draft, setDraft] = useState({ stageCode: '', minApprovals: 1, approvalMode: 'MIN_N', @@ -52,26 +120,37 @@ export function ApprovalPoliciesPage() { fetchPolicies(); }, []); - const startEdit = (policy: Policy) => { - setEditingCode(policy.stageCode); + const openCreateModal = () => { + setIsEditMode(false); + setIsCustomStage(false); + setDraft({ + stageCode: '', + minApprovals: 1, + approvalMode: 'MIN_N', + requiredRoles: [], + isActive: true + }); + setIsModalOpen(true); + }; + + const openEditModal = (policy: Policy) => { + setIsEditMode(true); + setIsCustomStage(true); // Always treat existings as "custom entry" if not found in list, for UI consistency setDraft({ stageCode: policy.stageCode, minApprovals: policy.minApprovals || 1, - approvalMode: policy.approvalMode || 'MIN_N', - requiredRoles: Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [], + approvalMode: (policy.approvalMode as ApprovalMode) || 'MIN_N', + requiredRoles: Array.isArray(policy.requiredRoles) ? [...policy.requiredRoles] : [], isActive: policy.isActive !== false }); + setIsModalOpen(true); }; - const cancelEdit = () => { - setEditingCode(null); - setDraft(null); - }; - - const saveEdit = async () => { - if (!draft) return; + const savePolicy = async () => { + if (!draft.stageCode.trim()) return; if (draft.approvalMode === 'ROLE_MANDATORY' && draft.requiredRoles.length > 0 && draft.minApprovals > draft.requiredRoles.length) { + alert('In ROLE_MANDATORY mode, min approvals cannot exceed the number of required roles.'); return; } @@ -81,42 +160,13 @@ export function ApprovalPoliciesPage() { requiredRoles: draft.requiredRoles, isActive: draft.isActive }; - const res = await approvalPolicyService.savePolicy(draft.stageCode, payload); - if (res?.success) { - await fetchPolicies(); - cancelEdit(); - } - }; - const resetNewPolicy = () => { - setNewPolicy({ - stageCode: '', - minApprovals: 1, - approvalMode: 'MIN_N', - requiredRoles: [], - isActive: true - }); - }; - - const createPolicy = async () => { - const stageCode = newPolicy.stageCode.trim().toUpperCase().replace(/\s+/g, '_'); - if (!stageCode) return; - - if (newPolicy.approvalMode === 'ROLE_MANDATORY' && newPolicy.requiredRoles.length > 0 && newPolicy.minApprovals > newPolicy.requiredRoles.length) { - return; - } - - const payload = { - minApprovals: Number(newPolicy.minApprovals) || 1, - approvalMode: newPolicy.approvalMode, - requiredRoles: newPolicy.requiredRoles, - isActive: newPolicy.isActive - }; + const stageCode = draft.stageCode.trim().toUpperCase().replace(/\s+/g, '_'); const res = await approvalPolicyService.savePolicy(stageCode, payload); + if (res?.success) { await fetchPolicies(); - resetNewPolicy(); - setCreating(false); + setIsModalOpen(false); } }; @@ -130,207 +180,278 @@ export function ApprovalPoliciesPage() {

Configure stage-level approvers, mode, and minimum approvals.

- +
+ + +
- - -
- Configured Stages - {!creating ? ( - - ) : ( -
- - -
- )} -
+ + + Configured Stages - {creating && ( -
-
- - setNewPolicy({ ...newPolicy, stageCode: e.target.value })} - /> +
+ + + + Stage Code + Approval Mode + Min Appr. + Required Roles + Status + Actions + + + + {sortedPolicies.map((policy) => ( + + {policy.stageCode} + + + {policy.approvalMode} + + + + + {policy.minApprovals} + + + +
+ {(policy.requiredRoles || []).map((role) => ( + + {role} + + ))} +
+
+ +
+
+ + {policy.isActive ? 'Active' : 'Inactive'} + +
+ + + + + + ))} + +
+
+ + + + {/* Unified Edit/Create Modal */} + + + + + {isEditMode ? : } + {isEditMode ? 'Edit Policy' : 'Create New Policy'} + + + {isEditMode + ? `Update configuration for stage ${draft.stageCode}.` + : 'Define approval requirements for a workflow stage.'} + + + +
+
+ +
+ {!isCustomStage && !isEditMode ? ( +
+ +
+ ) : ( +
+ setDraft({ ...draft, stageCode: e.target.value.toUpperCase() })} + /> + {!isEditMode && ( + { + setIsCustomStage(false); + setDraft({ ...draft, stageCode: '' }); + }} + /> + )} +
+ )}
-
- +
+ +
+ +
-
-
- - setNewPolicy({ ...newPolicy, minApprovals: Number(e.target.value || 1) })} - /> -
-
- - - setNewPolicy({ - ...newPolicy, - requiredRoles: e.target.value.split(',').map((r) => r.trim()).filter(Boolean) - }) - } - /> -
-
- -
- )} - - - - Stage Code - Approval Mode - Min Approvals - Required Roles - Status - Actions - - - - {sortedPolicies.map((policy) => { - const isEditing = editingCode === policy.stageCode && draft; - return ( - - {policy.stageCode} - - {isEditing ? ( - - ) : ( - {policy.approvalMode} - )} - - - {isEditing ? ( - setDraft({ ...draft, minApprovals: Number(e.target.value || 1) })} - className="w-28" - /> - ) : ( - policy.minApprovals - )} - - - {isEditing ? ( -
- - - setDraft({ - ...draft, - requiredRoles: e.target.value - .split(',') - .map((r) => r.trim()) - .filter(Boolean) - }) - } - /> -
- ) : ( -
- {(policy.requiredRoles || []).map((role) => ( - {role} - ))} -
- )} -
- - {isEditing ? ( - - ) : ( - - {policy.isActive ? 'Active' : 'Inactive'} - - )} - - - {isEditing ? ( -
- - -
- ) : ( - - )} -
-
- ); - })} -
-
- - + +
+ +
+ setDraft({ ...draft, minApprovals: Number(e.target.value || 1) })} + className="w-20 h-8 text-xs border-slate-200" + /> +
+
+ +
+ +
+ + + + + + Available Roles + {AVAILABLE_ROLES.map((role) => ( + { + if (checked) { + setDraft({ ...draft, requiredRoles: [...draft.requiredRoles, role] }); + } else { + setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) }); + } + }} + > + {role} + + ))} + + + +
+ {draft.requiredRoles.map((role) => ( + + {role} + setDraft({ ...draft, requiredRoles: draft.requiredRoles.filter(r => r !== role) })} + /> + + ))} + {draft.requiredRoles.length === 0 && ( + No roles assigned. + )} +
+
+
+ +
+ +
+ +
+
+
+ + + + + + +
); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 242bbe0..c3001f9 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -13,7 +13,8 @@ import { Settings, RefreshCcw, MapPin, - ClipboardList + ClipboardList, + ListChecks } from 'lucide-react'; import { useState, useRef, useCallback, useEffect } from 'react'; import ReactDOM from 'react-dom'; @@ -109,6 +110,7 @@ export function Sidebar({ onLogout }: SidebarProps) { if (hasRole(['Super Admin'])) { menuItems.push({ id: 'users', label: 'User Management', icon: Users }); menuItems.push({ id: 'questionnaires', label: 'Questionnaire Templates', icon: ClipboardList }); + menuItems.push({ id: 'interview-configs', label: 'Interview Configs', icon: ListChecks }); } const handleSearch = (e: React.FormEvent) => { @@ -152,7 +154,7 @@ export function Sidebar({ onLogout }: SidebarProps) { {collapsed ? ( /* Collapsed header: logo + toggle stacked, centered */
-
+
RE
- Royal Enfield + Royal Enfield Dealer Onboarding diff --git a/src/components/public/ApplicationFormPage.tsx b/src/components/public/ApplicationFormPage.tsx index af51bc4..d228806 100644 --- a/src/components/public/ApplicationFormPage.tsx +++ b/src/components/public/ApplicationFormPage.tsx @@ -290,7 +290,7 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
- Own a Royal Enfield? + Own a Royal Enfield? *
{['yes', 'no'].map(val => (
- Are you an existing Dealer / Vendor of Royal Enfield? + Are you an existing Dealer / Vendor of Royal Enfield? *
{['yes', 'no'].map(val => (
+ + {paginationMeta && paginationMeta.totalPages > 1 && ( +
+ + + + setCurrentPage(prev => Math.max(1, prev - 1))} + className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + {[...Array(paginationMeta.totalPages)].map((_, i) => { + const pageNum = i + 1; + if ( + pageNum === 1 || + pageNum === paginationMeta.totalPages || + (pageNum >= currentPage - 1 && pageNum <= currentPage + 1) + ) { + return ( + + setCurrentPage(pageNum)} + className="cursor-pointer" + > + {pageNum} + + + ); + } + if ( + (pageNum === 2 && currentPage > 3) || + (pageNum === paginationMeta.totalPages - 1 && currentPage < paginationMeta.totalPages - 2) + ) { + return ; + } + return null; + })} + + + setCurrentPage(prev => Math.min(paginationMeta.totalPages, prev + 1))} + className={currentPage === paginationMeta.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + +
+ )}
diff --git a/src/features/dashboard/pages/FinanceDashboard.tsx b/src/features/dashboard/pages/FinanceDashboard.tsx index 75c10c9..7cfac2b 100644 --- a/src/features/dashboard/pages/FinanceDashboard.tsx +++ b/src/features/dashboard/pages/FinanceDashboard.tsx @@ -45,10 +45,11 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit const fetchData = async () => { try { setLoading(true); - const [settlements, apps] = await Promise.all([ + const [settlements, response] = await Promise.all([ settlementService.getFnFSettlements(), onboardingService.getApplications() ]); + const apps = response.data || []; // Derive Onboarding Payments from Application + SecurityDeposit (Standardized nomenclature) // This ensures applications in "Payment Pending" / "Security Details" are visible diff --git a/src/features/master/components/AutoAssignmentSettings.tsx b/src/features/master/components/AutoAssignmentSettings.tsx new file mode 100644 index 0000000..0bdc0f5 --- /dev/null +++ b/src/features/master/components/AutoAssignmentSettings.tsx @@ -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([]); + 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 ( +
+ +

Loading governance controls...

+
+ ); + } + + return ( +
+ + +
+
+ +
+
+ Auto-Assignment Governance + + Control the automated mapping of participants across different business modules + +
+
+
+ +
+ {modules.map((mod) => { + const config = configs.find(c => c.key === mod.key); + const isEnabled = config?.value?.enabled ?? false; + + return ( +
+
+
+ +
+
+
+ {mod.label} + + + + + + +

When OFF, all participants for new {mod.label} requests must be assigned manually by authorized administrators.

+
+
+
+
+
+ + {isEnabled ? 'Auto-Assign ON' : 'Manual Mode'} + +
+
+
+ +
+ { + 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 } }); + } + }} + /> +
+
+ ); + })} +
+ +
+ +
+

Impact of Manual Mode:

+

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.

+
+
+
+
+
+ ); +}; diff --git a/src/features/master/components/InterviewConfigManagement.tsx b/src/features/master/components/InterviewConfigManagement.tsx new file mode 100644 index 0000000..5bba910 --- /dev/null +++ b/src/features/master/components/InterviewConfigManagement.tsx @@ -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([]); + const [, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState('KT_MATRIX'); + const [showEditor, setShowEditor] = useState(false); + const [editingConfig, setEditingConfig] = useState(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 = { + 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 ( +
+
+
+

Interview Configuration

+

Manage KT Matrix criteria and feedback fields for all interview levels

+
+ +
+ + + + {CONFIG_TYPES.map(t => ( + {t.label} + ))} + + {CONFIG_TYPES.map(type => { + const activeConfig = getActiveConfig(type.value); + const typeConfigs = configs.filter(c => c.configType === type.value); + return ( + + + +
+ {type.label} +

{type.description}

+
+ +
+ + {activeConfig ? ( +
+
+
+

Active: {activeConfig.name}

+

Version {activeConfig.version} • {activeConfig.items?.length || 0} items

+
+
+ +
+
+
+ ) : ( +
+ No active configuration found. Click "Publish New Version" to create one, or "Reset to Defaults" to initialize system defaults. +
+ )} + +

Version History

+ {typeConfigs.length === 0 ? ( +

No versions found.

+ ) : ( +
+ {typeConfigs.map(cfg => ( +
+
+ {cfg.isActive && Active} + {cfg.name} + {cfg.version} + {cfg.items?.length || 0} items +
+
+ + +
+
+ ))} +
+ )} +
+
+
+ ); + })} +
{/* Precision Engineered Editor Dialog */} + + + {/* Compact Minimalist Header */} +
+
+
+ + {editingConfig?.id ? 'Edit Configuration' : 'New Configuration'} + + + {editingConfig?.configType.replace(/_/g, ' ')} · {editingConfig?.version || 'v1.0'} + +
+ {editingConfig?.configType === 'KT_MATRIX' && ( +
+ Weight: {totalWeight}% / 100% +
+ )} +
+
+
+
+ +
+ {editingConfig && ( +
+ {/* Compact Meta Row */} +
+
+ + setEditingConfig({ ...editingConfig, name: e.target.value })} + className="h-9 border-slate-200 bg-white shadow-none text-sm font-normal" + /> +
+
+ + setEditingConfig({ ...editingConfig, version: e.target.value })} + className="h-9 border-slate-200 bg-white shadow-none text-sm font-normal" + /> +
+
+ +
+
+ + {/* Items Area with Horizontal Guard */} +
+
+ + + + + + + + {editingConfig.configType === 'KT_MATRIX' && ( + <> + + + + )} + + + + + + {(editingConfig.items || []).map((item, index) => ( + + + + + + + {editingConfig.configType === 'KT_MATRIX' && ( + <> + + + + )} + + + + + {item.type === 'select' && ( + + + + + )} + + ))} + +
#LabelData KeyTypeWeightMaxReq.
{String(index + 1).padStart(2, '0')} + 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." + /> + + 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" + /> + + + +
+ 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" + /> +
+ + +
+ % +
+
+
+ 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" + /> +
+ + +
+
+
+ updateItem(index, 'isRequired', e.target.checked)} + className="w-4 h-4 accent-slate-900 border-slate-300" + /> + + +
+
+
+

+

Selection Choices Profile +

+ +
+
+
Display Label
+
API Value
+
Score
+
+
+ {(item.options || []).map((opt, optIndex) => ( +
+
+ updateOption(index, optIndex, 'optionLabel', e.target.value)} className="h-9 border-slate-200 bg-white text-xs font-normal" /> +
+
+ updateOption(index, optIndex, 'optionValue', e.target.value)} className="h-9 border-slate-200 bg-white text-xs font-mono" /> +
+
+
+ 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" + /> +
+ + +
+
+
+
+ +
+
+ ))} +
+
+
+
+
+
+ )} +
+ +
+ + +
+
+
+
+ ); +}; + +export default InterviewConfigManagement; diff --git a/src/features/master/components/LocationDialog.tsx b/src/features/master/components/LocationDialog.tsx index 28e7107..5a8bec6 100644 --- a/src/features/master/components/LocationDialog.tsx +++ b/src/features/master/components/LocationDialog.tsx @@ -118,14 +118,14 @@ export const LocationDialog: React.FC = ({
- +
diff --git a/src/features/master/components/LocationManagement.tsx b/src/features/master/components/LocationManagement.tsx index 2e71d4b..6457025 100644 --- a/src/features/master/components/LocationManagement.tsx +++ b/src/features/master/components/LocationManagement.tsx @@ -70,13 +70,13 @@ export const LocationManagement: React.FC = ({ @@ -96,7 +96,7 @@ export const LocationManagement: React.FC = ({ City District Active Period - Status + Opportunity Actions @@ -140,10 +140,10 @@ export const LocationManagement: React.FC = ({ - {district.isActive ? 'Active' : 'Inactive'} + {district.isOpportunity ? 'Yes' : 'No'} diff --git a/src/features/master/pages/MasterPage.tsx b/src/features/master/pages/MasterPage.tsx index 3abec55..0f16fe4 100644 --- a/src/features/master/pages/MasterPage.tsx +++ b/src/features/master/pages/MasterPage.tsx @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText } from 'lucide-react'; +import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText, Settings2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; @@ -31,6 +31,7 @@ import { TemplateDialog } from '@/features/master/components/TemplateDialog'; import { LocationDialog } from '@/features/master/components/LocationDialog'; import { SecurityDepositMaster } from '@/features/master/components/SecurityDepositMaster'; import { DocumentConfigManagement } from '@/features/master/components/DocumentConfigManagement'; +import { AutoAssignmentSettings } from '@/features/master/components/AutoAssignmentSettings'; import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage'; import { RootState } from '@/store'; @@ -377,7 +378,7 @@ export const MasterPage: React.FC = () => { setLocationDistrict(loc.districtId || ''); setLocationActiveFrom(loc.openFrom ? new Date(loc.openFrom).toISOString().split('T')[0] : ''); setLocationActiveTo(loc.openTo ? new Date(loc.openTo).toISOString().split('T')[0] : ''); - setLocationStatus(loc.isActive ? 'active' : 'inactive'); + setLocationStatus(loc.isOpportunity ? 'active' : 'inactive'); setShowLocationDialog(true); }; @@ -404,7 +405,7 @@ export const MasterPage: React.FC = () => { status: locationStatus, openFrom: locationActiveFrom, openTo: locationActiveTo, - isActive: locationStatus === 'active' + isOpportunity: locationStatus === 'active' }; const res = await (editingLocationId ? masterService.updateArea(editingLocationId, payload) @@ -423,11 +424,11 @@ export const MasterPage: React.FC = () => { search: districtsSearch, page: districtsPage, stateId: locationStateFilter === 'all' ? undefined : locationStateFilter, - isActive: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false') + isOpportunity: locationStatusFilter === 'all' ? undefined : (locationStatusFilter === 'active' ? 'true' : 'false') }); }, 500); return () => clearTimeout(handler); - }, [districtsSearch, districtsPage, locationStateFilter, fetchAreas]); + }, [districtsSearch, districtsPage, locationStateFilter, locationStatusFilter, fetchAreas]); return (
@@ -446,7 +447,7 @@ export const MasterPage: React.FC = () => {
) : ( - + Organisation @@ -465,6 +466,9 @@ export const MasterPage: React.FC = () => { Docs Config + + Governance + App Settings @@ -581,6 +585,10 @@ export const MasterPage: React.FC = () => { + + + + diff --git a/src/features/onboarding/components/application-details/ApplicationDetailsActionModals.tsx b/src/features/onboarding/components/application-details/ApplicationDetailsActionModals.tsx index 62ef223..f54df2f 100644 --- a/src/features/onboarding/components/application-details/ApplicationDetailsActionModals.tsx +++ b/src/features/onboarding/components/application-details/ApplicationDetailsActionModals.tsx @@ -135,7 +135,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
- +