contitutional and relocation changes done based on document alignment

This commit is contained in:
laxmanhalaki 2026-05-06 10:45:55 +05:30
parent b357dbdcbb
commit c23593bb11
13 changed files with 132 additions and 66 deletions

View File

@ -77,7 +77,6 @@ export default function App() {
const navigate = useNavigate();
const location = useLocation();
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();

View File

@ -47,7 +47,6 @@ export function Sidebar({ onLogout }: SidebarProps) {
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import {
Tabs, TabsContent, TabsList, TabsTrigger
} from '@/components/ui/tabs';
import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText, Settings2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
// Services & Hooks

View File

@ -50,7 +50,6 @@ interface ApplicationDetailsTabsProps {
setShowDocumentsModal: (value: boolean) => void;
setShowUploadForm: (value: boolean) => void;
handleRetriggerEvaluators: () => void;
handleCancelInterview: (interviewId: any) => void;
handleRescheduleInterview: (interview: any) => void;
setSelectedEvaluationForView: (value: any) => void;
setShowFeedbackDetailsModal: (value: boolean) => void;
@ -86,7 +85,6 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
setShowDocumentsModal,
setShowUploadForm,
handleRetriggerEvaluators,
handleCancelInterview,
handleRescheduleInterview,
setSelectedEvaluationForView,
setShowFeedbackDetailsModal,

View File

@ -35,7 +35,6 @@ import {
Mail,
Grid3x3,
List,
AlertCircle,
Loader2,
Calendar,
ArrowUpDown

View File

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

View File

@ -213,14 +213,6 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const isDDLeadStage = currentStage === 'DD Lead' || currentStage === 'DD Lead Review';
const isDDLead = userRoleCode === 'DD_LEAD' || userRoleCode === 'DD LEAD';
const canApprove = isCurrentlyAssigned &&
!isFinalState &&
!isSettlementPhase &&
!hasAlreadyPartiallyApproved &&
!(currentStage === 'Legal' && legalStageApproved) &&
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
!(currentStage === 'DD Admin' && !isLwdReached);
const isLwdReached = (() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
@ -231,6 +223,14 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
return today >= lwd;
})();
const canApprove = isCurrentlyAssigned &&
!isFinalState &&
!isSettlementPhase &&
!hasAlreadyPartiallyApproved &&
!(currentStage === 'Legal' && legalStageApproved) &&
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
!(currentStage === 'DD Admin' && !isLwdReached);
return {
canApprove,
canSendBack: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && stageIndex > 0,

View File

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

View File

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