Compare commits

..

No commits in common. "ac10c461e4f770d622fd0d9fa15faca0f75b3abe" and "d285ea88d855509f4794cba378df82217c7db719" have entirely different histories.

71 changed files with 5879 additions and 6932 deletions

View File

@ -1,27 +0,0 @@
#Local
VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
VITE_BASE_URL=http://localhost:3000
VITE_API_BASE_URL=http://localhost:3000/api/v1
VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
VITE_OKTA_DOMAIN=https://royalenfield.okta.com
#Development
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
# VITE_BASE_URL=https://re-workflow-nt-dev.siplsolutions.com
# VITE_API_BASE_URL=https://re-workflow-nt-dev.siplsolutions.com/api/v1
# VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
# VITE_OKTA_DOMAIN=https://royalenfield.okta.com
#Uat
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
# VITE_BASE_URL=https://reflow-uat.royalenfield.com
# VITE_API_BASE_URL=https://reflow-uat.royalenfield.com/api/v1/
# VITE_OKTA_CLIENT_ID=0oa2jgzvrpdwx2iqd0h8
# VITE_OKTA_DOMAIN=https://dev-830839.oktapreview.com
#Production
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
# VITE_BASE_URL=https://reflow.royalenfield.com
# VITE_API_BASE_URL=https://reflow.royalenfield.com/api/v1
# VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
# VITE_OKTA_DOMAIN=https://royalenfield.okta.com

View File

@ -10,7 +10,7 @@
<meta name="theme-color" content="#2d4a3e" /> <meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title> <title>Royal Enfield | Approval Portal</title>
<!-- Preload essential fonts and icons --> <!-- Preload critical fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head> </head>

View File

@ -1,4 +0,0 @@
User-agent: *
Disallow: /api/
Sitemap: https://reflow.royalenfield.com/sitemap.xml

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://reflow.royalenfield.com</loc>
<lastmod>2024-03-20T12:00:00+00:00</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@ -10,7 +10,7 @@ import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail
import { WorkNotes } from '@/pages/WorkNotes'; import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest'; import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard'; import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
// import { DealerDashboard } from '@/dealer-claim/pages/Dashboard'; import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
import { MyRequests } from '@/pages/MyRequests'; import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests'; import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests'; import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
@ -18,21 +18,21 @@ import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance'; import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
import { Profile } from '@/pages/Profile'; import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings'; import { Settings } from '@/pages/Settings';
import { SecuritySettings } from '@/pages/Settings/SecuritySettings';
import { Notifications } from '@/pages/Notifications'; import { Notifications } from '@/pages/Notifications';
import { DetailedReports } from '@/pages/DetailedReports'; import { DetailedReports } from '@/pages/DetailedReports';
import { Admin } from '@/pages/Admin';
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList'; import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate'; import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest'; import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback'; import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi'; import { createClaimRequest } from '@/services/dealerClaimApi';
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal'; import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
import { navigateToRequest } from '@/utils/requestNavigation'; import { navigateToRequest } from '@/utils/requestNavigation';
import { TokenManager } from '@/utils/tokenManager'; // import { TokenManager } from '@/utils/tokenManager';
interface AppProps { interface AppProps {
onLogout?: () => void; onLogout?: () => void;
@ -61,8 +61,8 @@ function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: stri
useEffect(() => { useEffect(() => {
try { try {
const userData = TokenManager.getUserData(); // const userData = TokenManager.getUserData();
setIsDealer(userData?.jobTitle === 'Dealer'); // // setIsDealer(userData?.jobTitle === 'Dealer');
} catch (error) { } catch (error) {
console.error('[App] Error checking dealer status:', error); console.error('[App] Error checking dealer status:', error);
setIsDealer(false); setIsDealer(false);
@ -84,8 +84,7 @@ function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: stri
// Render dealer-specific dashboard if user is a dealer // Render dealer-specific dashboard if user is a dealer
if (isDealer) { if (isDealer) {
console.log("isDealer", isDealer) return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
// return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
} }
// Render regular dashboard for all other users // Render regular dashboard for all other users
@ -194,7 +193,7 @@ function AppRoutes({ onLogout }: AppProps) {
// Regular custom request submission (old flow without API) // Regular custom request submission (old flow without API)
// Generate unique ID for the new custom request // Generate unique ID for the new custom request
const requestId = `RE-REQ-2024-${String(dynamicRequests.length + 1).padStart(3, '0')}`; const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
// Create full custom request object // Create full custom request object
const newCustomRequest = { const newCustomRequest = {
@ -413,6 +412,201 @@ function AppRoutes({ onLogout }: AppProps) {
}); });
} }
// Keep the old code below for backward compatibility (local storage fallback)
// This can be removed once API integration is fully tested
/*
// Generate unique ID for the new claim request
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
// Create full request object
const newRequest = {
id: requestId,
title: `${claimData.activityName} - Claim Request`,
description: claimData.requestDescription,
category: 'Dealer Operations',
subcategory: 'Claim Management',
status: 'pending',
priority: 'standard',
amount: 'TBD',
slaProgress: 0,
slaRemaining: '7 days',
slaEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
currentStep: 1,
totalSteps: 8,
templateType: 'claim-management',
templateName: 'Claim Management',
initiator: {
name: 'Current User',
role: 'Regional Marketing Coordinator',
department: 'Marketing',
email: 'current.user@royalenfield.com',
phone: '+91 98765 43290',
avatar: 'CU'
},
department: 'Marketing',
createdAt: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}),
updatedAt: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}),
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
conclusionRemark: '',
claimDetails: {
activityName: claimData.activityName,
activityType: claimData.activityType,
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
location: claimData.location,
dealerCode: claimData.dealerCode,
dealerName: claimData.dealerName,
dealerEmail: claimData.dealerEmail || 'N/A',
dealerPhone: claimData.dealerPhone || 'N/A',
dealerAddress: claimData.dealerAddress || 'N/A',
requestDescription: claimData.requestDescription,
estimatedBudget: claimData.estimatedBudget || 'TBD',
periodStart: claimData.periodStartDate ? new Date(claimData.periodStartDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
periodEnd: claimData.periodEndDate ? new Date(claimData.periodEndDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
},
approvalFlow: claimData.workflowSteps || [
{
step: 1,
approver: `${claimData.dealerName} (Dealer)`,
role: 'Dealer - Document Upload',
status: 'pending',
tatHours: 72,
elapsedHours: 0,
assignedAt: new Date().toISOString(),
comment: null,
timestamp: null,
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
},
{
step: 2,
approver: 'Current User (Initiator)',
role: 'Initiator Evaluation',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator reviews dealer documents and approves or requests modifications'
},
{
step: 3,
approver: 'System Auto-Process',
role: 'IO Confirmation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Automatic IO (Internal Order) confirmation generated upon initiator approval'
},
{
step: 4,
approver: 'Rajesh Kumar',
role: 'Department Lead Approval',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Department head approves and blocks budget in IO for this activity'
},
{
step: 5,
approver: `${claimData.dealerName} (Dealer)`,
role: 'Dealer - Completion Documents',
status: 'waiting',
tatHours: 120,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Dealer submits activity completion documents and description'
},
{
step: 6,
approver: 'Current User (Initiator)',
role: 'Initiator Verification',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator verifies completion documents and can modify approved amount'
},
{
step: 7,
approver: 'System Auto-Process',
role: 'E-Invoice Generation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Auto-generate e-invoice based on final approved amount'
},
{
step: 8,
approver: 'Finance Team',
role: 'Credit Note Issuance',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Finance team issues credit note to dealer'
}
],
documents: [],
spectators: [],
auditTrail: [
{
type: 'created',
action: 'Request Created',
details: `Claim request for ${claimData.activityName} created`,
user: 'Current User',
timestamp: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})
}
],
tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')]
};
// Add to dynamic requests
setDynamicRequests(prev => [...prev, newRequest]);
// Also add to REQUEST_DATABASE for immediate viewing
(REQUEST_DATABASE as any)[requestId] = newRequest;
toast.success('Claim Request Submitted', {
description: 'Your claim management request has been created successfully.',
});
navigate('/my-requests');
*/
}; };
return ( return (
@ -464,7 +658,44 @@ function AppRoutes({ onLogout }: AppProps) {
} }
/> />
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
{/* Admin Routes Group with Shared Layout */}
<Route
element={
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Outlet />
</PageLayout>
}
>
<Route path="/admin/create-template" element={<CreateTemplate />} />
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
<Route path="/admin/templates" element={<AdminTemplatesList />} />
</Route>
{/* Create Request from Admin Template (Dedicated Flow) */}
<Route
path="/create-admin-request/:templateId"
element={
<CreateAdminRequest />
}
/>
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
{/* Open Requests */} {/* Open Requests */}
<Route <Route
@ -611,16 +842,6 @@ function AppRoutes({ onLogout }: AppProps) {
} }
/> />
{/* Security Settings */}
<Route
path="/settings/security"
element={
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SecuritySettings />
</PageLayout>
}
/>
{/* Notifications */} {/* Notifications */}
<Route <Route
path="/notifications" path="/notifications"

View File

@ -12,14 +12,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
FileText, FileText,
Plus, Plus,
Trash2, Trash2,
@ -28,12 +21,12 @@ import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getAllActivityTypes, getAllActivityTypes,
createActivityType, createActivityType,
updateActivityType, updateActivityType,
deleteActivityType, deleteActivityType,
ActivityType ActivityType
} from '@/services/adminApi'; } from '@/services/adminApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -95,18 +88,17 @@ export function ActivityTypeManager() {
const handleSave = async () => { const handleSave = async () => {
try { try {
setError(null); setError(null);
if (!formData.title.trim() || !formData.taxationType.trim() || !formData.sapRefNo.trim()) { if (!formData.title.trim()) {
setError('Title, Taxation Type, and Claim Document Type (SAP Ref) are required'); setError('Activity type title is required');
toast.error('Please fill in all mandatory fields');
return; return;
} }
const payload: Partial<ActivityType> = { const payload: Partial<ActivityType> = {
title: formData.title.trim(), title: formData.title.trim(),
itemCode: formData.itemCode.trim() || null, itemCode: formData.itemCode.trim() || null,
taxationType: formData.taxationType.trim(), taxationType: formData.taxationType.trim() || null,
sapRefNo: formData.sapRefNo.trim() sapRefNo: formData.sapRefNo.trim() || null
}; };
if (editingActivityType) { if (editingActivityType) {
@ -173,9 +165,9 @@ export function ActivityTypeManager() {
<AlertCircle className="w-4 h-4 text-white shrink-0" /> <AlertCircle className="w-4 h-4 text-white shrink-0" />
</div> </div>
<p className="text-sm font-medium text-red-900">{error}</p> <p className="text-sm font-medium text-red-900">{error}</p>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setError(null)} onClick={() => setError(null)}
className="ml-auto hover:bg-red-100" className="ml-auto hover:bg-red-100"
> >
@ -199,8 +191,8 @@ export function ActivityTypeManager() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm" className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@ -224,9 +216,9 @@ export function ActivityTypeManager() {
</div> </div>
<p className="text-slate-700 font-medium text-lg">No activity types found</p> <p className="text-slate-700 font-medium text-lg">No activity types found</p>
<p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p> <p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
variant="outline" variant="outline"
className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm" className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@ -253,7 +245,7 @@ export function ActivityTypeManager() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-4"> <CardContent className="space-y-3 pt-4">
{activeActivityTypes.map(activityType => ( {activeActivityTypes.map(activityType => (
<div <div
key={activityType.activityTypeId} key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm" className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm"
> >
@ -322,7 +314,7 @@ export function ActivityTypeManager() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-4"> <CardContent className="space-y-3 pt-4">
{inactiveActivityTypes.map(activityType => ( {inactiveActivityTypes.map(activityType => (
<div <div
key={activityType.activityTypeId} key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm" className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm"
> >
@ -405,51 +397,46 @@ export function ActivityTypeManager() {
{/* Taxation Type Field */} {/* Taxation Type Field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900 flex items-center gap-1"> <Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900">
Taxation Type <span className="text-red-500">*</span> Taxation Type <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label> </Label>
<Select <Input
id="taxationType"
placeholder="e.g., GST, VAT, Exempt"
value={formData.taxationType} value={formData.taxationType}
onValueChange={(value) => setFormData({ ...formData, taxationType: value })} onChange={(e) => setFormData({ ...formData, taxationType: e.target.value })}
> className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
<SelectTrigger id="taxationType" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"> />
<SelectValue placeholder="Select Taxation Type" /> <p className="text-xs text-slate-500">Optional taxation type for the activity</p>
</SelectTrigger>
<SelectContent className="rounded-lg">
<SelectItem value="GST" className="p-3">GST</SelectItem>
<SelectItem value="Non GST" className="p-3">Non GST</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Select whether the activity is GST or Non-GST</p>
</div> </div>
{/* SAP Reference Number Field */} {/* SAP Reference Number Field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900 flex items-center gap-1"> <Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900">
Claim Document Type (SAP Ref) <span className="text-red-500">*</span> SAP Reference Number <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label> </Label>
<Input <Input
id="sapRefNo" id="sapRefNo"
placeholder="e.g., ZCNS, ZRE" placeholder="e.g., SAP-12345"
value={formData.sapRefNo} value={formData.sapRefNo}
onChange={(e) => setFormData({ ...formData, sapRefNo: e.target.value })} onChange={(e) => setFormData({ ...formData, sapRefNo: e.target.value })}
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/> />
<p className="text-xs text-slate-500">Required SAP reference number for CSV generation</p> <p className="text-xs text-slate-500">Optional SAP reference number</p>
</div> </div>
</div> </div>
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0"> <DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowAddDialog(false)} onClick={() => setShowAddDialog(false)}
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm" className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={!formData.title.trim() || !formData.taxationType || !formData.sapRefNo.trim()} disabled={!formData.title.trim()}
className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
<FileText className="w-4 h-4 mr-2" /> <FileText className="w-4 h-4 mr-2" />

View File

@ -31,7 +31,7 @@ export function AnalyticsConfig() {
}); });
const handleSave = () => { const handleSave = () => {
// TODO: Implement API call to save configuration
toast.success('Analytics configuration saved successfully'); toast.success('Analytics configuration saved successfully');
}; };

View File

@ -8,7 +8,7 @@ import { toast } from 'sonner';
export type Role = 'Initiator' | 'Approver' | 'Spectator'; export type Role = 'Initiator' | 'Approver' | 'Spectator';
export type KPICard = export type KPICard =
| 'Total Requests' | 'Total Requests'
| 'Open Requests' | 'Open Requests'
| 'Approved Requests' | 'Approved Requests'
@ -59,7 +59,7 @@ export function DashboardConfig() {
}); });
const handleSave = () => { const handleSave = () => {
// TODO: Implement API call to save dashboard configuration
toast.success('Dashboard layout saved successfully'); toast.success('Dashboard layout saved successfully');
}; };

View File

@ -28,7 +28,7 @@ export function NotificationConfig() {
}); });
const handleSave = () => { const handleSave = () => {
// TODO: Implement API call to save notification configuration
toast.success('Notification configuration saved successfully'); toast.success('Notification configuration saved successfully');
}; };

View File

@ -23,7 +23,7 @@ export function SharingConfig() {
}); });
const handleSave = () => { const handleSave = () => {
// TODO: Implement API call to save sharing configuration
toast.success('Sharing policy saved successfully'); toast.success('Sharing policy saved successfully');
}; };

View File

@ -2,18 +2,18 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import {
Plus, Plus,
Search, Search,
Users, Users,
Shield, Shield,
Loader2, Loader2,
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
@ -75,7 +75,7 @@ export function UserManagement() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false);
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 }); const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
// Pagination and filtering // Pagination and filtering
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED'); const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@ -135,14 +135,14 @@ export function UserManagement() {
// We'll search with a broader filter to find the user // We'll search with a broader filter to find the user
const response = await userApi.getUsersByRole('ALL', 1, 1000); const response = await userApi.getUsersByRole('ALL', 1, 1000);
const allUsers = response.data?.data?.users || []; const allUsers = response.data?.data?.users || [];
const foundUser = allUsers.find((u: any) => const foundUser = allUsers.find((u: any) =>
u.email?.toLowerCase() === email.toLowerCase() u.email?.toLowerCase() === email.toLowerCase()
); );
if (foundUser && foundUser.role) { if (foundUser && foundUser.role) {
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN'; return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
} }
return null; // User not found in system, no role assigned return null; // User not found in system, no role assigned
} catch (error) { } catch (error) {
console.error('Failed to fetch user role:', error); console.error('Failed to fetch user role:', error);
@ -156,7 +156,7 @@ export function UserManagement() {
setSearchQuery(user.email); setSearchQuery(user.email);
setSearchResults([]); setSearchResults([]);
setFetchingRole(true); setFetchingRole(true);
try { try {
// Fetch and set the user's current role if they have one // Fetch and set the user's current role if they have one
const currentRole = await fetchUserRole(user.email); const currentRole = await fetchUserRole(user.email);
@ -186,7 +186,7 @@ export function UserManagement() {
try { try {
await userApi.assignRole(selectedUser.email, selectedRole); await userApi.assignRole(selectedUser.email, selectedRole);
setMessage({ setMessage({
type: 'success', type: 'success',
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}` text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
@ -200,7 +200,7 @@ export function UserManagement() {
// Refresh the users list // Refresh the users list
await fetchUsers(); await fetchUsers();
await fetchRoleStatistics(); await fetchRoleStatistics();
toast.success(`Role assigned successfully`); toast.success(`Role assigned successfully`);
} catch (error: any) { } catch (error: any) {
console.error('Role assignment failed:', error); console.error('Role assignment failed:', error);
@ -220,7 +220,7 @@ export function UserManagement() {
setLoadingUsers(true); setLoadingUsers(true);
try { try {
const response = await userApi.getUsersByRole(roleFilter, page, limit); const response = await userApi.getUsersByRole(roleFilter, page, limit);
const usersData = response.data?.data?.users || []; const usersData = response.data?.data?.users || [];
const paginationData = response.data?.data?.pagination; const paginationData = response.data?.data?.pagination;
const summaryData = response.data?.data?.summary; const summaryData = response.data?.data?.summary;
@ -234,13 +234,13 @@ export function UserManagement() {
designation: u.designation, designation: u.designation,
isActive: u.isActive !== false // Default to true if not specified isActive: u.isActive !== false // Default to true if not specified
}))); })));
if (paginationData) { if (paginationData) {
setCurrentPage(paginationData.currentPage); setCurrentPage(paginationData.currentPage);
setTotalPages(paginationData.totalPages); setTotalPages(paginationData.totalPages);
setTotalUsers(paginationData.totalUsers); setTotalUsers(paginationData.totalUsers);
} }
// Update summary stats if available // Update summary stats if available
if (summaryData) { if (summaryData) {
setRoleStats(prev => ({ setRoleStats(prev => ({
@ -264,13 +264,13 @@ export function UserManagement() {
try { try {
const response = await userApi.getRoleStatistics(); const response = await userApi.getRoleStatistics();
const statsData = response.data?.data?.statistics || response.data?.statistics || []; const statsData = response.data?.data?.statistics || response.data?.statistics || [];
const stats = { const stats = {
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'), admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'), management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0') users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
}; };
setRoleStats(prev => ({ setRoleStats(prev => ({
...prev, ...prev,
...stats, ...stats,
@ -317,8 +317,8 @@ export function UserManagement() {
const handleToggleUserStatus = async (userId: string) => { const handleToggleUserStatus = async (userId: string) => {
const user = users.find(u => u.userId === userId); const user = users.find(u => u.userId === userId);
if (!user) return; if (!user) return;
// TODO: Implement backend API for toggling user status
toast.info('User status toggle functionality coming soon'); toast.info('User status toggle functionality coming soon');
}; };
@ -326,12 +326,13 @@ export function UserManagement() {
const handleDeleteUser = async (userId: string) => { const handleDeleteUser = async (userId: string) => {
const user = users.find(u => u.userId === userId); const user = users.find(u => u.userId === userId);
if (!user) return; if (!user) return;
if (user.role === 'ADMIN') { if (user.role === 'ADMIN') {
toast.error('Cannot delete admin user'); toast.error('Cannot delete admin user');
return; return;
} }
// TODO: Implement backend API for deleting users
toast.info('User deletion functionality coming soon'); toast.info('User deletion functionality coming soon');
}; };
@ -514,10 +515,11 @@ export function UserManagement() {
{/* Message */} {/* Message */}
{message && ( {message && (
<div className={`border-2 rounded-lg p-4 ${message.type === 'success' <div className={`border-2 rounded-lg p-4 ${
? 'border-green-200 bg-green-50' message.type === 'success'
: 'border-red-200 bg-red-50' ? 'border-green-200 bg-green-50'
}`}> : 'border-red-200 bg-red-50'
}`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{message.type === 'success' ? ( {message.type === 'success' ? (
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" /> <CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
@ -600,7 +602,7 @@ export function UserManagement() {
</div> </div>
<p className="font-medium text-gray-700">No users found</p> <p className="font-medium text-gray-700">No users found</p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{roleFilter === 'ELEVATED' {roleFilter === 'ELEVATED'
? 'Assign ADMIN or MANAGEMENT roles to see users here' ? 'Assign ADMIN or MANAGEMENT roles to see users here'
: 'No users match the selected filter' : 'No users match the selected filter'
} }
@ -662,10 +664,11 @@ export function UserManagement() {
variant={currentPage === pageNum ? "default" : "outline"} variant={currentPage === pageNum ? "default" : "outline"}
size="sm" size="sm"
onClick={() => handlePageChange(pageNum)} onClick={() => handlePageChange(pageNum)}
className={`w-9 h-9 p-0 ${currentPage === pageNum className={`w-9 h-9 p-0 ${
? 'bg-re-green hover:bg-re-green/90' currentPage === pageNum
: '' ? 'bg-re-green hover:bg-re-green/90'
}`} : ''
}`}
> >
{pageNum} {pageNum}
</Button> </Button>

View File

@ -1,194 +0,0 @@
/**
* AntivirusScanStatus Component
* Displays the antivirus scan result badge/status for uploaded files.
* Shows ClamAV scan result and XSS content scan result.
*/
import React from 'react';
// ── Types ──
export interface ScanResultData {
malwareScan?: {
scanned: boolean;
isInfected: boolean;
skipped?: boolean;
virusNames?: string[];
scanDuration?: number;
error?: string;
};
contentScan?: {
scanned: boolean;
safe: boolean;
scanType: string;
severity: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
threats?: Array<{ description: string; severity: string }>;
patternsChecked: number;
};
scanEventId?: string;
}
interface AntivirusScanStatusProps {
scanResult?: ScanResultData;
compact?: boolean;
className?: string;
}
// ── Helpers ──
function getStatusColor(result?: ScanResultData): string {
if (!result) return '#94a3b8'; // gray — no scan data
// Check malware first
if (result.malwareScan?.isInfected) return '#ef4444'; // red
if (result.malwareScan?.error) return '#f59e0b'; // amber
// Then XSS
if (result.contentScan && !result.contentScan.safe) {
if (result.contentScan.severity === 'CRITICAL') return '#ef4444';
if (result.contentScan.severity === 'HIGH') return '#ef4444';
if (result.contentScan.severity === 'MEDIUM') return '#f59e0b';
return '#f59e0b';
}
// Skipped
if (result.malwareScan?.skipped) return '#94a3b8';
return '#22c55e'; // green — all clear
}
function getStatusIcon(result?: ScanResultData): string {
if (!result) return '⏳';
if (result.malwareScan?.isInfected) return '🛑';
if (result.contentScan && !result.contentScan.safe) return '⚠️';
if (result.malwareScan?.skipped) return '⏭️';
if (result.malwareScan?.error) return '❌';
if (result.malwareScan?.scanned && result.contentScan?.scanned) return '✅';
return '⏳';
}
function getStatusLabel(result?: ScanResultData): string {
if (!result) return 'Pending scan';
if (result.malwareScan?.isInfected) return 'Malware detected';
if (result.contentScan && !result.contentScan.safe) return 'Content threat detected';
if (result.malwareScan?.skipped) return 'Scan skipped';
if (result.malwareScan?.error) return 'Scan error';
if (result.malwareScan?.scanned && result.contentScan?.scanned) return 'Clean';
return 'Scanning…';
}
// ── Component ──
const AntivirusScanStatus: React.FC<AntivirusScanStatusProps> = ({
scanResult,
compact = false,
className = '',
}) => {
const color = getStatusColor(scanResult);
const icon = getStatusIcon(scanResult);
const label = getStatusLabel(scanResult);
// Compact mode: just a badge
if (compact) {
return (
<span
className={className}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 500,
backgroundColor: `${color}15`,
color,
border: `1px solid ${color}30`,
}}
title={label}
>
<span style={{ fontSize: '11px' }}>{icon}</span>
{label}
</span>
);
}
// Full mode: detailed card
return (
<div
className={className}
style={{
border: `1px solid ${color}30`,
borderRadius: '8px',
padding: '12px 16px',
backgroundColor: `${color}08`,
fontSize: '13px',
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontSize: '16px' }}>{icon}</span>
<span style={{ fontWeight: 600, color }}>{label}</span>
{scanResult?.malwareScan?.scanDuration && (
<span style={{ marginLeft: 'auto', fontSize: '11px', color: '#94a3b8' }}>
{scanResult.malwareScan.scanDuration}ms
</span>
)}
</div>
{/* Details */}
{scanResult && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{/* ClamAV Result */}
{scanResult.malwareScan?.scanned && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
<span>🦠</span>
<span>
ClamAV:{' '}
{scanResult.malwareScan.isInfected
? `Infected — ${scanResult.malwareScan.virusNames?.join(', ')}`
: 'Clean'}
</span>
</div>
)}
{/* XSS Result */}
{scanResult.contentScan?.scanned && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
<span>🔍</span>
<span>
Content scan ({scanResult.contentScan.scanType}):{' '}
{scanResult.contentScan.safe
? `Safe — ${scanResult.contentScan.patternsChecked} patterns checked`
: `${scanResult.contentScan.threats?.length || 0} threats found (${scanResult.contentScan.severity})`}
</span>
</div>
)}
{/* Threats list */}
{scanResult.contentScan?.threats && scanResult.contentScan.threats.length > 0 && (
<ul style={{ margin: '4px 0 0 24px', padding: 0, fontSize: '11px', color: '#ef4444' }}>
{scanResult.contentScan.threats.slice(0, 5).map((threat, i) => (
<li key={i}>
{threat.description} ({threat.severity})
</li>
))}
{scanResult.contentScan.threats.length > 5 && (
<li>and {scanResult.contentScan.threats.length - 5} more</li>
)}
</ul>
)}
{/* Scan event ID */}
{scanResult.scanEventId && (
<div style={{ fontSize: '10px', color: '#94a3b8', marginTop: '4px' }}>
Scan ID: {scanResult.scanEventId}
</div>
)}
</div>
)}
</div>
);
};
export default AntivirusScanStatus;

View File

@ -72,8 +72,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const items = [ const items = [
{ id: 'dashboard', label: 'Dashboard', icon: Home }, { id: 'dashboard', label: 'Dashboard', icon: Home },
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests) // Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
{ id: 'requests', label: 'All Requests', icon: List, adminOnly: false }, { id: 'requests', label: 'All Requests', icon: List },
{ id: 'my-requests', label: 'My Requests', icon: User } { id: 'my-requests', label: 'My Requests', icon: User, adminOnly: false },
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true }, // { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
]; ];

View File

@ -4,9 +4,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { import {
Receipt, Receipt,
Package, Package,
ArrowRight, ArrowRight,
ArrowLeft, ArrowLeft,
Clock, Clock,
@ -96,7 +96,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent <DialogContent
className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0" className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0"
> >
{/* Accessibility - Hidden Title and Description */} {/* Accessibility - Hidden Title and Description */}
@ -118,7 +118,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12"> <div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
{/* Header Section */} {/* Header Section */}
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="text-center mb-12 max-w-3xl" className="text-center mb-12 max-w-3xl"
@ -150,13 +150,14 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
whileHover={isDisabled ? {} : { scale: 1.03 }} whileHover={isDisabled ? {} : { scale: 1.03 }}
whileTap={isDisabled ? {} : { scale: 0.98 }} whileTap={isDisabled ? {} : { scale: 0.98 }}
> >
<Card <Card
className={`h-full transition-all duration-300 border-2 ${isDisabled className={`h-full transition-all duration-300 border-2 ${
? 'opacity-50 cursor-not-allowed border-gray-200' isDisabled
: isSelected ? 'opacity-50 cursor-not-allowed border-gray-200'
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200' : isSelected
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg' ? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
}`} : 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
}`}
onClick={() => handleSelect(template.id)} onClick={() => handleSelect(template.id)}
> >
<CardHeader className="space-y-4 pb-4"> <CardHeader className="space-y-4 pb-4">
@ -205,9 +206,9 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
{template.category} {template.category}
</Badge> </Badge>
</div> </div>
<Separator /> <Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500"> <div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> <Clock className="w-3.5 h-3.5" />
@ -243,28 +244,29 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
className="flex flex-col sm:flex-row justify-center gap-4 mt-4" className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
> >
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}
size="lg" size="lg"
className="px-8" className="px-8"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleContinue} onClick={handleContinue}
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled} disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
size="lg" size="lg"
className={`gap-2 px-8 ${selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled className={`gap-2 px-8 ${
? 'bg-blue-600 hover:bg-blue-700' selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
: 'bg-gray-400 cursor-not-allowed' ? 'bg-blue-600 hover:bg-blue-700'
}`} : 'bg-gray-400 cursor-not-allowed'
}`}
> >
Continue with Template Continue with Template
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />

View File

@ -1,297 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Key, Plus, Trash2, Copy, Check } from 'lucide-react';
import { format } from 'date-fns';
import axios from '@/services/authApi';
import { toast } from 'sonner';
interface ApiToken {
id: string;
name: string;
prefix: string;
lastUsedAt?: string;
expiresAt?: string;
createdAt: string;
isActive: boolean;
}
export function ApiTokenManager() {
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newTokenName, setNewTokenName] = useState('');
const [newTokenExpiry, setNewTokenExpiry] = useState<number | ''>('');
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [copied, setCopied] = useState(false);
const [tokenToRevoke, setTokenToRevoke] = useState<ApiToken | null>(null);
useEffect(() => {
fetchTokens();
}, []);
const fetchTokens = async () => {
try {
setIsLoading(true);
const response = await axios.get('/api-tokens');
setTokens(response.data.data.tokens);
} catch (error) {
console.error('Failed to fetch API tokens:', error);
toast.error('Failed to load API tokens');
} finally {
setIsLoading(false);
}
};
const handleCreateToken = async () => {
if (!newTokenName.trim()) return;
try {
setIsCreating(true);
const payload: any = { name: newTokenName };
if (newTokenExpiry) {
payload.expiresInDays = Number(newTokenExpiry);
}
const response = await axios.post('/api-tokens', payload);
setGeneratedToken(response.data.data.token);
toast.success('API Token created successfully');
fetchTokens(); // Refresh list
} catch (error) {
console.error('Failed to create token:', error);
toast.error('Failed to create API token');
} finally {
setIsCreating(false);
}
};
const handleRevokeToken = (token: ApiToken) => {
setTokenToRevoke(token);
};
const confirmRevokeToken = async () => {
if (!tokenToRevoke) return;
try {
await axios.delete(`/api-tokens/${tokenToRevoke.id}`);
toast.success('Token revoked successfully');
setTokens(tokens.filter(t => t.id !== tokenToRevoke.id));
setTokenToRevoke(null);
} catch (error) {
console.error('Failed to revoke token:', error);
toast.error('Failed to revoke token');
}
};
const copyToClipboard = () => {
if (generatedToken) {
navigator.clipboard.writeText(generatedToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success('Token copied to clipboard');
}
};
const resetCreateModal = () => {
setShowCreateModal(false);
setNewTokenName('');
setNewTokenExpiry('');
setGeneratedToken(null);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900">API Tokens</h3>
<p className="text-sm text-gray-500">Manage personal access tokens for external integrations</p>
</div>
<Button onClick={() => setShowCreateModal(true)} size="sm" className="bg-re-green hover:bg-re-green/90 text-white">
<Plus className="w-4 h-4 mr-2" />
Generate
</Button>
</div>
{isLoading ? (
<div className="text-center py-4 text-gray-500">Loading tokens...</div>
) : tokens.length === 0 ? (
<div className="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-300">
<Key className="w-10 h-10 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 font-medium">No API tokens found</p>
<p className="text-gray-400 text-sm mt-1">Generate a token to access the API programmatically</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Prefix</TableHead>
<TableHead>Last Used</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tokens.map((token) => (
<TableRow key={token.id}>
<TableCell className="font-medium">{token.name}</TableCell>
<TableCell className="font-mono text-xs bg-slate-100 rounded px-2 py-1 w-fit">{token.prefix}...</TableCell>
<TableCell className="text-gray-500 text-sm">
{token.lastUsedAt ? format(new Date(token.lastUsedAt), 'MMM d, yyyy') : 'Never'}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{token.expiresAt ? format(new Date(token.expiresAt), 'MMM d, yyyy') : 'No Expiry'}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRevokeToken(token)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
<span className="sr-only">Revoke</span>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Create Token Modal */}
<Dialog open={showCreateModal} onOpenChange={(open) => !open && resetCreateModal()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate API Token</DialogTitle>
<DialogDescription>
Create a new token to access the API. Treat this token like a password.
</DialogDescription>
</DialogHeader>
{!generatedToken ? (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="token-name">Token Name</Label>
<Input
id="token-name"
placeholder="e.g., CI/CD Pipeline, Prometheus"
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="token-expiry">Expiration (Days)</Label>
<Input
id="token-expiry"
type="number"
min="1"
placeholder="Leave empty for no expiry"
value={newTokenExpiry}
onChange={(e) => {
const val = e.target.value;
if (val === '') {
setNewTokenExpiry('');
} else {
const num = parseInt(val);
// Prevent negative numbers
if (!isNaN(num) && num >= 1) {
setNewTokenExpiry(num);
}
}
}}
/>
</div>
</div>
) : (
<div className="space-y-4 py-4">
<Alert className="bg-green-50 border-green-200">
<Check className="h-4 w-4 text-green-600" />
<AlertTitle className="text-green-800">Token Generated Successfully</AlertTitle>
<AlertDescription className="text-green-700">
Please copy your token now. You won't be able to see it again!
</AlertDescription>
</Alert>
<div className="relative">
<div className="p-4 bg-slate-900 rounded-md font-mono text-sm text-green-400 break-all pr-10">
{generatedToken}
</div>
<Button
size="icon"
variant="ghost"
className="absolute top-1 right-1 text-gray-400 hover:text-white hover:bg-slate-800"
onClick={copyToClipboard}
>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
)}
<DialogFooter>
{!generatedToken ? (
<>
<Button variant="outline" onClick={resetCreateModal}>Cancel</Button>
<Button onClick={handleCreateToken} disabled={!newTokenName.trim() || isCreating}>
{isCreating ? 'Generating...' : 'Generate Token'}
</Button>
</>
) : (
<Button onClick={resetCreateModal}>Done</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!tokenToRevoke} onOpenChange={(open) => !open && setTokenToRevoke(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke API Token</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke the token <strong>{tokenToRevoke?.name}</strong>?
This action cannot be undone and any applications using this token will lose access immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRevokeToken} className="bg-red-600 hover:bg-red-700 text-white">
Revoke Token
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -45,18 +45,18 @@ export function ApprovalWorkflowStep({
useEffect(() => { useEffect(() => {
const approverCount = formData.approverCount || 1; const approverCount = formData.approverCount || 1;
const currentApprovers = formData.approvers || []; const currentApprovers = formData.approvers || [];
// Ensure we have the correct number of approvers // Ensure we have the correct number of approvers
if (currentApprovers.length < approverCount) { if (currentApprovers.length < approverCount) {
const newApprovers = [...currentApprovers]; const newApprovers = [...currentApprovers];
// Fill missing approver slots // Fill missing approver slots
for (let i = currentApprovers.length; i < approverCount; i++) { for (let i = currentApprovers.length; i < approverCount; i++) {
if (!newApprovers[i]) { if (!newApprovers[i]) {
newApprovers[i] = { newApprovers[i] = {
email: '', email: '',
name: '', name: '',
level: i + 1, level: i + 1,
tat: '' as any tat: '' as any
}; };
} }
} }
@ -71,7 +71,7 @@ export function ApprovalWorkflowStep({
const newApprovers = [...formData.approvers]; const newApprovers = [...formData.approvers];
const previousEmail = newApprovers[index]?.email; const previousEmail = newApprovers[index]?.email;
const emailChanged = previousEmail !== value; const emailChanged = previousEmail !== value;
newApprovers[index] = { newApprovers[index] = {
...newApprovers[index], ...newApprovers[index],
email: value, email: value,
@ -94,8 +94,8 @@ export function ApprovalWorkflowStep({
try { try {
// Check for duplicates in other approver slots (excluding current index) // Check for duplicates in other approver slots (excluding current index)
const isDuplicateApprover = formData.approvers?.some( const isDuplicateApprover = formData.approvers?.some(
(approver: any, idx: number) => (approver: any, idx: number) =>
idx !== index && idx !== index &&
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase()) (approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
); );
@ -196,9 +196,9 @@ export function ApprovalWorkflowStep({
<div data-testid="approval-workflow-count-field"> <div data-testid="approval-workflow-count-field">
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label> <Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const currentCount = formData.approverCount || 1; const currentCount = formData.approverCount || 1;
@ -216,14 +216,14 @@ export function ApprovalWorkflowStep({
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display"> <span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
{formData.approverCount || 1} {formData.approverCount || 1}
</span> </span>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const currentCount = formData.approverCount || 1; const currentCount = formData.approverCount || 1;
const newCount = currentCount + 1; const newCount = currentCount + 1;
// Validate against system policy // Validate against system policy
if (newCount > systemPolicy.maxApprovalLevels) { if (newCount > systemPolicy.maxApprovalLevels) {
onPolicyViolation([{ onPolicyViolation([{
@ -234,7 +234,7 @@ export function ApprovalWorkflowStep({
}]); }]);
return; return;
} }
updateFormData('approverCount', newCount); updateFormData('approverCount', newCount);
}} }}
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels} disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
@ -282,13 +282,13 @@ export function ApprovalWorkflowStep({
{Array.from({ length: formData.approverCount || 1 }, (_, index) => { {Array.from({ length: formData.approverCount || 1 }, (_, index) => {
const level = index + 1; const level = index + 1;
const isLast = level === (formData.approverCount || 1); const isLast = level === (formData.approverCount || 1);
// Ensure approver exists (should be initialized by useEffect, but provide fallback) // Ensure approver exists (should be initialized by useEffect, but provide fallback)
const approver = formData.approvers[index] || { const approver = formData.approvers[index] || {
email: '', email: '',
name: '', name: '',
level: level, level: level,
tat: '' as any tat: '' as any
}; };
return ( return (
@ -296,16 +296,18 @@ export function ApprovalWorkflowStep({
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-px h-6 bg-gray-300"></div> <div className="w-px h-6 bg-gray-300"></div>
</div> </div>
<div className={`p-4 rounded-lg border-2 transition-all ${approver.email <div className={`p-4 rounded-lg border-2 transition-all ${
? 'border-green-200 bg-green-50' approver.email
: 'border-gray-200 bg-gray-50' ? 'border-green-200 bg-green-50'
}`}> : 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${approver.email <div className={`w-10 h-10 rounded-full flex items-center justify-center ${
? 'bg-green-600' approver.email
: 'bg-gray-400' ? 'bg-green-600'
}`}> : 'bg-gray-400'
}`}>
<span className="text-white font-semibold">{level}</span> <span className="text-white font-semibold">{level}</span>
</div> </div>
<div className="flex-1"> <div className="flex-1">
@ -334,7 +336,7 @@ export function ApprovalWorkflowStep({
<Input <Input
id={`approver-${level}`} id={`approver-${level}`}
type="email" type="email"
placeholder={`approver@${import.meta.env.VITE_APP_DOMAIN}`} placeholder="approver@royalenfield.com"
value={approver.email || ''} value={approver.email || ''}
onChange={(e) => handleApproverEmailChange(index, e.target.value)} onChange={(e) => handleApproverEmailChange(index, e.target.value)}
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full" className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"

View File

@ -107,8 +107,8 @@ export function TemplateSelectionStep({
<p>No admin templates available yet.</p> <p>No admin templates available yet.</p>
</div> </div>
) : ( ) : (
displayTemplates.map((template, index) => { displayTemplates.map((template) => {
const isComingSoon = index === 1; const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
const isDisabled = isComingSoon; const isDisabled = isComingSoon;
const isCategoryCard = template.id === 'admin-templates-category'; const isCategoryCard = template.id === 'admin-templates-category';
// const isCustomCard = template.id === 'custom'; // const isCustomCard = template.id === 'custom';
@ -124,7 +124,7 @@ export function TemplateSelectionStep({
> >
<Card <Card
className={`h-full transition-all duration-300 border-2 ${isDisabled className={`h-full transition-all duration-300 border-2 ${isDisabled
? 'border-gray-200 bg-gray-50/50 opacity-50 cursor-not-allowed' ? 'border-gray-200 bg-gray-50/50 opacity-85 cursor-not-allowed'
: isSelected : isSelected
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer' ? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer'
: isCategoryCard : isCategoryCard

View File

@ -73,15 +73,15 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout) // PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
const logoutFlag = sessionStorage.getItem('__logout_in_progress__'); const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__'); const forceLogout = sessionStorage.getItem('__force_logout__');
if (logoutFlag === 'true' || forceLogout === 'true') { if (logoutFlag === 'true' || forceLogout === 'true') {
// Remove flags // Remove flags
sessionStorage.removeItem('__logout_in_progress__'); sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__'); sessionStorage.removeItem('__force_logout__');
// Clear all tokens one more time (aggressive) // Clear all tokens one more time (aggressive)
TokenManager.clearAll(); TokenManager.clearAll();
// Also manually clear everything // Also manually clear everything
try { try {
localStorage.clear(); localStorage.clear();
@ -89,16 +89,16 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} catch (e) { } catch (e) {
console.error('Error clearing storage:', e); console.error('Error clearing storage:', e);
} }
// Set unauthenticated state // Set unauthenticated state
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
return; return;
} }
// PRIORITY 2: Check if URL has logout parameter (from redirect) // PRIORITY 2: Check if URL has logout parameter (from redirect)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) { if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
@ -127,43 +127,43 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
window.history.replaceState({}, document.title, newUrl); window.history.replaceState({}, document.title, newUrl);
return; return;
} }
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first // PRIORITY 3: Skip auth check if on callback page - let callback handler process first
// This is essential for production mode where we need to exchange code for tokens // This is critical for production mode where we need to exchange code for tokens
// before we can verify session with server // before we can verify session with server
if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') { if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
// Don't check auth status here - let the callback handler do its job // Don't check auth status here - let the callback handler do its job
// The callback handler will set isAuthenticated after successful token exchange // The callback handler will set isAuthenticated after successful token exchange
return; return;
} }
// PRIORITY 4: Check authentication status // PRIORITY 4: Check authentication status
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
const refreshToken = TokenManager.getRefreshToken(); const refreshToken = TokenManager.getRefreshToken();
const userData = TokenManager.getUserData(); const userData = TokenManager.getUserData();
const hasAuthData = token || refreshToken || userData; const hasAuthData = token || refreshToken || userData;
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS) // Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production: Always verify with server (cookies are sent automatically) // In production: Always verify with server (cookies are sent automatically)
// In development: Check local auth data first // In development: Check local auth data first
if (isProductionMode) { if (isProductionMode) {
// Prod: Verify session with server via httpOnly cookie // Production: Verify session with server via httpOnly cookie
if (!isLoggingOut) { if (!isLoggingOut) {
checkAuthStatus(); checkAuthStatus();
} else { } else {
setIsLoading(false); setIsLoading(false);
} }
} else { } else {
// Dev: If no auth data exists, user is not authenticated // Development: If no auth data exists, user is not authenticated
if (!hasAuthData) { if (!hasAuthData) {
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setIsLoading(false); setIsLoading(false);
return; return;
} }
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out // PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) { if (!isLoggingOut) {
checkAuthStatus(); checkAuthStatus();
@ -211,7 +211,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Handle callback from OAuth redirect // Handle callback from OAuth redirect
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev) // Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
const callbackProcessedRef = useRef(false); const callbackProcessedRef = useRef(false);
useEffect(() => { useEffect(() => {
// Skip if already processed or not on callback page // Skip if already processed or not on callback page
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') { if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
@ -220,7 +220,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const handleCallback = async () => { const handleCallback = async () => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
// Check if this is a logout redirect (from Tanflow post-logout redirect) // Check if this is a logout redirect (from Tanflow post-logout redirect)
// If it has logout parameters but no code, it's a logout redirect, not a login callback // If it has logout parameters but no code, it's a logout redirect, not a login callback
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) { if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
@ -236,10 +236,10 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
window.location.replace(redirectUrl); window.location.replace(redirectUrl);
return; return;
} }
// Mark as processed immediately to prevent duplicate calls // Mark as processed immediately to prevent duplicate calls
callbackProcessedRef.current = true; callbackProcessedRef.current = true;
const code = urlParams.get('code'); const code = urlParams.get('code');
const errorParam = urlParams.get('error'); const errorParam = urlParams.get('error');
@ -248,7 +248,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Detect provider from sessionStorage // Detect provider from sessionStorage
const authProvider = sessionStorage.getItem('auth_provider'); const authProvider = sessionStorage.getItem('auth_provider');
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component) // If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
if (authProvider === 'tanflow') { if (authProvider === 'tanflow') {
// Clear the provider flag and let TanflowCallback handle it // Clear the provider flag and let TanflowCallback handle it
@ -277,21 +277,21 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsLoading(true); setIsLoading(true);
setIsAuthenticated(false); setIsAuthenticated(false);
setError(null); setError(null);
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request // IMPORTANT: redirectUri must match the one used in initial Okta authorization request
// This is the frontend callback URL, NOT the backend URL // This is the frontend callback URL, NOT the backend URL
// Backend will use this same URI when exchanging code with Okta // Backend will use this same URI when exchanging code with Okta
const redirectUri = `${window.location.origin}/login/callback`; const redirectUri = `${window.location.origin}/login/callback`;
const result = await exchangeCodeForTokens(code, redirectUri); const result = await exchangeCodeForTokens(code, redirectUri);
setUser(result.user); setUser(result.user);
setIsAuthenticated(true); setIsAuthenticated(true);
setError(null); setError(null);
// Clear provider flag after successful authentication // Clear provider flag after successful authentication
sessionStorage.removeItem('auth_provider'); sessionStorage.removeItem('auth_provider');
// Clean URL after success // Clean URL after success
window.history.replaceState({}, document.title, '/'); window.history.replaceState({}, document.title, '/');
} catch (err: any) { } catch (err: any) {
@ -317,17 +317,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsLoading(false); setIsLoading(false);
return; return;
} }
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
setIsLoading(true); setIsLoading(true);
// Prod MODE: Verify session via httpOnly cookie // PRODUCTION MODE: Verify session via httpOnly cookie
// The cookie is sent automatically with the request (withCredentials: true) // The cookie is sent automatically with the request (withCredentials: true)
if (isProductionMode) { if (isProductionMode) {
const storedUser = TokenManager.getUserData(); const storedUser = TokenManager.getUserData();
// Try to get current user from server - this validates the httpOnly cookie // Try to get current user from server - this validates the httpOnly cookie
try { try {
const userData = await getCurrentUser(); const userData = await getCurrentUser();
@ -368,8 +368,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} }
return; return;
} }
// Dev MODE: Check local token // DEVELOPMENT MODE: Check local token
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
const storedUser = TokenManager.getUserData(); const storedUser = TokenManager.getUserData();
@ -454,7 +454,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
try { try {
setError(null); setError(null);
// Redirect to Okta login // Redirect to Okta login
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || '{{IDP_DOMAIN}}'; const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || 'https://dev-830839.oktapreview.com';
const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8'; const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8';
const redirectUri = `${window.location.origin}/login/callback`; const redirectUri = `${window.location.origin}/login/callback`;
const responseType = 'code'; const responseType = 'code';
@ -467,14 +467,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication // Check if we're coming from a logout - if so, add prompt=login to force re-authentication
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out'); const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` + let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
`client_id=${clientId}&` + `client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` + `redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=${responseType}&` + `response_type=${responseType}&` +
`scope=${encodeURIComponent(scope)}&` + `scope=${encodeURIComponent(scope)}&` +
`state=${state}`; `state=${state}`;
// Add prompt=login if coming from logout to force re-authentication // Add prompt=login if coming from logout to force re-authentication
// This ensures Okta requires login even if a session still exists // This ensures Okta requires login even if a session still exists
if (isAfterLogout) { if (isAfterLogout) {
@ -490,28 +490,28 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const logout = async () => { const logout = async () => {
try { try {
//: Get id_token from TokenManager before clearing anything // CRITICAL: Get id_token from TokenManager before clearing anything
// Needed for both Okta and Tanflow logout endpoints // Needed for both Okta and Tanflow logout endpoints
const idToken = TokenManager.getIdToken(); const idToken = TokenManager.getIdToken();
// Detect which provider was used for login (check sessionStorage or user data) // Detect which provider was used for login (check sessionStorage or user data)
// If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern // If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern
const authProvider = sessionStorage.getItem('auth_provider') || const authProvider = sessionStorage.getItem('auth_provider') ||
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) || (idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
'okta'; // Default to OKTA if unknown 'okta'; // Default to OKTA if unknown
// Set logout flag to prevent auto-authentication after redirect // Set logout flag to prevent auto-authentication after redirect
// This must be set BEFORE clearing storage so it survives // This must be set BEFORE clearing storage so it survives
sessionStorage.setItem('__logout_in_progress__', 'true'); sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true'); sessionStorage.setItem('__force_logout__', 'true');
setIsLoggingOut(true); setIsLoggingOut(true);
// Reset auth state FIRST to prevent any re-authentication // Reset auth state FIRST to prevent any re-authentication
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setError(null); setError(null);
setIsLoading(true); // Set loading to prevent checkAuthStatus from running setIsLoading(true); // Set loading to prevent checkAuthStatus from running
// Call backend logout API to clear server-side session and httpOnly cookies // Call backend logout API to clear server-side session and httpOnly cookies
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies // IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
try { try {
@ -522,17 +522,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared'); console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
// Continue with logout even if API call fails // Continue with logout even if API call fails
} }
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens // Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__'); const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__'); const forceLogout = sessionStorage.getItem('__force_logout__');
const storedAuthProvider = sessionStorage.getItem('auth_provider'); const storedAuthProvider = sessionStorage.getItem('auth_provider');
// Clear all tokens EXCEPT id_token (we need it for provider logout) // Clear all tokens EXCEPT id_token (we need it for provider logout)
// Note: We'll clear id_token after provider logout // Note: We'll clear id_token after provider logout
// Clear tokens (but we'll restore id_token if needed) // Clear tokens (but we'll restore id_token if needed)
TokenManager.clearAll(); TokenManager.clearAll();
// Restore logout flags and id_token immediately after clearAll // Restore logout flags and id_token immediately after clearAll
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress); if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout); if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
@ -542,10 +542,10 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
if (storedAuthProvider) { if (storedAuthProvider) {
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
} }
// Small delay to ensure sessionStorage is written before redirect // Small delay to ensure sessionStorage is written before redirect
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
// Handle provider-specific logout // Handle provider-specific logout
if (authProvider === 'tanflow' && idToken) { if (authProvider === 'tanflow' && idToken) {
console.log('🚪 Initiating Tanflow logout...'); console.log('🚪 Initiating Tanflow logout...');
@ -560,7 +560,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Fall through to default logout flow // Fall through to default logout flow
} }
} }
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags // OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
console.log('🚪 Using OKTA logout flow or fallback'); console.log('🚪 Using OKTA logout flow or fallback');
sessionStorage.removeItem('auth_provider'); sessionStorage.removeItem('auth_provider');
@ -590,7 +590,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const getAccessTokenSilently = async (): Promise<string | null> => { const getAccessTokenSilently = async (): Promise<string | null> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production mode, tokens are in httpOnly cookies // In production mode, tokens are in httpOnly cookies
// We can't access them directly, but API calls will include them automatically // We can't access them directly, but API calls will include them automatically
if (isProductionMode) { if (isProductionMode) {
@ -599,7 +599,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
if (isAuthenticated) { if (isAuthenticated) {
return 'cookie-based-auth'; // Placeholder - actual auth via cookies return 'cookie-based-auth'; // Placeholder - actual auth via cookies
} }
// Try to refresh the session // Try to refresh the session
try { try {
await refreshTokenSilently(); await refreshTokenSilently();
@ -608,8 +608,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return null; return null;
} }
} }
// Dev mode: tokens in localStorage // Development mode: tokens in localStorage
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) { if (token && !isTokenExpired(token)) {
return token; return token;
@ -626,17 +626,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const refreshTokenSilently = async (): Promise<void> => { const refreshTokenSilently = async (): Promise<void> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
const newToken = await refreshAccessToken(); const newToken = await refreshAccessToken();
// In production, refresh might not return token (it's in httpOnly cookie) // In production, refresh might not return token (it's in httpOnly cookie)
// but if the call succeeded, the session is valid // but if the call succeeded, the session is valid
if (isProductionMode) { if (isProductionMode) {
// Session refreshed via cookies // Session refreshed via cookies
return; return;
} }
if (newToken) { if (newToken) {
// Token refreshed successfully (development mode) // Token refreshed successfully (development mode)
return; return;
@ -672,7 +672,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
export function _Auth0AuthProvider({ children }: { children: ReactNode }) { export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
return ( return (
<Auth0Provider <Auth0Provider
domain="{{IDP_DOMAIN}}/oauth2/default/v1" domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
clientId="0oa2j8slwj5S4bG5k0h8" clientId="0oa2j8slwj5S4bG5k0h8"
authorizationParams={{ authorizationParams={{
redirect_uri: window.location.origin + '/login/callback', redirect_uri: window.location.origin + '/login/callback',

View File

@ -31,14 +31,14 @@ export function StandardClosedRequestsFilters({
searchTerm, searchTerm,
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter: _templateTypeFilter, // templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
activeFiltersCount, activeFiltersCount,
onSearchChange, onSearchChange,
onPriorityChange, onPriorityChange,
onStatusChange, onStatusChange,
onTemplateTypeChange: _onTemplateTypeChange, // onTemplateTypeChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
@ -130,8 +130,8 @@ export function StandardClosedRequestsFilters({
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{/*
{/* <Select value={_templateTypeFilter} onValueChange={_onTemplateTypeChange}> <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>

View File

@ -31,13 +31,13 @@ export function StandardRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter: _templateTypeFilter, // templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
onSearchChange, onSearchChange,
onStatusFilterChange, onStatusFilterChange,
onPriorityFilterChange, onPriorityFilterChange,
onTemplateTypeFilterChange: _onTemplateTypeFilterChange, // onTemplateTypeFilterChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,

View File

@ -87,7 +87,7 @@ export function StandardUserAllRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter: _templateTypeFilter, // templateTypeFilter,
departmentFilter, departmentFilter,
slaComplianceFilter, slaComplianceFilter,
initiatorFilter: _initiatorFilter, initiatorFilter: _initiatorFilter,
@ -104,7 +104,7 @@ export function StandardUserAllRequestsFilters({
onSearchChange, onSearchChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
onTemplateTypeChange: _onTemplateTypeChange, // onTemplateTypeChange,
onDepartmentChange, onDepartmentChange,
onSlaComplianceChange, onSlaComplianceChange,
onInitiatorChange: _onInitiatorChange, onInitiatorChange: _onInitiatorChange,
@ -180,7 +180,7 @@ export function StandardUserAllRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
{/* <Select value={_templateTypeFilter} onValueChange={_onTemplateTypeChange}> {/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-10" data-testid="template-type-filter"> <SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>

View File

@ -70,7 +70,7 @@ export function ClaimApproverSelectionStep({
onPolicyViolation, onPolicyViolation,
}: ClaimApproverSelectionStepProps) { }: ClaimApproverSelectionStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch(); const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
// State for add approver modal // State for add approver modal
const [showAddApproverModal, setShowAddApproverModal] = useState(false); const [showAddApproverModal, setShowAddApproverModal] = useState(false);
const [addApproverEmail, setAddApproverEmail] = useState(''); const [addApproverEmail, setAddApproverEmail] = useState('');
@ -96,7 +96,7 @@ export function ClaimApproverSelectionStep({
// For manual steps (3 and 8), check if approver is assigned, verified, and has TAT // For manual steps (3 and 8), check if approver is assigned, verified, and has TAT
const approver = approvers.find((a: ClaimApprover) => a.level === step.level); const approver = approvers.find((a: ClaimApprover) => a.level === step.level);
if (!approver || !approver.email || !approver.userId || !approver.tat) { if (!approver || !approver.email || !approver.userId || !approver.tat) {
missingSteps.push(`${step.name}`); missingSteps.push(`${step.name}`);
} }
@ -120,20 +120,20 @@ export function ClaimApproverSelectionStep({
// Initialize approvers array for all 8 steps // Initialize approvers array for all 8 steps
useEffect(() => { useEffect(() => {
const currentApprovers = formData.approvers || []; const currentApprovers = formData.approvers || [];
// If we already have approvers (including additional ones), don't reinitialize // If we already have approvers (including additional ones), don't reinitialize
// This prevents creating duplicates when approvers have been shifted // This prevents creating duplicates when approvers have been shifted
if (currentApprovers.length > 0) { if (currentApprovers.length > 0) {
// Just ensure all fixed steps have their approvers, but don't recreate shifted ones // Just ensure all fixed steps have their approvers, but don't recreate shifted ones
const newApprovers: ClaimApprover[] = []; const newApprovers: ClaimApprover[] = [];
const additionalApprovers = currentApprovers.filter((a: ClaimApprover) => a.isAdditional); const additionalApprovers = currentApprovers.filter((a: ClaimApprover) => a.isAdditional);
CLAIM_STEPS.forEach((step) => { CLAIM_STEPS.forEach((step) => {
// Find existing approver by originalStepLevel (handles shifted levels) // Find existing approver by originalStepLevel (handles shifted levels)
const existing = currentApprovers.find((a: ClaimApprover) => const existing = currentApprovers.find((a: ClaimApprover) =>
a.originalStepLevel === step.level || (!a.originalStepLevel && !a.isAdditional && a.level === step.level) a.originalStepLevel === step.level || (!a.originalStepLevel && !a.isAdditional && a.level === step.level)
); );
if (existing) { if (existing) {
// Use existing approver (preserves shifted level) // Use existing approver (preserves shifted level)
newApprovers.push(existing); newApprovers.push(existing);
@ -141,7 +141,7 @@ export function ClaimApproverSelectionStep({
// Create new approver only if it doesn't exist // Create new approver only if it doesn't exist
if (step.isAuto) { if (step.isAuto) {
// System steps // System steps
const systemEmail = step.level === 8 ? `finance@${import.meta.env.VITE_APP_DOMAIN}` : `system@${import.meta.env.VITE_APP_DOMAIN}`; const systemEmail = step.level === 8 ? 'finance@royalenfield.com' : 'system@royalenfield.com';
const systemName = step.level === 8 ? 'System/Finance' : 'System'; const systemName = step.level === 8 ? 'System/Finance' : 'System';
newApprovers.push({ newApprovers.push({
email: systemEmail, email: systemEmail,
@ -182,19 +182,19 @@ export function ClaimApproverSelectionStep({
} }
} }
}); });
// Add back all additional approvers // Add back all additional approvers
additionalApprovers.forEach((addApprover: ClaimApprover) => { additionalApprovers.forEach((addApprover: ClaimApprover) => {
newApprovers.push(addApprover); newApprovers.push(addApprover);
}); });
// Sort by level // Sort by level
newApprovers.sort((a, b) => a.level - b.level); newApprovers.sort((a, b) => a.level - b.level);
// Only update if there are actual changes (to avoid infinite loops) // Only update if there are actual changes (to avoid infinite loops)
const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !== const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !==
JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))); JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel })));
if (hasChanges) { if (hasChanges) {
updateFormData('approvers', newApprovers); updateFormData('approvers', newApprovers);
} }
@ -246,10 +246,10 @@ export function ClaimApproverSelectionStep({
const handleApproverEmailChange = (level: number, value: string) => { const handleApproverEmailChange = (level: number, value: string) => {
const approvers = [...(formData.approvers || [])]; const approvers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility // Find by originalStepLevel first, then fallback to level for backwards compatibility
const index = approvers.findIndex((a: ClaimApprover) => const index = approvers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
); );
if (index === -1) { if (index === -1) {
// Create new approver entry // Create new approver entry
const step = CLAIM_STEPS.find(s => s.level === level); const step = CLAIM_STEPS.find(s => s.level === level);
@ -304,8 +304,8 @@ export function ClaimApproverSelectionStep({
// Check for duplicates across other steps // Check for duplicates across other steps
const approvers = formData.approvers || []; const approvers = formData.approvers || [];
const isDuplicate = approvers.some( const isDuplicate = approvers.some(
(a: ClaimApprover) => (a: ClaimApprover) =>
a.level !== level && a.level !== level &&
(a.userId === selectedUser.userId || a.email?.toLowerCase() === selectedUser.email?.toLowerCase()) (a.userId === selectedUser.userId || a.email?.toLowerCase() === selectedUser.email?.toLowerCase())
); );
@ -343,10 +343,10 @@ export function ClaimApproverSelectionStep({
// Update approver in array // Update approver in array
const updatedApprovers = [...(formData.approvers || [])]; const updatedApprovers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility // Find by originalStepLevel first, then fallback to level for backwards compatibility
const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) => const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
); );
if (approverIndex === -1) { if (approverIndex === -1) {
const step = CLAIM_STEPS.find(s => s.level === level); const step = CLAIM_STEPS.find(s => s.level === level);
updatedApprovers.push({ updatedApprovers.push({
@ -391,10 +391,10 @@ export function ClaimApproverSelectionStep({
const handleTatChange = (level: number, tat: number | string) => { const handleTatChange = (level: number, tat: number | string) => {
const approvers = [...(formData.approvers || [])]; const approvers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility // Find by originalStepLevel first, then fallback to level for backwards compatibility
const index = approvers.findIndex((a: ClaimApprover) => const index = approvers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
); );
if (index !== -1) { if (index !== -1) {
const existingApprover = approvers[index]; const existingApprover = approvers[index];
if (existingApprover) { if (existingApprover) {
@ -410,10 +410,10 @@ export function ClaimApproverSelectionStep({
const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => { const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => {
const approvers = [...(formData.approvers || [])]; const approvers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility // Find by originalStepLevel first, then fallback to level for backwards compatibility
const index = approvers.findIndex((a: ClaimApprover) => const index = approvers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional) a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
); );
if (index !== -1) { if (index !== -1) {
const existingApprover = approvers[index]; const existingApprover = approvers[index];
if (existingApprover) { if (existingApprover) {
@ -430,12 +430,12 @@ export function ClaimApproverSelectionStep({
// Handle adding additional approver between steps // Handle adding additional approver between steps
const handleAddApproverEmailChange = (value: string) => { const handleAddApproverEmailChange = (value: string) => {
setAddApproverEmail(value); setAddApproverEmail(value);
// Clear selectedUser when manually editing // Clear selectedUser when manually editing
if (selectedAddApproverUser && selectedAddApproverUser.email.toLowerCase() !== value.toLowerCase()) { if (selectedAddApproverUser && selectedAddApproverUser.email.toLowerCase() !== value.toLowerCase()) {
setSelectedAddApproverUser(null); setSelectedAddApproverUser(null);
} }
// Clear existing timer // Clear existing timer
if (addApproverSearchTimer.current) { if (addApproverSearchTimer.current) {
clearTimeout(addApproverSearchTimer.current); clearTimeout(addApproverSearchTimer.current);
@ -484,7 +484,7 @@ export function ClaimApproverSelectionStep({
secondEmail: user.secondEmail, secondEmail: user.secondEmail,
location: user.location location: user.location
}); });
setAddApproverEmail(user.email); setAddApproverEmail(user.email);
setSelectedAddApproverUser(user); setSelectedAddApproverUser(user);
setAddApproverSearchResults([]); setAddApproverSearchResults([]);
@ -497,7 +497,7 @@ export function ClaimApproverSelectionStep({
const handleConfirmAddApprover = async () => { const handleConfirmAddApprover = async () => {
const emailToAdd = addApproverEmail.trim().toLowerCase(); const emailToAdd = addApproverEmail.trim().toLowerCase();
if (!emailToAdd) { if (!emailToAdd) {
toast.error('Please enter an email address'); toast.error('Please enter an email address');
return; return;
@ -540,7 +540,7 @@ export function ClaimApproverSelectionStep({
// Check for duplicates // Check for duplicates
const approvers = formData.approvers || []; const approvers = formData.approvers || [];
const isDuplicate = approvers.some( const isDuplicate = approvers.some(
(a: ClaimApprover) => (a: ClaimApprover) =>
(a.userId && selectedAddApproverUser?.userId && a.userId === selectedAddApproverUser.userId) || (a.userId && selectedAddApproverUser?.userId && a.userId === selectedAddApproverUser.userId) ||
a.email?.toLowerCase() === emailToAdd a.email?.toLowerCase() === emailToAdd
); );
@ -552,15 +552,15 @@ export function ClaimApproverSelectionStep({
// Find the approver for the selected step by its originalStepLevel // Find the approver for the selected step by its originalStepLevel
// This handles cases where steps have been shifted due to previous additional approvers // This handles cases where steps have been shifted due to previous additional approvers
const approverAfter = approvers.find((a: ClaimApprover) => const approverAfter = approvers.find((a: ClaimApprover) =>
a.originalStepLevel === addApproverInsertAfter || a.originalStepLevel === addApproverInsertAfter ||
(!a.originalStepLevel && !a.isAdditional && a.level === addApproverInsertAfter) (!a.originalStepLevel && !a.isAdditional && a.level === addApproverInsertAfter)
); );
// Get the current level of the approver we're inserting after // Get the current level of the approver we're inserting after
// If the step has been shifted, use its current level; otherwise use the original level // If the step has been shifted, use its current level; otherwise use the original level
const currentLevelAfter = approverAfter ? approverAfter.level : addApproverInsertAfter; const currentLevelAfter = approverAfter ? approverAfter.level : addApproverInsertAfter;
// Calculate insert level based on current shifted level // Calculate insert level based on current shifted level
const insertLevel = currentLevelAfter + 1; const insertLevel = currentLevelAfter + 1;
@ -570,7 +570,7 @@ export function ClaimApproverSelectionStep({
// After shifting, we'll have the same number of unique levels + 1 (the new approver) // After shifting, we'll have the same number of unique levels + 1 (the new approver)
const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size; const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size;
const newTotalLevels = currentUniqueLevels + 1; const newTotalLevels = currentUniqueLevels + 1;
if (newTotalLevels > maxApprovalLevels) { if (newTotalLevels > maxApprovalLevels) {
const violations = [{ const violations = [{
type: 'max_approval_levels', type: 'max_approval_levels',
@ -578,7 +578,7 @@ export function ClaimApproverSelectionStep({
currentValue: newTotalLevels, currentValue: newTotalLevels,
maxValue: maxApprovalLevels maxValue: maxApprovalLevels
}]; }];
if (onPolicyViolation) { if (onPolicyViolation) {
onPolicyViolation(violations); onPolicyViolation(violations);
} else { } else {
@ -593,12 +593,12 @@ export function ClaimApproverSelectionStep({
try { try {
const response = await searchUsers(emailToAdd, 1); const response = await searchUsers(emailToAdd, 1);
const searchOktaResults = response.data?.data || []; const searchOktaResults = response.data?.data || [];
if (searchOktaResults.length === 0) { if (searchOktaResults.length === 0) {
toast.error('User not found in organization directory. Please use @ to search for users.'); toast.error('User not found in organization directory. Please use @ to search for users.');
return; return;
} }
const foundUser = searchOktaResults[0]; const foundUser = searchOktaResults[0];
await ensureUserExists({ await ensureUserExists({
userId: foundUser.userId, userId: foundUser.userId,
@ -617,7 +617,7 @@ export function ClaimApproverSelectionStep({
secondEmail: foundUser.secondEmail, secondEmail: foundUser.secondEmail,
location: foundUser.location location: foundUser.location
}); });
// Use found user - insert at integer level and shift subsequent approvers // Use found user - insert at integer level and shift subsequent approvers
// insertLevel is already calculated above based on current shifted level // insertLevel is already calculated above based on current shifted level
const newApprover: ClaimApprover = { const newApprover: ClaimApprover = {
@ -631,7 +631,7 @@ export function ClaimApproverSelectionStep({
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
stepName: `Additional Approver - ${foundUser.displayName || foundUser.email}`, stepName: `Additional Approver - ${foundUser.displayName || foundUser.email}`,
}; };
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional) // Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
const updatedApprovers = approvers.map((a: ClaimApprover) => { const updatedApprovers = approvers.map((a: ClaimApprover) => {
if (a.level >= insertLevel) { if (a.level >= insertLevel) {
@ -639,13 +639,13 @@ export function ClaimApproverSelectionStep({
} }
return a; return a;
}); });
// Insert the new approver // Insert the new approver
updatedApprovers.push(newApprover); updatedApprovers.push(newApprover);
// Sort by level to maintain order // Sort by level to maintain order
updatedApprovers.sort((a, b) => a.level - b.level); updatedApprovers.sort((a, b) => a.level - b.level);
updateFormData('approvers', updatedApprovers); updateFormData('approvers', updatedApprovers);
toast.success(`Additional approver added and subsequent steps shifted`); toast.success(`Additional approver added and subsequent steps shifted`);
} catch (error) { } catch (error) {
@ -667,7 +667,7 @@ export function ClaimApproverSelectionStep({
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
stepName: `Additional Approver - ${selectedAddApproverUser.displayName || selectedAddApproverUser.email}`, stepName: `Additional Approver - ${selectedAddApproverUser.displayName || selectedAddApproverUser.email}`,
}; };
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional) // Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
const updatedApprovers = approvers.map((a: ClaimApprover) => { const updatedApprovers = approvers.map((a: ClaimApprover) => {
if (a.level >= insertLevel) { if (a.level >= insertLevel) {
@ -675,13 +675,13 @@ export function ClaimApproverSelectionStep({
} }
return a; return a;
}); });
// Insert the new approver // Insert the new approver
updatedApprovers.push(newApprover); updatedApprovers.push(newApprover);
// Sort by level to maintain order // Sort by level to maintain order
updatedApprovers.sort((a, b) => a.level - b.level); updatedApprovers.sort((a, b) => a.level - b.level);
updateFormData('approvers', updatedApprovers); updateFormData('approvers', updatedApprovers);
toast.success(`Additional approver added and subsequent steps shifted`); toast.success(`Additional approver added and subsequent steps shifted`);
} }
@ -699,12 +699,12 @@ export function ClaimApproverSelectionStep({
const handleRemoveAdditionalApprover = (level: number) => { const handleRemoveAdditionalApprover = (level: number) => {
const approvers = [...(formData.approvers || [])]; const approvers = [...(formData.approvers || [])];
const approverToRemove = approvers.find((a: ClaimApprover) => a.level === level); const approverToRemove = approvers.find((a: ClaimApprover) => a.level === level);
if (!approverToRemove) return; if (!approverToRemove) return;
// Remove the additional approver // Remove the additional approver
const filtered = approvers.filter((a: ClaimApprover) => a.level !== level); const filtered = approvers.filter((a: ClaimApprover) => a.level !== level);
// Shift all approvers with level > removed level down by 1 // Shift all approvers with level > removed level down by 1
const updatedApprovers = filtered.map((a: ClaimApprover) => { const updatedApprovers = filtered.map((a: ClaimApprover) => {
if (a.level > level && !a.isAdditional) { if (a.level > level && !a.isAdditional) {
@ -712,10 +712,10 @@ export function ClaimApproverSelectionStep({
} }
return a; return a;
}); });
// Sort by level to maintain order // Sort by level to maintain order
updatedApprovers.sort((a, b) => a.level - b.level); updatedApprovers.sort((a, b) => a.level - b.level);
updateFormData('approvers', updatedApprovers); updateFormData('approvers', updatedApprovers);
toast.success('Additional approver removed and subsequent steps shifted back'); toast.success('Additional approver removed and subsequent steps shifted back');
}; };
@ -829,15 +829,15 @@ export function ClaimApproverSelectionStep({
{/* Dynamic Approver Cards - Filter out system steps (auto-processed) */} {/* Dynamic Approver Cards - Filter out system steps (auto-processed) */}
{(() => { {(() => {
// Count additional approvers before first step // Count additional approvers before first step
const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) => const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) =>
a.isAdditional && a.insertAfterLevel === 0 a.isAdditional && a.insertAfterLevel === 0
); );
let displayIndex = additionalBeforeFirst.length; // Start index after additional approvers before first step let displayIndex = additionalBeforeFirst.length; // Start index after additional approvers before first step
return CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => { return CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => {
// Find approver by originalStepLevel first, then fallback to level // Find approver by originalStepLevel first, then fallback to level
const approver = approvers.find((a: ClaimApprover) => const approver = approvers.find((a: ClaimApprover) =>
a.originalStepLevel === step.level || (!a.originalStepLevel && a.level === step.level && !a.isAdditional) a.originalStepLevel === step.level || (!a.originalStepLevel && a.level === step.level && !a.isAdditional)
) || { ) || {
email: '', email: '',
@ -856,17 +856,17 @@ export function ClaimApproverSelectionStep({
// Additional approvers inserted after this step will have insertAfterLevel === step.level // Additional approvers inserted after this step will have insertAfterLevel === step.level
// and their level will be step.level + 1 (or higher if multiple are added) // and their level will be step.level + 1 (or higher if multiple are added)
const additionalApproversAfter = sortedApprovers.filter( const additionalApproversAfter = sortedApprovers.filter(
(a: ClaimApprover) => (a: ClaimApprover) =>
a.isAdditional && a.isAdditional &&
a.insertAfterLevel === step.level a.insertAfterLevel === step.level
).sort((a, b) => a.level - b.level); ).sort((a, b) => a.level - b.level);
// Calculate current step's display number // Calculate current step's display number
const currentStepDisplayNumber = displayIndex + 1; const currentStepDisplayNumber = displayIndex + 1;
// Increment display index for this step // Increment display index for this step
displayIndex++; displayIndex++;
// Increment display index for each additional approver after this step // Increment display index for each additional approver after this step
displayIndex += additionalApproversAfter.length; displayIndex += additionalApproversAfter.length;
@ -875,259 +875,238 @@ export function ClaimApproverSelectionStep({
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-px h-3 bg-gray-300"></div> <div className="w-px h-3 bg-gray-300"></div>
</div> </div>
{/* Render additional approvers before this step if any */} {/* Render additional approvers before this step if any */}
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => { {index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
return ( return (
<div key={`additional-${addApprover.level}`} className="space-y-1"> <div key={`additional-${addApprover.level}`} className="space-y-1">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-px h-3 bg-gray-300"></div> <div className="w-px h-3 bg-gray-300"></div>
</div> </div>
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50"> <div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600"> <div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span> <span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-semibold text-gray-900 text-sm"> <span className="font-semibold text-gray-900 text-sm">
Additional Approver Additional Approver
</span> </span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300"> <Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL ADDITIONAL
</Badge> </Badge>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRemoveAdditionalApprover(addApprover.level)} onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50" className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</Button> </Button>
</div> </div>
<p className="text-xs text-gray-600 mb-2"> <p className="text-xs text-gray-600 mb-2">
{addApprover.name || addApprover.email} {addApprover.name || addApprover.email}
</p> </p>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
<div>Email: {addApprover.email}</div> <div>Email: {addApprover.email}</div>
<div>TAT: {addApprover.tat} {addApprover.tatType}</div> <div>TAT: {addApprover.tat} {addApprover.tatType}</div>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
);
})}
<div className={`p-3 rounded-lg border-2 transition-all ${approver.email && approver.userId
? 'border-green-200 bg-green-50'
: isPreFilled
? 'border-blue-200 bg-blue-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-start gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${approver.email && approver.userId
? 'bg-green-600'
: isPreFilled
? 'bg-blue-600'
: 'bg-gray-400'
}`}>
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-semibold text-gray-900 text-sm">
{step.name}
</span>
{isLast && (
<Badge variant="destructive" className="text-xs">FINAL</Badge>
)}
{isPreFilled && (
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
)}
</div>
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
{isEditable && (() => {
const isVerified = !!(approver.email && approver.userId);
const isEmpty = !approver.email && !isPreFilled;
return (
<div className="space-y-2">
<div>
<div className="flex items-center justify-between mb-1">
<Label htmlFor={`approver-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
}`}>
Approver Email {!isPreFilled && '*'}
{isEmpty && <span className="ml-2 text-[10px] font-semibold italic text-blue-600">(Required)</span>}
</Label>
{isVerified && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" />
Verified
</Badge>
)}
</div>
<div className="relative">
<Input
id={`approver-${step.level}`}
type="text"
placeholder={isPreFilled ? approver.email : "@username or email..."}
value={approver.email || ''}
onChange={(e) => {
const newValue = e.target.value;
if (!isPreFilled) {
handleApproverEmailChange(step.level, newValue);
}
}}
disabled={isPreFilled || step.isAuto}
className={`h-9 border-2 transition-all mt-1 w-full text-sm ${isPreFilled
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
: isVerified
? 'bg-green-50/50 border-green-600 focus:border-green-700 ring-offset-green-50 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
}`}
/>
{/* Search suggestions dropdown */}
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
{userSearchLoading[step.level - 1] ? (
<div className="p-2 text-xs text-gray-500">Searching...</div>
) : (
<ul className="max-h-56 overflow-auto divide-y">
{userSearchResults[step.level - 1]?.map((u) => (
<li
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleUserSelect(step.level, u)}
>
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
<div className="text-xs text-gray-600">{u.email}</div>
{u.department && (
<div className="text-xs text-gray-500">{u.department}</div>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
{approver.name && (
<p className="text-xs text-green-600 mt-1">
Selected: <span className="font-semibold">{approver.name}</span>
</p>
)}
</div>
<div>
<Label htmlFor={`tat-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
}`}>
TAT (Turn Around Time) *
</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id={`tat-${step.level}`}
type="number"
placeholder={approver.tatType === 'days' ? '7' : '24'}
min="1"
max={approver.tatType === 'days' ? '30' : '720'}
value={approver.tat || ''}
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
disabled={step.isAuto}
className={`h-9 border-2 transition-all flex-1 text-sm ${isPreFilled
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
: isVerified
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
}`}
/>
<Select
value={approver.tatType || 'hours'}
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
disabled={step.isAuto}
>
<SelectTrigger className={`w-20 h-9 border-2 transition-all text-sm ${isPreFilled
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed'
: isVerified
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 text-gray-900 font-medium'
: 'bg-white border-blue-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
);
})()}
</div>
</div> </div>
</div> );
})}
{/* Render additional approvers after this step */}
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => { <div className={`p-3 rounded-lg border-2 transition-all ${
// Additional approvers come after the current step, so they should be numbered after it approver.email && approver.userId
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1; ? 'border-green-200 bg-green-50'
return ( : isPreFilled
<div key={`additional-${addApprover.level}`} className="space-y-1"> ? 'border-blue-200 bg-blue-50'
<div className="flex justify-center"> : 'border-gray-200 bg-gray-50'
<div className="w-px h-3 bg-gray-300"></div> }`}>
</div> <div className="flex items-start gap-3">
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50"> <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
<div className="flex items-start gap-3"> approver.email && approver.userId
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600"> ? 'bg-green-600'
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span> : isPreFilled
</div> ? 'bg-blue-600'
<div className="flex-1 min-w-0"> : 'bg-gray-400'
<div className="flex items-center gap-2 mb-1 flex-wrap"> }`}>
<span className="font-semibold text-gray-900 text-sm"> <span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
{addApprover.stepName || 'Additional Approver'} </div>
</span> <div className="flex-1 min-w-0">
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300"> <div className="flex items-center gap-2 mb-1 flex-wrap">
ADDITIONAL <span className="font-semibold text-gray-900 text-sm">
{step.name}
</span>
{isLast && (
<Badge variant="destructive" className="text-xs">FINAL</Badge>
)}
{isPreFilled && (
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
)}
</div>
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
{isEditable && (
<div className="space-y-2">
<div>
<div className="flex items-center justify-between mb-1">
<Label htmlFor={`approver-${step.level}`} className="text-xs font-medium">
Email Address {!isPreFilled && '*'}
</Label>
{approver.email && approver.userId && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" />
Verified
</Badge> </Badge>
{addApprover.email && addApprover.userId && ( )}
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300"> </div>
<CheckCircle className="w-3 h-3 mr-1" /> <div className="relative">
Verified <Input
</Badge> id={`approver-${step.level}`}
)} type="text"
<Button placeholder={isPreFilled ? approver.email : "@approver@royalenfield.com"}
type="button" value={approver.email || ''}
variant="ghost" onChange={(e) => {
size="sm" const newValue = e.target.value;
onClick={() => handleRemoveAdditionalApprover(addApprover.level)} if (!isPreFilled) {
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50" handleApproverEmailChange(step.level, newValue);
> }
<X className="w-3 h-3" /> }}
</Button> disabled={isPreFilled || step.isAuto}
</div> className="h-9 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full text-sm"
<p className="text-xs text-gray-600 mb-2"> />
{addApprover.name || addApprover.email || 'No approver assigned'} {/* Search suggestions dropdown */}
</p> {!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
{addApprover.email && ( <div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
<div className="text-xs text-gray-500 space-y-1"> {userSearchLoading[step.level - 1] ? (
<div>Email: {addApprover.email}</div> <div className="p-2 text-xs text-gray-500">Searching...</div>
{addApprover.tat && ( ) : (
<div>TAT: {addApprover.tat} {addApprover.tatType}</div> <ul className="max-h-56 overflow-auto divide-y">
{userSearchResults[step.level - 1]?.map((u) => (
<li
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleUserSelect(step.level, u)}
>
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
<div className="text-xs text-gray-600">{u.email}</div>
{u.department && (
<div className="text-xs text-gray-500">{u.department}</div>
)}
</li>
))}
</ul>
)} )}
</div> </div>
)} )}
</div> </div>
{approver.name && (
<p className="text-xs text-green-600 mt-1">
Selected: <span className="font-semibold">{approver.name}</span>
</p>
)}
</div>
<div>
<Label htmlFor={`tat-${step.level}`} className="text-xs font-medium">
TAT (Turn Around Time) *
</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id={`tat-${step.level}`}
type="number"
placeholder={approver.tatType === 'days' ? '7' : '24'}
min="1"
max={approver.tatType === 'days' ? '30' : '720'}
value={approver.tat || ''}
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
disabled={step.isAuto}
className="h-9 border-2 border-gray-300 focus:border-blue-500 flex-1 text-sm"
/>
<Select
value={approver.tatType || 'hours'}
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
disabled={step.isAuto}
>
<SelectTrigger className="w-20 h-9 border-2 border-gray-300 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Render additional approvers after this step */}
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => {
// Additional approvers come after the current step, so they should be numbered after it
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1;
return (
<div key={`additional-${addApprover.level}`} className="space-y-1">
<div className="flex justify-center">
<div className="w-px h-3 bg-gray-300"></div>
</div>
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-semibold text-gray-900 text-sm">
{addApprover.stepName || 'Additional Approver'}
</span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL
</Badge>
{addApprover.email && addApprover.userId && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" />
Verified
</Badge>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-3 h-3" />
</Button>
</div>
<p className="text-xs text-gray-600 mb-2">
{addApprover.name || addApprover.email || 'No approver assigned'}
</p>
{addApprover.email && (
<div className="text-xs text-gray-500 space-y-1">
<div>Email: {addApprover.email}</div>
{addApprover.tat && (
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
); </div>
})} );
</div> })}
); </div>
);
}); });
})()} })()}
</CardContent> </CardContent>
@ -1146,17 +1125,17 @@ export function ClaimApproverSelectionStep({
{sortedApprovers.map((approver: ClaimApprover) => { {sortedApprovers.map((approver: ClaimApprover) => {
// Skip system/auto steps // Skip system/auto steps
// Find step by originalStepLevel first, then fallback to level // Find step by originalStepLevel first, then fallback to level
const step = approver.originalStepLevel const step = approver.originalStepLevel
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel) ? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
: CLAIM_STEPS.find(s => s.level === approver.level && !approver.isAdditional); : CLAIM_STEPS.find(s => s.level === approver.level && !approver.isAdditional);
if (step?.isAuto) return null; if (step?.isAuto) return null;
const tat = Number(approver.tat || 0); const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours'; const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat; const hours = tatType === 'days' ? tat * 24 : tat;
if (!tat) return null; if (!tat) return null;
// Handle additional approvers // Handle additional approvers
if (approver.isAdditional) { if (approver.isAdditional) {
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel); const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel);
@ -1169,7 +1148,7 @@ export function ClaimApproverSelectionStep({
</div> </div>
); );
} }
return ( return (
<div key={approver.level} className="flex items-center justify-between p-2 bg-gray-50 rounded"> <div key={approver.level} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{step?.name || 'Unknown'}</span> <span className="text-sm font-medium">{step?.name || 'Unknown'}</span>
@ -1194,13 +1173,13 @@ export function ClaimApproverSelectionStep({
Add an additional approver between workflow steps. The approver will be inserted after the selected step. Additional approvers cannot be added after "Requestor Claim Approval". Add an additional approver between workflow steps. The approver will be inserted after the selected step. Additional approvers cannot be added after "Requestor Claim Approval".
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
{/* Insert After Level Selection */} {/* Insert After Level Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Insert After Step *</Label> <Label className="text-sm font-medium">Insert After Step *</Label>
<Select <Select
value={addApproverInsertAfter.toString()} value={addApproverInsertAfter.toString()}
onValueChange={(value) => setAddApproverInsertAfter(Number(value))} onValueChange={(value) => setAddApproverInsertAfter(Number(value))}
> >
<SelectTrigger className="h-11 border-gray-300"> <SelectTrigger className="h-11 border-gray-300">
@ -1232,7 +1211,7 @@ export function ClaimApproverSelectionStep({
<p className="text-xs text-amber-600 font-medium"> <p className="text-xs text-amber-600 font-medium">
Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
</p> </p>
{/* Max Approval Levels Note */} {/* Max Approval Levels Note */}
{maxApprovalLevels && ( {maxApprovalLevels && (
<p className="text-xs text-gray-600 mt-2"> <p className="text-xs text-gray-600 mt-2">
@ -1311,7 +1290,7 @@ export function ClaimApproverSelectionStep({
className="pl-10 h-11 border-gray-300" className="pl-10 h-11 border-gray-300"
autoFocus autoFocus
/> />
{/* Search Results Dropdown */} {/* Search Results Dropdown */}
{(isSearchingApprover || addApproverSearchResults.length > 0) && ( {(isSearchingApprover || addApproverSearchResults.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto"> <div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto">

View File

@ -29,7 +29,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { verifyDealerLogin, searchExternalDealerByCode, type DealerInfo } from '@/services/dealerApi'; import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi';
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep'; import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi';
@ -68,7 +68,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const dealerSearchTimer = useRef<any>(null); const dealerSearchTimer = useRef<any>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null); const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// System policy state // System policy state
const [systemPolicy, setSystemPolicy] = useState({ const [systemPolicy, setSystemPolicy] = useState({
maxApprovalLevels: 10, maxApprovalLevels: 10,
@ -76,7 +76,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
allowSpectators: true, allowSpectators: true,
maxSpectators: 20 maxSpectators: 20
}); });
const [policyViolationModal, setPolicyViolationModal] = useState<{ const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean; open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>; violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
@ -140,7 +140,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
} }
}; };
}, []); }, []);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
activityName: '', activityName: '',
activityType: '', activityType: '',
@ -175,7 +175,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Handle dealer search input with debouncing // Handle dealer search input with debouncing
const handleDealerSearchInputChange = (value: string) => { const handleDealerSearchInputChange = (value: string) => {
setDealerSearchInput(value); setDealerSearchInput(value);
// Clear previous timer // Clear previous timer
if (dealerSearchTimer.current) { if (dealerSearchTimer.current) {
clearTimeout(dealerSearchTimer.current); clearTimeout(dealerSearchTimer.current);
@ -194,26 +194,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Debounce search // Debounce search
dealerSearchTimer.current = setTimeout(async () => { dealerSearchTimer.current = setTimeout(async () => {
try { try {
const result = await searchExternalDealerByCode(value); const results = await fetchDealersFromAPI(value, 10); // Limit to 10 results
if (result) { setDealerSearchResults(results);
// Map external API response to DealerInfo structure
const mappedDealer: DealerInfo = {
dealerId: result.dealer || result.dealer_code || value,
dealerCode: result.dealer || result.dealer_code || value,
dealerName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
displayName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
email: result['dealer email'] || '',
phone: result['dealer phone'] || '',
city: result['re city'] || result.city || '',
state: result['re state code'] || result.state || '',
isLoggedIn: true, // We'll verify this in the next step
};
setDealerSearchResults([mappedDealer]);
} else {
setDealerSearchResults([]);
}
} catch (error) { } catch (error) {
console.error('Error searching external dealer:', error); console.error('Error searching dealers:', error);
setDealerSearchResults([]); setDealerSearchResults([]);
} finally { } finally {
setDealerSearchLoading(false); setDealerSearchLoading(false);
@ -224,7 +208,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const updateFormData = (field: string, value: any) => { const updateFormData = (field: string, value: any) => {
setFormData(prev => { setFormData(prev => {
const updated = { ...prev, [field]: value }; const updated = { ...prev, [field]: value };
// Validate period dates // Validate period dates
if (field === 'periodStartDate') { if (field === 'periodStartDate') {
// If start date is selected and end date exists, validate end date // If start date is selected and end date exists, validate end date
@ -241,7 +225,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
return prev; return prev;
} }
} }
return updated; return updated;
}); });
}; };
@ -249,18 +233,18 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const isStepValid = () => { const isStepValid = () => {
switch (currentStep) { switch (currentStep) {
case 1: case 1:
return formData.activityName && return formData.activityName &&
formData.activityType && formData.activityType &&
formData.dealerCode && formData.dealerCode &&
formData.dealerName && formData.dealerName &&
formData.activityDate && formData.activityDate &&
formData.location && formData.location &&
formData.requestDescription; formData.requestDescription;
case 2: case 2:
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance) // Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
const approvers = formData.approvers || []; const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level // Find step 3 approver by originalStepLevel first, then fallback to level
const step3Approver = approvers.find((a: any) => const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional) a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
); );
// Step 8 is now a system step, no validation needed // Step 8 is now a system step, no validation needed
@ -279,15 +263,15 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
if (currentStep === 2) { if (currentStep === 2) {
const approvers = formData.approvers || []; const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level // Find step 3 approver by originalStepLevel first, then fallback to level
const step3Approver = approvers.find((a: any) => const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional) a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
); );
const missingSteps: string[] = []; const missingSteps: string[] = [];
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) { if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
missingSteps.push('Department Lead Approval'); missingSteps.push('Department Lead Approval');
} }
if (missingSteps.length > 0) { if (missingSteps.length > 0) {
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`); toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
} else { } else {
@ -313,7 +297,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
setVerifyingDealer(true); setVerifyingDealer(true);
try { try {
const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode); const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode);
if (!verifiedDealer.isLoggedIn) { if (!verifiedDealer.isLoggedIn) {
toast.error( toast.error(
`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" (${verifiedDealer.dealerCode}) is not mapped to the system.`, `Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" (${verifiedDealer.dealerCode}) is not mapped to the system.`,
@ -337,14 +321,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
updateFormData('dealerEmail', verifiedDealer.email || ''); updateFormData('dealerEmail', verifiedDealer.email || '');
updateFormData('dealerPhone', verifiedDealer.phone || ''); updateFormData('dealerPhone', verifiedDealer.phone || '');
updateFormData('dealerAddress', ''); // Address not available in API response updateFormData('dealerAddress', ''); // Address not available in API response
// Clear search input and results // Clear search input and results
setDealerSearchInput(verifiedDealer.dealerName || verifiedDealer.displayName); setDealerSearchInput(verifiedDealer.dealerName || verifiedDealer.displayName);
setDealerSearchResults([]); setDealerSearchResults([]);
toast.success(`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" verified and mapped to the System`); toast.success(`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" verified and mapped to the System`);
} catch (error: any) { } catch (error: any) {
const errorMessage = 'Dealer is not mapped to the system' const errorMessage = 'Dealer is not mapped to the system'
toast.error(errorMessage, { duration: 5000 }); toast.error(errorMessage, { duration: 5000 });
// Clear the selection // Clear the selection
setDealerSearchInput(''); setDealerSearchInput('');
@ -369,11 +353,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Just sort them and prepare for submission // Just sort them and prepare for submission
const approvers = formData.approvers || []; const approvers = formData.approvers || [];
const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level); const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
// Check for duplicate levels (should not happen, but safeguard) // Check for duplicate levels (should not happen, but safeguard)
const levelMap = new Map<number, typeof sortedApprovers[0]>(); const levelMap = new Map<number, typeof sortedApprovers[0]>();
const duplicates: number[] = []; const duplicates: number[] = [];
sortedApprovers.forEach((approver) => { sortedApprovers.forEach((approver) => {
if (levelMap.has(approver.level)) { if (levelMap.has(approver.level)) {
duplicates.push(approver.level); duplicates.push(approver.level);
@ -381,13 +365,13 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
levelMap.set(approver.level, approver); levelMap.set(approver.level, approver);
} }
}); });
if (duplicates.length > 0) { if (duplicates.length > 0) {
toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`); toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`);
console.error('Duplicate levels found:', duplicates, sortedApprovers); console.error('Duplicate levels found:', duplicates, sortedApprovers);
return; return;
} }
// Prepare final approvers array - preserve stepName for additional approvers // Prepare final approvers array - preserve stepName for additional approvers
// The backend will use stepName to set the levelName for approval levels // The backend will use stepName to set the levelName for approval levels
// Also preserve originalStepLevel so backend can identify which step each approver belongs to // Also preserve originalStepLevel so backend can identify which step each approver belongs to
@ -400,18 +384,18 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
tat: approver.tat, tat: approver.tat,
tatType: approver.tatType, tatType: approver.tatType,
}; };
// Preserve stepName for additional approvers // Preserve stepName for additional approvers
if (approver.isAdditional && approver.stepName) { if (approver.isAdditional && approver.stepName) {
result.stepName = approver.stepName; result.stepName = approver.stepName;
result.isAdditional = true; result.isAdditional = true;
} }
// Preserve originalStepLevel for fixed steps (so backend can identify which step this is) // Preserve originalStepLevel for fixed steps (so backend can identify which step this is)
if (approver.originalStepLevel) { if (approver.originalStepLevel) {
result.originalStepLevel = approver.originalStepLevel; result.originalStepLevel = approver.originalStepLevel;
} }
return result; return result;
}); });
@ -502,8 +486,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div> <div>
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label> <Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
<Select <Select
value={formData.activityType} value={formData.activityType}
onValueChange={(value) => updateFormData('activityType', value)} onValueChange={(value) => updateFormData('activityType', value)}
disabled={loadingActivityTypes} disabled={loadingActivityTypes}
> >
@ -750,7 +734,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
</p> </p>
) : ( ) : (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{formData.periodStartDate {formData.periodStartDate
? 'Please select end date for the period' ? 'Please select end date for the period'
: 'Please select start date first'} : 'Please select start date first'}
</p> </p>
@ -770,9 +754,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
currentUserEmail={(user as any)?.email || ''} currentUserEmail={(user as any)?.email || ''}
currentUserId={(user as any)?.userId || ''} currentUserId={(user as any)?.userId || ''}
currentUserName={ currentUserName={
(user as any)?.displayName || (user as any)?.displayName ||
(user as any)?.name || (user as any)?.name ||
((user as any)?.firstName && (user as any)?.lastName ((user as any)?.firstName && (user as any)?.lastName
? `${(user as any).firstName} ${(user as any).lastName}`.trim() ? `${(user as any).firstName} ${(user as any).lastName}`.trim()
: (user as any)?.email || 'User') : (user as any)?.email || 'User')
} }
@ -873,16 +857,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const sortedApprovers = [...(formData.approvers || [])] const sortedApprovers = [...(formData.approvers || [])]
.filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@')) .filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@'))
.sort((a: any, b: any) => a.level - b.level); .sort((a: any, b: any) => a.level - b.level);
return sortedApprovers.map((approver: any) => { return sortedApprovers.map((approver: any) => {
const tat = Number(approver.tat || 0); const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours'; const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat; const hours = tatType === 'days' ? tat * 24 : tat;
// Find step name - handle additional approvers and shifted levels // Find step name - handle additional approvers and shifted levels
let stepName = 'Unknown'; let stepName = 'Unknown';
let stepLabel = ''; let stepLabel = '';
if (approver.isAdditional) { if (approver.isAdditional) {
// Additional approver - use stepName if available // Additional approver - use stepName if available
stepName = approver.stepName || 'Additional Approver'; stepName = approver.stepName || 'Additional Approver';
@ -890,16 +874,17 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`; stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`;
} else { } else {
// Fixed step - find by originalStepLevel first, then fallback to level // Fixed step - find by originalStepLevel first, then fallback to level
const step = approver.originalStepLevel const step = approver.originalStepLevel
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel) ? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
: CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto); : CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto);
stepName = step?.name || 'Unknown'; stepName = step?.name || 'Unknown';
stepLabel = stepName; stepLabel = stepName;
} }
return ( return (
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200' <div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${
}`}> approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@ -975,8 +960,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div> <div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label> <Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
<div className="mt-2 p-4 bg-gray-50 rounded-lg border"> <div className="mt-2 p-4 bg-gray-50 rounded-lg border">
<FormattedDescription <FormattedDescription
content={formData.requestDescription || ''} content={formData.requestDescription || ''}
className="text-sm" className="text-sm"
/> />
</div> </div>
@ -1047,7 +1032,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<span className="hidden sm:inline">Back to Templates</span> <span className="hidden sm:inline">Back to Templates</span>
<span className="sm:hidden">Back</span> <span className="sm:hidden">Back</span>
</Button> </Button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
<div> <div>
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge> <Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
@ -1063,10 +1048,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<Progress value={(currentStep / totalSteps) * 100} className="h-2" /> <Progress value={(currentStep / totalSteps) * 100} className="h-2" />
<div className="flex justify-between mt-2 px-1"> <div className="flex justify-between mt-2 px-1">
{STEP_NAMES.map((_name, index) => ( {STEP_NAMES.map((_name, index) => (
<span <span
key={index} key={index}
className={`text-xs sm:text-sm ${index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400' className={`text-xs sm:text-sm ${
}`} index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`}
> >
{index + 1} {index + 1}
</span> </span>
@ -1099,10 +1085,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{currentStep < totalSteps ? ( {currentStep < totalSteps ? (
<Button <Button
onClick={nextStep} onClick={nextStep}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${!isStepValid() className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
? 'opacity-50 cursor-pointer hover:opacity-60' !isStepValid()
: '' ? 'opacity-50 cursor-pointer hover:opacity-60'
}`} : ''
}`}
> >
Next Next
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />

View File

@ -5,13 +5,13 @@
* Located in: src/dealer-claim/components/request-detail/ * Located in: src/dealer-claim/components/request-detail/
*/ */
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { DollarSign, Download, CircleCheckBig, Target, CircleAlert } from 'lucide-react'; import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi'; import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
@ -30,87 +30,95 @@ interface IOBlockedDetails {
blockedDate: string; blockedDate: string;
blockedBy: string; // User who blocked blockedBy: string; // User who blocked
sapDocumentNumber: string; sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed' | 'pending'; status: 'blocked' | 'released' | 'failed';
} }
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const { user } = useAuth(); const { user } = useAuth();
const requestId = apiRequest?.requestId || request?.requestId; const requestId = apiRequest?.requestId || request?.requestId;
// Load existing IO data from apiRequest or request
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
// Get organizer user object from association (organizer) or fallback to organizedBy UUID // Get organizer user object from association (organizer) or fallback to organizedBy UUID
const organizer = internalOrder?.organizer || null;
// Get estimated budget from proposal details
const proposalDetails = apiRequest?.proposalDetails || {}; const proposalDetails = apiRequest?.proposalDetails || {};
const claimDetails = apiRequest?.claimDetails || apiRequest || {}; const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
// Calculate total base amount (needed for budget verification as requested) const [ioNumber, setIoNumber] = useState(existingIONumber);
// This is the taxable amount excluding GST
const totalBaseAmount = useMemo(() => {
const costBreakupRaw = proposalDetails?.costBreakup || claimDetails?.costBreakup || [];
const costBreakup = Array.isArray(costBreakupRaw)
? costBreakupRaw
: (typeof costBreakupRaw === 'string'
? JSON.parse(costBreakupRaw)
: []);
if (!Array.isArray(costBreakup) || costBreakup.length === 0) {
return Number(claimDetails?.totalProposedTaxableAmount || proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
}
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
return sum + (Number(amount) * Number(quantity));
}, 0);
}, [proposalDetails?.costBreakup, claimDetails?.costBreakup, claimDetails?.totalProposedTaxableAmount, proposalDetails?.totalEstimatedBudget]);
// Use base amount as the target budget for blocking
const estimatedBudget = totalBaseAmount;
// Budget status for signaling (Scenario 2)
// Use apiRequest as the primary source of truth, fall back to request
const budgetTracking = apiRequest?.budgetTracking || request?.budgetTracking || {};
const budgetStatus = budgetTracking?.budgetStatus || budgetTracking?.budget_status || '';
const internalOrdersList = apiRequest?.internalOrders || apiRequest?.internal_orders || request?.internalOrders || [];
const isAdditionalBlockingNeeded = budgetStatus === 'PROPOSED' && internalOrdersList.length > 0;
const [ioNumber, setIoNumber] = useState('');
const [fetchingAmount, setFetchingAmount] = useState(false); const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null); const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>(''); const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]); const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false); const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO blocks // Load existing IO block details from apiRequest
useEffect(() => { useEffect(() => {
if (internalOrdersList.length > 0) { if (internalOrder && existingIONumber) {
const formattedIOs = internalOrdersList.map((io: any) => { // IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking
const org = io.organizer || null; // We should NOT add blockedAmount to it - that would cause double deduction
const blockedByName = org?.displayName || // Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block)
org?.display_name || const availableBeforeBlock = Number(existingAvailableBalance) || 0;
org?.name ||
(org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) || // Get blocked by user name from organizer association (who blocked the amount)
org?.email || // When amount is blocked, organizedBy stores the user who blocked it
'Unknown User'; const blockedByName = organizer?.displayName ||
return { organizer?.display_name ||
ioNumber: io.ioNumber || io.io_number, organizer?.name ||
blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0), (organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0), organizer?.email ||
remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0), 'Unknown User';
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
// Set IO number from existing data
setIoNumber(existingIONumber);
// Only set blocked details if amount is blocked
if (existingBlockedAmount > 0) {
const blockedAmt = Number(existingBlockedAmount) || 0;
const backendRemaining = Number(existingRemainingBalance) || 0;
// Calculate expected remaining balance for validation/debugging
// Formula: remaining = availableBeforeBlock - blockedAmount
const expectedRemaining = availableBeforeBlock - blockedAmt;
// Loading existing IO block
// Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(backendRemaining - expectedRemaining) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
availableBalance: availableBeforeBlock,
blockedAmount: blockedAmt,
expectedRemaining,
backendRemaining,
difference: backendRemaining - expectedRemaining,
});
}
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: blockedAmt,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: backendRemaining, // Use backend calculated value
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName, blockedBy: blockedByName,
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '', sapDocumentNumber: sapDocNumber,
status: (io.status === 'BLOCKED' ? 'blocked' : status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
io.status === 'RELEASED' ? 'released' : internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
io.status === 'PENDING' ? 'pending' : 'blocked') as any, });
};
}); // Set fetched amount if available balance exists
setBlockedIOs(formattedIOs); if (availableBeforeBlock > 0) {
setFetchedAmount(availableBeforeBlock);
// If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience }
if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) {
setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
} }
} }
}, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]); }, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/** /**
* Fetch available budget from SAP * Fetch available budget from SAP
@ -132,25 +140,15 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
try { try {
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later // Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
const ioData = await validateIO(requestId, ioNumber.trim()); const ioData = await validateIO(requestId, ioNumber.trim());
if (ioData.isValid && ioData.availableBalance > 0) { if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance); setFetchedAmount(ioData.availableBalance);
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
// Calculate total already blocked amount if (estimatedBudget > 0) {
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0); setAmountToBlock(String(estimatedBudget));
// Calculate remaining budget to block
const remainingToBlock = Math.max(0, estimatedBudget - totalAlreadyBlocked);
// Pre-fill amount to block with remaining budget, otherwise use available balance
if (remainingToBlock > 0) {
setAmountToBlock(String(remainingToBlock.toFixed(2)));
} else if (estimatedBudget > 0 && totalAlreadyBlocked === 0) {
setAmountToBlock(String(estimatedBudget.toFixed(2)));
} else { } else {
setAmountToBlock(String(ioData.availableBalance.toFixed(2))); setAmountToBlock(String(ioData.availableBalance));
} }
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
} else { } else {
toast.error('Invalid IO number or no available balance found'); toast.error('Invalid IO number or no available balance found');
@ -186,33 +184,26 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
} }
const blockAmountRaw = parseFloat(amountToBlock); const blockAmountRaw = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) { if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
toast.error('Please enter a valid amount to block'); toast.error('Please enter a valid amount to block');
return; return;
} }
// Round to exactly 2 decimal places to avoid floating point precision issues // Round to exactly 2 decimal places to avoid floating point precision issues
// Use parseFloat with toFixed to ensure exact 2 decimal precision // Use parseFloat with toFixed to ensure exact 2 decimal precision
const blockAmount = parseFloat(blockAmountRaw.toFixed(2)); const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
if (blockAmount > fetchedAmount) { if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget'); toast.error('Amount to block exceeds available IO budget');
return; return;
} }
// Validate that amount to block must exactly match estimated budget
// Calculate total already blocked
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
const totalPlanned = totalAlreadyBlocked + blockAmount;
// Validate that total planned must exactly match estimated budget
if (estimatedBudget > 0) { if (estimatedBudget > 0) {
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2)); const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
const roundedTotalPlanned = parseFloat(totalPlanned.toFixed(2)); if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) {
toast.error(`Amount to block must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`);
if (Math.abs(roundedTotalPlanned - roundedEstimatedBudget) > 0.01) {
toast.error(`Total blocked amount (₹${roundedTotalPlanned.toLocaleString('en-IN')}) must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN')})`);
return; return;
} }
} }
@ -233,29 +224,29 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
ioBlockedAmount: blockAmount, ioBlockedAmount: blockAmount,
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value) ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value)
}; };
// Sending to backend // Sending to backend
await updateIODetails(requestId, payload); await updateIODetails(requestId, payload);
// Fetch updated claim details to get the blocked IO data // Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId); const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) { if (updatedInternalOrder) {
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount); const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0); const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
// Calculate expected remaining balance for validation/debugging // Calculate expected remaining balance for validation/debugging
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount; const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
// Blocking result processed // Blocking result processed
// Warn if the saved amount differs from what we sent // Warn if the saved amount differs from what we sent
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) { if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount); console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
} }
// Warn if remaining balance calculation seems incorrect (for backend debugging) // Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) { if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', { console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
@ -266,17 +257,17 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
difference: savedRemainingBalance - expectedRemainingBalance, difference: savedRemainingBalance - expectedRemainingBalance,
}); });
} }
const currentUser = user as any; const currentUser = user as any;
// When blocking, always use the current user who is performing the block action // When blocking, always use the current user who is performing the block action
// The organizer association may be from initial IO organization, but we want who blocked the amount // The organizer association may be from initial IO organization, but we want who blocked the amount
const blockedByName = currentUser?.displayName || const blockedByName = currentUser?.displayName ||
currentUser?.display_name || currentUser?.display_name ||
currentUser?.name || currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) || (currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email || currentUser?.email ||
'Current User'; 'Current User';
const blocked: IOBlockedDetails = { const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber, ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: savedBlockedAmount, blockedAmount: savedBlockedAmount,
@ -287,12 +278,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '', sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
status: 'blocked', status: 'blocked',
}; };
setBlockedIOs(prev => [...prev, blocked]); setBlockedDetails(blocked);
setAmountToBlock(''); // Clear the input setAmountToBlock(''); // Clear the input
setFetchedAmount(null); // Reset fetched state
toast.success('IO budget blocked successfully in SAP'); toast.success('IO budget blocked successfully in SAP');
// Refresh request details // Refresh request details
onRefresh?.(); onRefresh?.();
} else { } else {
@ -331,12 +321,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
placeholder="Enter IO number (e.g., IO-2024-12345)" placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber} value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)} onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)} disabled={fetchingAmount || !!blockedDetails}
className="flex-1" className="flex-1"
/> />
<Button <Button
onClick={handleFetchAmount} onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)} disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
className="bg-[#2d4a3e] hover:bg-[#1f3329]" className="bg-[#2d4a3e] hover:bg-[#1f3329]"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
@ -346,7 +336,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</div> </div>
{/* Instructions when IO number is entered but not fetched */} {/* Instructions when IO number is entered but not fetched */}
{!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && ( {!fetchedAmount && !blockedDetails && ioNumber.trim() && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800"> <p className="text-sm text-blue-800">
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP. <strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
@ -355,7 +345,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
)} )}
{/* Fetched Amount Display */} {/* Fetched Amount Display */}
{fetchedAmount !== null && (blockedIOs.length === 0 || isAdditionalBlockingNeeded) && ( {fetchedAmount !== null && !blockedDetails && (
<> <>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4"> <div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -402,11 +392,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
<Button <Button
onClick={handleBlockBudget} onClick={handleBlockBudget}
disabled={ disabled={
blockingBudget || blockingBudget ||
!amountToBlock || !amountToBlock ||
parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount || parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs((blockedIOs.reduce((s, i) => s + i.blockedAmount, 0) + parseFloat(amountToBlock)) - estimatedBudget) > 0.01) (estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01)
} }
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]" className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
> >
@ -430,57 +420,71 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{blockedIOs.length > 0 ? ( {blockedDetails ? (
<div className="space-y-6"> <div className="space-y-4">
{isAdditionalBlockingNeeded && ( {/* Success Banner */}
<div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse"> <div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" /> <CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="font-semibold text-amber-900">Additional Budget Blocking Required</p> <p className="font-semibold text-green-900">IO Blocked Successfully</p>
<p className="text-sm text-amber-700 mt-1">Actual expenses exceed the previously blocked amount. Please block an additional {(estimatedBudget - blockedIOs.reduce((s, i) => s + i.blockedAmount, 0)).toLocaleString('en-IN', { minimumFractionDigits: 2 })}.</p> <p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
</div>
</div> </div>
</div> </div>
)} </div>
{blockedIOs.slice().reverse().map((io, idx) => ( {/* Blocked Details */}
<div key={idx} className="border rounded-lg overflow-hidden"> <div className="border rounded-lg divide-y">
<div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}> <div className="p-4">
<span className="font-semibold text-sm">IO: {io.ioNumber}</span> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
<Badge className={ <p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
io.status === 'blocked' ? 'bg-green-100 text-green-800' : </div>
io.status === 'pending' ? 'bg-amber-100 text-amber-800' : <div className="p-4">
'bg-blue-100 text-blue-800' <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
}> <p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
{io.status === 'blocked' ? 'Blocked' : </div>
io.status === 'pending' ? 'Provisioned' : 'Released'} <div className="p-4 bg-green-50">
</Badge> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
</div> <p className="text-xl font-bold text-green-700">
<div className="grid grid-cols-2 divide-x divide-y"> {blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
<div className="p-3"> </p>
<p className="text-[10px] text-gray-500 uppercase">Amount</p> </div>
<p className="text-sm font-bold text-green-700">{io.blockedAmount.toLocaleString('en-IN')}</p> <div className="p-4">
</div> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
<div className="p-3"> <p className="text-sm font-medium text-gray-900">
<p className="text-[10px] text-gray-500 uppercase">SAP Doc</p> {blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
<p className="text-sm font-medium">{io.sapDocumentNumber || 'N/A'}</p> </p>
</div> </div>
<div className="p-3"> <div className="p-4 bg-blue-50">
<p className="text-[10px] text-gray-500 uppercase">Blocked By</p> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
<p className="text-xs">{io.blockedBy}</p> <p className="text-sm font-bold text-blue-700">
</div> {blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
<div className="p-3"> </p>
<p className="text-[10px] text-gray-500 uppercase">Date</p> </div>
<p className="text-[10px]">{new Date(io.blockedDate).toLocaleString()}</p> <div className="p-4">
</div> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
</div> <p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked At</p>
<p className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
</p>
</div>
<div className="p-4 bg-gray-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
Blocked
</Badge>
</div> </div>
))}
<div className="mt-4 p-4 bg-[#2d4a3e] text-white rounded-lg flex justify-between items-center">
<span className="font-bold">Total Blocked:</span>
<span className="text-xl font-bold">{blockedIOs.reduce((s, i) => s + i.blockedAmount, 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
</div> </div>
</div> </div>
) : ( ) : (

File diff suppressed because it is too large Load Diff

View File

@ -18,11 +18,11 @@ interface ActivityInformationCardProps {
updatedAt?: string | Date; updatedAt?: string | Date;
} }
export function ActivityInformationCard({ export function ActivityInformationCard({
activityInfo, activityInfo,
className, className,
createdAt, createdAt,
updatedAt updatedAt
}: ActivityInformationCardProps) { }: ActivityInformationCardProps) {
// Defensive check: Ensure activityInfo exists // Defensive check: Ensure activityInfo exists
if (!activityInfo) { if (!activityInfo) {
@ -109,7 +109,7 @@ export function ActivityInformationCard({
</label> </label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2"> <p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" /> <DollarSign className="w-4 h-4 text-green-600" />
{activityInfo.estimatedBudget !== undefined && activityInfo.estimatedBudget !== null {activityInfo.estimatedBudget
? formatCurrency(activityInfo.estimatedBudget) ? formatCurrency(activityInfo.estimatedBudget)
: 'TBD'} : 'TBD'}
</p> </p>
@ -123,11 +123,7 @@ export function ActivityInformationCard({
</label> </label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2"> <p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<Receipt className="w-4 h-4 text-blue-600" /> <Receipt className="w-4 h-4 text-blue-600" />
{formatCurrency( {formatCurrency(activityInfo.closedExpenses)}
activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0
? activityInfo.closedExpensesBreakdown.reduce((sum, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
: activityInfo.closedExpenses
)}
</p> </p>
</div> </div>
)} )}
@ -151,40 +147,23 @@ export function ActivityInformationCard({
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block"> <label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
Closed Expenses Breakdown Closed Expenses Breakdown
</label> </label>
<div className="bg-blue-50 border border-blue-200 rounded-lg overflow-hidden"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<table className="w-full text-xs sm:text-sm"> {activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
<thead className="bg-blue-100/50"> <div key={index} className="flex justify-between items-center text-sm">
<tr> <span className="text-gray-700">{item.description}</span>
<th className="px-3 py-2 text-left font-semibold text-blue-900">Description</th> <span className="font-medium text-gray-900">
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">Base</th> {formatCurrency(item.amount)}
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">GST</th> </span>
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-28">Total</th> </div>
</tr> ))}
</thead> <div className="pt-2 border-t border-blue-300 flex justify-between items-center">
<tbody className="divide-y divide-blue-200/50"> <span className="font-semibold text-gray-900">Total</span>
{activityInfo.closedExpensesBreakdown.map((item: any, index: number) => ( <span className="font-bold text-blue-600">
<tr key={index} className="hover:bg-blue-100/30"> {formatCurrency(
<td className="px-3 py-2 text-gray-700"> activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
{item.description} )}
{item.gstRate ? <span className="text-[10px] text-gray-400 block">{item.gstRate}% GST</span> : null} </span>
</td> </div>
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.amount)}</td>
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.gstAmt || 0)}</td>
<td className="px-3 py-2 text-right font-medium text-gray-900">
{formatCurrency(item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0)))}
</td>
</tr>
))}
<tr className="bg-blue-100/50 font-bold">
<td colSpan={3} className="px-3 py-2 text-blue-900">Final Claim Amount</td>
<td className="px-3 py-2 text-right text-blue-700">
{formatCurrency(
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
)}
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
)} )}
@ -196,8 +175,8 @@ export function ActivityInformationCard({
Description Description
</label> </label>
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200"> <div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200">
<FormattedDescription <FormattedDescription
content={activityInfo.description || ''} content={activityInfo.description || ''}
className="text-sm" className="text-sm"
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
/** /**
* ProcessDetailsCard Component * ProcessDetailsCard Component
* Displays process-related details: IO Number, E-Invoice, Claim Amount, and Budget Breakdowns * Displays process-related details: IO Number, DMS Number, Claim Amount, and Budget Breakdowns
* Visibility controlled by user role * Visibility controlled by user role
*/ */
@ -26,11 +26,6 @@ interface DMSDetails {
remarks?: string; remarks?: string;
createdByName?: string; createdByName?: string;
createdAt?: string; createdAt?: string;
// PWC fields
irn?: string;
ackNo?: string;
ackDate?: string;
signedInvoiceUrl?: string;
} }
interface ClaimAmountDetails { interface ClaimAmountDetails {
@ -42,8 +37,6 @@ interface ClaimAmountDetails {
interface CostBreakdownItem { interface CostBreakdownItem {
description: string; description: string;
amount: number; amount: number;
gstAmt?: number;
totalAmt?: number;
} }
interface RoleBasedVisibility { interface RoleBasedVisibility {
@ -92,7 +85,7 @@ export function ProcessDetailsCard({
const calculateTotal = (items?: CostBreakdownItem[]) => { const calculateTotal = (items?: CostBreakdownItem[]) => {
if (!items || items.length === 0) return 0; if (!items || items.length === 0) return 0;
return items.reduce((sum, item) => sum + (item.totalAmt ?? (item.amount + (item.gstAmt ?? 0))), 0); return items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
}; };
// Don't render if nothing to show // Don't render if nothing to show
@ -127,7 +120,7 @@ export function ProcessDetailsCard({
</Label> </Label>
</div> </div>
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p> <p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
{ioDetails.remarks && ( {ioDetails.remarks && (
<div className="pt-2 border-t border-blue-100"> <div className="pt-2 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">Remark:</p> <p className="text-xs text-gray-600 mb-1">Remark:</p>
@ -172,57 +165,27 @@ export function ProcessDetailsCard({
</div> </div>
)} )}
{/* E-Invoice Details */} {/* DMS Details */}
{visibility.showDMSDetails && dmsDetails && ( {visibility.showDMSDetails && dmsDetails && (
<div className="bg-white rounded-lg p-3 border border-purple-200"> <div className="bg-white rounded-lg p-3 border border-purple-200">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" /> <Activity className="w-4 h-4 text-purple-600" />
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide"> <Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
E-Invoice Details DMS Number
</Label> </Label>
</div> </div>
<p className="font-bold text-gray-900 mb-2">{dmsDetails.dmsNumber}</p>
<div className="grid grid-cols-2 gap-3 mb-2">
{dmsDetails.ackNo && (
<div>
<p className="text-[10px] text-gray-500 uppercase">Ack No</p>
<p className="font-bold text-sm text-purple-700">{dmsDetails.ackNo}</p>
</div>
)}
</div>
{dmsDetails.irn && (
<div className="mb-2 p-2 bg-purple-50 rounded border border-purple-100">
<p className="text-[10px] text-purple-600 uppercase font-semibold">IRN</p>
<p className="text-[10px] font-mono break-all text-gray-700 leading-tight">
{dmsDetails.irn}
</p>
</div>
)}
{dmsDetails.signedInvoiceUrl && (
<Button
variant="outline"
size="sm"
className="w-full h-8 text-xs gap-2 mb-2 border-purple-200 text-purple-700 hover:bg-purple-50"
onClick={() => window.open(dmsDetails.signedInvoiceUrl, '_blank')}
>
<Receipt className="w-3.5 h-3.5" />
View E-Invoice
</Button>
)}
{dmsDetails.remarks && ( {dmsDetails.remarks && (
<div className="pt-2 border-t border-purple-100"> <div className="pt-2 border-t border-purple-100">
<p className="text-[10px] text-gray-500 uppercase mb-1">Remarks</p> <p className="text-xs text-gray-600 mb-1">Remarks:</p>
<p className="text-xs text-gray-900">{dmsDetails.remarks}</p> <p className="text-xs text-gray-900">{dmsDetails.remarks}</p>
</div> </div>
)} )}
<div className="pt-2 border-t border-purple-100 mt-2"> <div className="pt-2 border-t border-purple-100 mt-2">
<p className="text-[10px] text-gray-500">By {dmsDetails.createdByName}</p> <p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
<p className="text-[10px] text-gray-500">{formatDate(dmsDetails.createdAt)}</p> <p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
</div> </div>
</div> </div>
)} )}
@ -278,10 +241,10 @@ export function ProcessDetailsCard({
</div> </div>
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
{estimatedBudgetBreakdown.map((item, index) => ( {estimatedBudgetBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs"> <div key={index} className="flex justify-between items-center text-xs">
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div> <span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900 whitespace-nowrap"> <span className="font-medium text-gray-900">
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))} {formatCurrency(item.amount)}
</span> </span>
</div> </div>
))} ))}
@ -306,10 +269,10 @@ export function ProcessDetailsCard({
</div> </div>
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
{closedExpensesBreakdown.map((item, index) => ( {closedExpensesBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs"> <div key={index} className="flex justify-between items-center text-xs">
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div> <span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900 whitespace-nowrap"> <span className="font-medium text-gray-900">
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))} {formatCurrency(item.amount)}
</span> </span>
</div> </div>
))} ))}

View File

@ -11,19 +11,11 @@ import { format } from 'date-fns';
interface ProposalCostItem { interface ProposalCostItem {
description: string; description: string;
amount?: number | null; amount?: number | null;
gstRate?: number;
gstAmt?: number;
cgstAmt?: number;
sgstAmt?: number;
igstAmt?: number;
quantity?: number;
totalAmt?: number;
} }
interface ProposalDetails { interface ProposalDetails {
costBreakup: ProposalCostItem[]; costBreakup: ProposalCostItem[];
estimatedBudgetTotal?: number | null; estimatedBudgetTotal?: number | null;
totalEstimatedBudget?: number | null;
timelineForClosure?: string | null; timelineForClosure?: string | null;
dealerComments?: string | null; dealerComments?: string | null;
submittedOn?: string | null; submittedOn?: string | null;
@ -37,22 +29,19 @@ interface ProposalDetailsCardProps {
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) { export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
// Calculate estimated total from costBreakup if not provided // Calculate estimated total from costBreakup if not provided
const calculateEstimatedTotal = () => { const calculateEstimatedTotal = () => {
const total = proposalDetails.totalEstimatedBudget ?? proposalDetails.estimatedBudgetTotal; if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
if (total !== undefined && total !== null) { return proposalDetails.estimatedBudgetTotal;
return total;
} }
// Calculate sum from costBreakup items // Calculate sum from costBreakup items
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) { if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
const total = proposalDetails.costBreakup.reduce((sum, item) => { const total = proposalDetails.costBreakup.reduce((sum, item) => {
const amount = item.amount || 0; const amount = item.amount || 0;
const gst = item.gstAmt || 0; return sum + (Number.isNaN(amount) ? 0 : amount);
const lineTotal = item.totalAmt || (Number(amount) + Number(gst));
return sum + (Number.isNaN(lineTotal) ? 0 : lineTotal);
}, 0); }, 0);
return total; return total;
} }
return 0; return 0;
}; };
@ -110,13 +99,7 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
Item Description Item Description
</th> </th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide"> <th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
Base Amount Amount
</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
GST
</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
Total
</th> </th>
</tr> </tr>
</thead> </thead>
@ -124,27 +107,16 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
{(proposalDetails.costBreakup || []).map((item, index) => ( {(proposalDetails.costBreakup || []).map((item, index) => (
<tr key={index} className="hover:bg-gray-50"> <tr key={index} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900"> <td className="px-4 py-3 text-sm text-gray-900">
<div>{item.description}</div> {item.description}
{item.gstRate ? (
<div className="text-[10px] text-gray-400">
{item.cgstAmt ? `CGST: ${item.gstRate / 2}%, SGST: ${item.gstRate / 2}%` : `IGST: ${item.gstRate}%`}
</div>
) : null}
</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right">
{formatCurrency(item.amount)}
</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right">
{formatCurrency(item.gstAmt)}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium"> <td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">
{formatCurrency(item.totalAmt || (Number(item.amount || 0) + Number(item.gstAmt || 0)))} {formatCurrency(item.amount)}
</td> </td>
</tr> </tr>
))} ))}
<tr className="bg-green-50 font-semibold"> <tr className="bg-green-50 font-semibold">
<td colSpan={3} className="px-4 py-3 text-sm text-gray-900"> <td className="px-4 py-3 text-sm text-gray-900">
Estimated Budget (Total Inclusive of GST) Estimated Budget (Total)
</td> </td>
<td className="px-4 py-3 text-sm text-green-700 text-right"> <td className="px-4 py-3 text-sm text-green-700 text-right">
{formatCurrency(estimatedTotal)} {formatCurrency(estimatedTotal)}

View File

@ -40,7 +40,6 @@ interface CreditNoteSAPModalProps {
requestNumber?: string; requestNumber?: string;
requestId?: string; requestId?: string;
dueDate?: string; dueDate?: string;
taxationType?: string | null;
} }
export function CreditNoteSAPModal({ export function CreditNoteSAPModal({
@ -54,16 +53,13 @@ export function CreditNoteSAPModal({
requestNumber, requestNumber,
requestId: _requestId, requestId: _requestId,
dueDate, dueDate,
taxationType,
}: CreditNoteSAPModalProps) { }: CreditNoteSAPModalProps) {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== ''; const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
const creditNoteNumber = creditNoteData?.creditNoteNumber || ''; const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
const creditNoteDate = creditNoteData?.creditNoteDate const creditNoteDate = creditNoteData?.creditNoteDate
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' }) ? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
: ''; : '';
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0; const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
@ -73,7 +69,7 @@ export function CreditNoteSAPModal({
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009'; const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
const activity = activityName || 'Activity'; const activity = activityName || 'Activity';
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101'; const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
const dueDateDisplay = dueDate const dueDateDisplay = dueDate
? formatDateTime(dueDate, { includeTime: false, format: 'short' }) ? formatDateTime(dueDate, { includeTime: false, format: 'short' })
: 'Jan 4, 2026'; : 'Jan 4, 2026';
@ -122,16 +118,9 @@ export function CreditNoteSAPModal({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl flex-wrap"> <DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
<div className="flex items-center gap-2"> <Receipt className="w-6 h-6 text-[--re-green]" />
<Receipt className="w-6 h-6 text-[--re-green]" /> Credit Note from SAP
Credit Note from SAP
</div>
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-base"> <DialogDescription className="text-base">
Review and send credit note to dealer Review and send credit note to dealer

View File

@ -1,16 +1,12 @@
.settlement-push-modal { .dms-push-modal {
width: 90vw !important; width: 90vw !important;
max-width: 1000px !important; max-width: 90vw !important;
min-width: 320px !important;
max-height: 95vh !important; max-height: 95vh !important;
overflow: hidden;
display: flex;
flex-direction: column;
} }
/* Mobile responsive */ /* Mobile responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.settlement-push-modal { .dms-push-modal {
width: 95vw !important; width: 95vw !important;
max-width: 95vw !important; max-width: 95vw !important;
max-height: 95vh !important; max-height: 95vh !important;
@ -19,48 +15,25 @@
/* Tablet and small desktop */ /* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) { @media (min-width: 641px) and (max-width: 1023px) {
.settlement-push-modal { .dms-push-modal {
width: 90vw !important; width: 90vw !important;
max-width: 900px !important; max-width: 90vw !important;
} }
} }
/* Scrollable content area */ /* Large screens - fixed max-width for better readability */
.settlement-push-modal .flex-1 { @media (min-width: 1024px) {
overflow-y: auto; .dms-push-modal {
padding-right: 4px; width: 90vw !important;
max-width: 1000px !important;
}
} }
/* Custom scrollbar for the modal content */ /* Extra large screens */
.settlement-push-modal .flex-1::-webkit-scrollbar { @media (min-width: 1536px) {
width: 6px; .dms-push-modal {
width: 90vw !important;
max-width: 1000px !important;
}
} }
.settlement-push-modal .flex-1::-webkit-scrollbar-track {
background: transparent;
}
.settlement-push-modal .flex-1::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
.settlement-push-modal .flex-1::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.file-preview-dialog {
width: 95vw !important;
max-width: 1200px !important;
max-height: 95vh !important;
padding: 0 !important;
display: flex;
flex-direction: column;
}
.file-preview-content {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}

View File

@ -25,7 +25,7 @@
@media (min-width: 1024px) { @media (min-width: 1024px) {
.dealer-completion-documents-modal { .dealer-completion-documents-modal {
width: 90vw !important; width: 90vw !important;
max-width: 1200px !important; max-width: 1000px !important;
} }
} }
@ -33,7 +33,7 @@
@media (min-width: 1536px) { @media (min-width: 1536px) {
.dealer-completion-documents-modal { .dealer-completion-documents-modal {
width: 90vw !important; width: 90vw !important;
max-width: 1200px !important; max-width: 1000px !important;
} }
} }
@ -64,4 +64,5 @@
right: 0.5rem; right: 0.5rem;
cursor: pointer; cursor: pointer;
opacity: 1; opacity: 1;
} }

View File

@ -25,7 +25,7 @@
@media (min-width: 1024px) { @media (min-width: 1024px) {
.dealer-proposal-modal { .dealer-proposal-modal {
width: 90vw !important; width: 90vw !important;
max-width: 1200px !important; max-width: 1000px !important;
} }
} }
@ -33,7 +33,7 @@
@media (min-width: 1536px) { @media (min-width: 1536px) {
.dealer-proposal-modal { .dealer-proposal-modal {
width: 90vw !important; width: 90vw !important;
max-width: 1200px !important; max-width: 1000px !important;
} }
} }
@ -64,4 +64,5 @@
right: 0.5rem; right: 0.5rem;
cursor: pointer; cursor: pointer;
opacity: 1; opacity: 1;
} }

View File

@ -37,7 +37,6 @@ interface DeptLeadIOApprovalModalProps {
preFilledIONumber?: string; preFilledIONumber?: string;
preFilledBlockedAmount?: number; preFilledBlockedAmount?: number;
preFilledRemainingBalance?: number; preFilledRemainingBalance?: number;
taxationType?: string | null;
} }
export function DeptLeadIOApprovalModal({ export function DeptLeadIOApprovalModal({
@ -50,16 +49,11 @@ export function DeptLeadIOApprovalModal({
preFilledIONumber, preFilledIONumber,
preFilledBlockedAmount, preFilledBlockedAmount,
preFilledRemainingBalance, preFilledRemainingBalance,
taxationType,
}: DeptLeadIOApprovalModalProps) { }: DeptLeadIOApprovalModalProps) {
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve'); const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
// Get IO number from props (read-only, from IO table) // Get IO number from props (read-only, from IO table)
const ioNumber = preFilledIONumber || ''; const ioNumber = preFilledIONumber || '';
@ -103,7 +97,7 @@ export function DeptLeadIOApprovalModal({
try { try {
setSubmitting(true); setSubmitting(true);
if (actionType === 'approve') { if (actionType === 'approve') {
await onApprove({ await onApprove({
ioNumber: ioNumber.trim(), ioNumber: ioNumber.trim(),
@ -112,7 +106,7 @@ export function DeptLeadIOApprovalModal({
} else { } else {
await onReject(comments.trim()); await onReject(comments.trim());
} }
handleReset(); handleReset();
onClose(); onClose();
} catch (error) { } catch (error) {
@ -144,13 +138,8 @@ export function DeptLeadIOApprovalModal({
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" /> <CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<DialogTitle className="font-semibold text-lg lg:text-xl flex items-center gap-2 flex-wrap"> <DialogTitle className="font-semibold text-lg lg:text-xl">
Review and Approve Review and Approve
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs lg:text-sm mt-1"> <DialogDescription className="text-xs lg:text-sm mt-1">
Review IO details and provide your approval comments Review IO details and provide your approval comments
@ -185,10 +174,11 @@ export function DeptLeadIOApprovalModal({
<Button <Button
type="button" type="button"
onClick={() => setActionType('approve')} onClick={() => setActionType('approve')}
className={`flex-1 text-sm lg:text-base ${actionType === 'approve' className={`flex-1 text-sm lg:text-base ${
? 'bg-green-600 text-white shadow-sm' actionType === 'approve'
: 'text-gray-700 hover:bg-gray-200' ? 'bg-green-600 text-white shadow-sm'
}`} : 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'} variant={actionType === 'approve' ? 'default' : 'ghost'}
> >
<CircleCheckBig className="w-4 h-4 mr-1" /> <CircleCheckBig className="w-4 h-4 mr-1" />
@ -197,10 +187,11 @@ export function DeptLeadIOApprovalModal({
<Button <Button
type="button" type="button"
onClick={() => setActionType('reject')} onClick={() => setActionType('reject')}
className={`flex-1 text-sm lg:text-base ${actionType === 'reject' className={`flex-1 text-sm lg:text-base ${
? 'bg-red-600 text-white shadow-sm' actionType === 'reject'
: 'text-gray-700 hover:bg-gray-200' ? 'bg-red-600 text-white shadow-sm'
}`} : 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'} variant={actionType === 'reject' ? 'destructive' : 'ghost'}
> >
<CircleX className="w-4 h-4 mr-1" /> <CircleX className="w-4 h-4 mr-1" />
@ -217,7 +208,7 @@ export function DeptLeadIOApprovalModal({
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" /> <Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4> <h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
</div> </div>
{/* IO Number - Read-only from IO table */} {/* IO Number - Read-only from IO table */}
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2"> <Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
@ -318,10 +309,11 @@ export function DeptLeadIOApprovalModal({
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isFormValid || submitting} disabled={!isFormValid || submitting}
className={`text-sm lg:text-base ${actionType === 'approve' className={`text-sm lg:text-base ${
? 'bg-green-600 hover:bg-green-700' actionType === 'approve'
: 'bg-red-600 hover:bg-red-700' ? 'bg-green-600 hover:bg-green-700'
} text-white`} : 'bg-red-600 hover:bg-red-700'
} text-white`}
> >
{submitting ? ( {submitting ? (
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...` `${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`

View File

@ -32,7 +32,7 @@ export function EmailNotificationTemplateModal({
stepNumber, stepNumber,
stepName, stepName,
requestNumber = 'RE-REQ-2024-CM-101', requestNumber = 'RE-REQ-2024-CM-101',
recipientEmail = `system@${import.meta.env.VITE_EMAIL_DOMAIN}`, recipientEmail = 'system@royalenfield.com',
subject, subject,
emailBody, emailBody,
}: EmailNotificationTemplateModalProps) { }: EmailNotificationTemplateModalProps) {

View File

@ -16,12 +16,12 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
CheckCircle, CheckCircle,
XCircle, XCircle,
FileText, FileText,
IndianRupee, IndianRupee,
Calendar, Calendar,
MessageSquare, MessageSquare,
Download, Download,
Eye, Eye,
@ -39,7 +39,6 @@ interface CostItem {
id: string; id: string;
description: string; description: string;
amount: number; amount: number;
quantity?: number;
} }
interface ProposalData { interface ProposalData {
@ -71,7 +70,6 @@ interface InitiatorProposalApprovalModalProps {
requestId?: string; requestId?: string;
request?: any; // Request object to check IO blocking status request?: any; // Request object to check IO blocking status
previousProposalData?: any; previousProposalData?: any;
taxationType?: string | null;
} }
export function InitiatorProposalApprovalModal({ export function InitiatorProposalApprovalModal({
@ -86,83 +84,16 @@ export function InitiatorProposalApprovalModal({
requestId: _requestId, // Prefix with _ to indicate intentionally unused requestId: _requestId, // Prefix with _ to indicate intentionally unused
request, request,
previousProposalData, previousProposalData,
taxationType,
}: InitiatorProposalApprovalModalProps) { }: InitiatorProposalApprovalModalProps) {
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null); const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
const [showPreviousProposal, setShowPreviousProposal] = useState(false); const [showPreviousProposal, setShowPreviousProposal] = useState(false);
// Calculate total budget (needed for display)
const totalBudget = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
// Ensure costBreakup is an array
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
const baseTotal = amount * quantity;
const gst = typeof item === 'object' ? (item.gstAmt || 0) : 0;
const total = item.totalAmt || (baseTotal + gst);
return sum + (Number(total) || 0);
}, 0);
}, [proposalData]);
// Calculate total base amount (needed for budget verification as requested)
// This is the taxable amount excluding GST
const totalBaseAmount = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
return sum + (Number(amount) * Number(quantity));
}, 0);
}, [proposalData]);
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level) // Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
// Sum up all successful blocks from internalOrders array const internalOrder = request?.internalOrder || request?.internal_order;
const totalBlockedAmount = useMemo(() => { const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const internalOrders = request?.internalOrders || request?.internal_orders || []; const isIOBlocked = ioBlockedAmount > 0;
// If we have an array, sum the blocked amounts
if (Array.isArray(internalOrders) && internalOrders.length > 0) {
return internalOrders.reduce((sum: number, io: any) => {
const amt = Number(io.ioBlockedAmount || io.io_blocked_amount || 0);
return sum + amt;
}, 0);
}
// Fallback to single internalOrder object for backward compatibility
const singleIO = request?.internalOrder || request?.internal_order;
return Number(singleIO?.ioBlockedAmount || singleIO?.io_blocked_amount || 0);
}, [request?.internalOrders, request?.internal_orders, request?.internalOrder, request?.internal_order]);
// Budget is considered blocked only if the total blocked amount matches or exceeds the proposed base amount
// Allow a small margin for floating point comparison if needed, but here simple >= should suffice
const isIOBlocked = totalBlockedAmount >= (totalBaseAmount - 0.01);
const remainingBaseToBlock = Math.max(0, totalBaseAmount - totalBlockedAmount);
const [previewDoc, setPreviewDoc] = useState<{ const [previewDoc, setPreviewDoc] = useState<{
name: string; name: string;
url: string; url: string;
@ -171,6 +102,25 @@ export function InitiatorProposalApprovalModal({
id?: string; id?: string;
} | null>(null); } | null>(null);
// Calculate total budget
const totalBudget = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
// Ensure costBreakup is an array
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
return sum + (Number(amount) || 0);
}, 0);
}, [proposalData]);
// Format date // Format date
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return '—'; if (!dateString) return '—';
@ -191,11 +141,11 @@ export function InitiatorProposalApprovalModal({
if (!doc.name) return false; if (!doc.name) return false;
const name = doc.name.toLowerCase(); const name = doc.name.toLowerCase();
return name.endsWith('.pdf') || return name.endsWith('.pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpg') ||
name.endsWith('.jpeg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.gif') ||
name.endsWith('.webp'); name.endsWith('.webp');
}; };
// Handle document preview - leverage FilePreview's internal fetching // Handle document preview - leverage FilePreview's internal fetching
@ -323,16 +273,9 @@ export function InitiatorProposalApprovalModal({
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col"> <DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b"> <DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl flex-wrap"> <DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
<div className="flex items-center gap-2"> <CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" /> Requestor Evaluation & Confirmation
Requestor Evaluation & Confirmation
</div>
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs lg:text-sm"> <DialogDescription className="text-xs lg:text-sm">
Step 2: Review dealer proposal and make a decision Step 2: Review dealer proposal and make a decision
@ -353,11 +296,11 @@ export function InitiatorProposalApprovalModal({
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6"> <div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6">
{/* Previous Proposal Reference Section */} {/* Previous Proposal Reference Section */}
{previousProposalData && ( {previousProposalData && (
<div className="mb-6"> <div className="mb-6">
<div <div
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors" className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
onClick={() => setShowPreviousProposal(!showPreviousProposal)} onClick={() => setShowPreviousProposal(!showPreviousProposal)}
> >
@ -373,48 +316,48 @@ export function InitiatorProposalApprovalModal({
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />} {showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
</Button> </Button>
</div> </div>
{showPreviousProposal && ( {showPreviousProposal && (
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50"> <div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
{/* Header Info: Date & Document */} {/* Header Info: Date & Document */}
<div className="flex flex-wrap gap-4 text-xs mt-3"> <div className="flex flex-wrap gap-4 text-xs mt-3">
{previousProposalData.expectedCompletionDate && ( {previousProposalData.expectedCompletionDate && (
<div className="flex items-center gap-1.5 text-gray-700"> <div className="flex items-center gap-1.5 text-gray-700">
<Calendar className="w-3.5 h-3.5 text-gray-500" /> <Calendar className="w-3.5 h-3.5 text-gray-500" />
<span className="font-medium">Expected Completion:</span> <span className="font-medium">Expected Completion:</span>
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span> <span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
</div> </div>
)} )}
{previousProposalData.documentUrl && ( {previousProposalData.documentUrl && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? ( {canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
<> <>
<Eye className="w-3.5 h-3.5 text-blue-500" /> <Eye className="w-3.5 h-3.5 text-blue-500" />
<a <a
href={previousProposalData.documentUrl} href={previousProposalData.documentUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1" className="text-blue-600 hover:underline font-medium flex items-center gap-1"
> >
View Previous Document View Previous Document
</a> </a>
</> </>
) : ( ) : (
<> <>
<Download className="w-3.5 h-3.5 text-blue-500" /> <Download className="w-3.5 h-3.5 text-blue-500" />
<a <a
href={previousProposalData.documentUrl} href={previousProposalData.documentUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1" className="text-blue-600 hover:underline font-medium flex items-center gap-1"
> >
Download Previous Document Download Previous Document
</a> </a>
</> </>
)} )}
</div> </div>
)} )}
</div> </div>
{/* Cost Breakdown */} {/* Cost Breakdown */}
@ -452,43 +395,43 @@ export function InitiatorProposalApprovalModal({
</div> </div>
</div> </div>
)} )}
{/* Additional/Supporting Documents */} {/* Additional/Supporting Documents */}
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && ( {previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
<div className="w-full pt-2 border-t border-amber-200/50"> <div className="w-full pt-2 border-t border-amber-200/50">
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1"> <p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
<FileText className="w-3 h-3" /> <FileText className="w-3 h-3" />
Supporting Documents Supporting Documents
</p> </p>
<div className="space-y-2 max-h-[150px] overflow-y-auto"> <div className="space-y-2 max-h-[150px] overflow-y-auto">
{previousProposalData.otherDocuments.map((doc: any, idx: number) => ( {previousProposalData.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard <DocumentCard
key={idx} key={idx}
document={{ document={{
documentId: doc.documentId || doc.id || '', documentId: doc.documentId || doc.id || '',
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document', name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file', fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
uploadedAt: doc.uploadedAt || new Date().toISOString() uploadedAt: doc.uploadedAt || new Date().toISOString()
}} }}
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined} onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
onDownload={async (id) => { onDownload={async (id) => {
if (id) { if (id) {
await downloadDocument(id); await downloadDocument(id);
} else { } else {
let downloadUrl = doc.storageUrl || doc.documentUrl; let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) { if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || ''; const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`; const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`; downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
} }
if (downloadUrl) window.open(downloadUrl, '_blank'); if (downloadUrl) window.open(downloadUrl, '_blank');
} }
}} }}
/> />
))} ))}
</div>
</div> </div>
</div>
)} )}
{/* Comments */} {/* Comments */}
@ -510,273 +453,247 @@ export function InitiatorProposalApprovalModal({
)} )}
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start"> <div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
{/* Left Column - Documents */} {/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col"> <div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Proposal Document Section */} {/* Proposal Document Section */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2"> <h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-600" /> <FileText className="w-4 h-4 text-blue-600" />
Proposal Document Proposal Document
</h3> </h3>
</div>
{proposalData?.proposalDocument ? (
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
{proposalData.proposalDocument.name}
</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500 truncate">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div> </div>
{proposalData?.proposalDocument ? ( <div className="flex items-center gap-2 flex-shrink-0">
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"> {proposalData.proposalDocument.id && (
<>
{canPreviewDocument(proposalData.proposalDocument) && (
<button
type="button"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (proposalData.proposalDocument?.id) {
await downloadDocument(proposalData.proposalDocument.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</>
)}
</div>
</div>
) : (
<p className="text-xs text-gray-500 italic">No proposal document available</p>
)}
</div>
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-600" />
Other Supporting Documents
</h3>
<Badge variant="secondary" className="text-xs">
{proposalData.otherDocuments.length} file(s)
</Badge>
</div>
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
>
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1"> <div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" /> <FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
<div className="min-w-0 flex-1"> <p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}> {doc.name}
{proposalData.proposalDocument.name} </p>
</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500 truncate">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> {doc.id && (
{proposalData.proposalDocument.id && ( <div className="flex items-center gap-1 flex-shrink-0">
<> {canPreviewDocument(doc) && (
{canPreviewDocument(proposalData.proposalDocument) && (
<button
type="button"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button <button
type="button" type="button"
onClick={async () => { onClick={() => handlePreviewDocument(doc)}
try {
if (proposalData.proposalDocument?.id) {
await downloadDocument(proposalData.proposalDocument.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors" className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document" title="Preview document"
> >
<Download className="w-5 h-5 text-gray-600" /> <Eye className="w-5 h-5 text-blue-600" />
</button> </button>
</> )}
)} <button
type="button"
onClick={async () => {
try {
if (doc.id) {
await downloadDocument(doc.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Right Column - Planning & Details */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Cost Breakup Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-green-600" />
Cost Breakup
</h3>
</div>
{(() => {
// Ensure costBreakup is an array
const costBreakup = proposalData?.costBreakup
? (Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []))
: [];
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
<>
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
<div className="grid grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
<div>Item Description</div>
<div className="text-right">Amount</div>
</div>
</div>
<div className="divide-y">
{costBreakup.map((item: any, index: number) => (
<div key={item?.id || item?.description || index} className="px-3 lg:px-4 py-2 lg:py-3 grid grid-cols-2 gap-4">
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
))}
</div> </div>
</div> </div>
) : ( <div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
<p className="text-xs text-gray-500 italic">No proposal document available</p> <div className="flex items-center justify-between">
)} <div className="flex items-center gap-2">
</div> <IndianRupee className="w-4 h-4 text-[--re-green]" />
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
{/* Other Supporting Documents */} </div>
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && ( <div className="text-lg lg:text-xl font-bold text-[--re-green]">
<div className="space-y-2"> {totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
<div className="flex items-center gap-2"> </div>
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-600" />
Other Supporting Documents
</h3>
<Badge variant="secondary" className="text-xs">
{proposalData.otherDocuments.length} file(s)
</Badge>
</div>
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
>
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
{doc.name}
</p>
</div>
{doc.id && (
<div className="flex items-center gap-1 flex-shrink-0">
{canPreviewDocument(doc) && (
<button
type="button"
onClick={() => handlePreviewDocument(doc)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (doc.id) {
await downloadDocument(doc.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</div>
)}
</div>
))}
</div> </div>
</div> </div>
)} </>
) : (
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<Calendar className="w-4 h-4 text-purple-600" />
Expected Completion Date
</h3>
</div> </div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p>
</div>
</div>
</div>
{/* Right Column - Planning & Details */} {/* Comments Section - Side by Side */}
<div className="space-y-4 lg:space-y-4 flex flex-col"> <div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
{/* Cost Breakup Section */} <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
{/* Dealer Comments */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2"> <h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-green-600" /> <MessageSquare className="w-4 h-4 text-blue-600" />
Cost Breakup Dealer Comments
</h3> </h3>
</div> </div>
{(() => { <div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
// Ensure costBreakup is an array <p className="text-xs text-gray-700 whitespace-pre-wrap">
const costBreakup = proposalData?.costBreakup {proposalData?.dealerComments || 'No comments provided'}
? (Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []))
: [];
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
<>
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
<div className={`grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4 text-xs lg:text-sm font-semibold text-gray-700`}>
<div className="col-span-1">Item Description</div>
<div className="text-right">Base</div>
{!isNonGst && <div className="text-right">GST</div>}
<div className="text-right">Total</div>
</div>
</div>
<div className="divide-y">
{costBreakup.map((item: any, index: number) => (
<div key={item?.id || item?.description || index} className={`px-3 lg:px-4 py-2 lg:py-3 grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4`}>
<div className="col-span-1 text-xs lg:text-sm text-gray-700">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="font-medium">
{item?.description?.startsWith('[ADDITIONAL]')
? item.description.replace('[ADDITIONAL]', '').trim()
: (item?.description || 'N/A')}
</span>
{costBreakup.some((i: any) => i?.description?.startsWith('[ADDITIONAL]')) && (
item?.description?.startsWith('[ADDITIONAL]') ? (
<Badge className="text-[9px] h-3.5 px-1 bg-amber-100 text-amber-700 hover:bg-amber-100 border-none leading-none">ADDITIONAL</Badge>
) : (
<Badge className="text-[9px] h-3.5 px-1 bg-gray-100 text-gray-600 hover:bg-gray-100 border-none leading-none">ORIGINAL</Badge>
)
)}
</div>
{!isNonGst && item?.gstRate ? <span className="block text-[10px] text-gray-400">{item.gstRate}% GST</span> : null}
</div>
<div className="text-xs lg:text-sm text-gray-900 text-right">
{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
{!isNonGst && (
<div className="text-xs lg:text-sm text-gray-900 text-right">
{(Number(item?.gstAmt) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.totalAmt || ((item?.amount || 0) * (item?.quantity || 1) + (item?.gstAmt || 0))) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
))}
</div>
</div>
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-[--re-green]" />
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
</div>
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</>
) : (
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<Calendar className="w-4 h-4 text-purple-600" />
Expected Completion Date
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p> </p>
</div> </div>
</div> </div>
</div>
{/* Comments Section - Side by Side */} {/* Your Decision & Comments */}
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2"> <div className="space-y-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6"> <h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
{/* Dealer Comments */} <Textarea
<div className="space-y-2"> placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
<div className="flex items-center gap-2"> value={comments}
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2"> onChange={(e) => setComments(e.target.value)}
<MessageSquare className="w-4 h-4 text-blue-600" /> className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
Dealer Comments />
</h3> <p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
<p className="text-xs text-gray-700 whitespace-pre-wrap">
{proposalData?.dealerComments || 'No comments provided'}
</p>
</div>
</div>
{/* Your Decision & Comments */}
<div className="space-y-2">
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
<Textarea
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
</div> </div>
</div> </div>
</div>
{/* Warning for missing comments */} {/* Warning for missing comments */}
{!comments.trim() && ( {!comments.trim() && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" /> <XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800"> <p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants. Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p> </p>
</div> </div>
)} )}
</div> </div>
</div> </div>
@ -839,10 +756,8 @@ export function InitiatorProposalApprovalModal({
</div> </div>
{/* Warning for IO not blocked - shown below Approve button */} {/* Warning for IO not blocked - shown below Approve button */}
{!isIOBlocked && ( {!isIOBlocked && (
<p className="text-xs text-red-600 text-center sm:text-left font-medium"> <p className="text-xs text-red-600 text-center sm:text-left">
{totalBlockedAmount > 0 Please block IO budget in the IO Tab before approving
? `Pending block: ₹${remainingBaseToBlock.toLocaleString('en-IN', { minimumFractionDigits: 2 })} more needs to be blocked in the IO Tab.`
: "Please block IO budget in the IO Tab before approving."}
</p> </p>
)} )}
</div> </div>

View File

@ -41,7 +41,7 @@ import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal'; import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
// Dealer Claim Components (import from index to get properly aliased exports) // Dealer Claim Components (import from index to get properly aliased exports)
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index'; import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
@ -153,14 +153,10 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number) // Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
const currentUserId = (user as any)?.userId || ''; const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims // IO tab visibility for dealer claims
// Restricted: Hide from Dealers, show for internal roles (Initiator, Dept Lead, Finance, Admin) // Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
const isDealer = (user as any)?.jobTitle === 'Dealer' || (user as any)?.designation === 'Dealer'; const showIOTab = isInitiator;
const isClaimManagement = request?.workflowType === 'CLAIM_MANAGEMENT' ||
apiRequest?.workflowType === 'CLAIM_MANAGEMENT' ||
request?.templateType === 'claim-management';
const showIOTab = isClaimManagement && !isDealer;
const { const {
mergedMessages, mergedMessages,
@ -181,7 +177,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// State to temporarily store approval level for modal (used for additional approvers) // State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null); const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel // Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel; const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
@ -224,7 +220,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Check both lowercase and uppercase status values // Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase(); const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator; const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
// Closure check completed // Closure check completed
const { const {
conclusionRemark, conclusionRemark,
@ -339,7 +335,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try { try {
setLoadingSummary(true); setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId); const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) { if (summary?.summaryId) {
setSummaryId(summary.summaryId); setSummaryId(summary.summaryId);
try { try {
@ -380,9 +376,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const notifRequestId = notif.requestId || notif.request_id; const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number; const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId && if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier && notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return; notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata // Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) { if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
@ -431,15 +427,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message} {accessDenied.message}
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -464,15 +460,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
The dealer claim request you're looking for doesn't exist or may have been deleted. The dealer claim request you're looking for doesn't exist or may have been deleted.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -602,8 +598,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{isClosed && ( {isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content"> <TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab <SummaryTab
summary={summaryDetails} summary={summaryDetails}
loading={loadingSummary} loading={loadingSummary}
onShare={handleShareSummary} onShare={handleShareSummary}
isInitiator={isInitiator} isInitiator={isInitiator}
@ -677,7 +673,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
request={request} request={request}
isInitiator={isInitiator} isInitiator={isInitiator}
isSpectator={isSpectator} isSpectator={isSpectator}
currentApprovalLevel={currentApprovalLevel} currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel}
onAddApprover={() => setShowAddApproverModal(true)} onAddApprover={() => setShowAddApproverModal(true)}
onAddSpectator={() => setShowAddSpectatorModal(true)} onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)} onApprove={() => setShowApproveModal(true)}

View File

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { uploadDocument } from '@/services/documentApi'; import { uploadDocument } from '@/services/documentApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { handleSecurityError } from '@/utils/securityToast';
/** /**
* Custom Hook: useDocumentUpload * Custom Hook: useDocumentUpload
@ -27,7 +26,7 @@ export function useDocumentUpload(
) { ) {
// State: Indicates if document is currently being uploaded // State: Indicates if document is currently being uploaded
const [uploadingDocument, setUploadingDocument] = useState(false); const [uploadingDocument, setUploadingDocument] = useState(false);
// State: Stores document for preview modal // State: Stores document for preview modal
const [previewDocument, setPreviewDocument] = useState<{ const [previewDocument, setPreviewDocument] = useState<{
fileName: string; fileName: string;
@ -102,7 +101,7 @@ export function useDocumentUpload(
// Check file extension // Check file extension
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return { return {
valid: false, valid: false,
@ -131,12 +130,12 @@ export function useDocumentUpload(
*/ */
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
// Validate: Check if file is selected // Validate: Check if file is selected
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const fileArray = Array.from(files); const fileArray = Array.from(files);
// Validate all files against document policy // Validate all files against document policy
const validationErrors: Array<{ fileName: string; reason: string }> = []; const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = []; const validFiles: File[] = [];
@ -170,11 +169,11 @@ export function useDocumentUpload(
} }
setUploadingDocument(true); setUploadingDocument(true);
try { try {
// Upload only the first valid file (backend currently supports single file) // Upload only the first valid file (backend currently supports single file)
const file = validFiles[0]; const file = validFiles[0];
// Validate: Ensure request ID is available // Validate: Ensure request ID is available
// Note: Backend requires UUID, not request number // Note: Backend requires UUID, not request number
const requestId = apiRequest?.requestId; const requestId = apiRequest?.requestId;
@ -182,17 +181,17 @@ export function useDocumentUpload(
toast.error('Request ID not found'); toast.error('Request ID not found');
return; return;
} }
// API Call: Upload document to backend // API Call: Upload document to backend
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED') // Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
if (file) { if (file) {
await uploadDocument(file, requestId, 'SUPPORTING'); await uploadDocument(file, requestId, 'SUPPORTING');
} }
// Refresh: Reload request details to show newly uploaded document // Refresh: Reload request details to show newly uploaded document
// This also updates the activity timeline // This also updates the activity timeline
await refreshDetails(); await refreshDetails();
// Success feedback // Success feedback
if (validFiles.length < fileArray.length) { if (validFiles.length < fileArray.length) {
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`); toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
@ -201,14 +200,12 @@ export function useDocumentUpload(
} }
} catch (error: any) { } catch (error: any) {
console.error('[useDocumentUpload] Upload error:', error); console.error('[useDocumentUpload] Upload error:', error);
// Show security-specific red toast for scan errors, or generic error toast // Error feedback with backend error message if available
if (!handleSecurityError(error)) { toast.error(error?.response?.data?.error || 'Failed to upload document');
toast.error(error?.response?.data?.message || 'Failed to upload document');
}
} finally { } finally {
setUploadingDocument(false); setUploadingDocument(false);
// Cleanup: Clear the file input to allow re-uploading same file // Cleanup: Clear the file input to allow re-uploading same file
if (event.target) { if (event.target) {
event.target.value = ''; event.target.value = '';

View File

@ -1,6 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import workflowApi, { getPauseDetails } from '@/services/workflowApi'; import workflowApi, { getPauseDetails } from '@/services/workflowApi';
import apiClient from '@/services/authApi'; import apiClient from '@/services/authApi';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { getSocket } from '@/utils/socket'; import { getSocket } from '@/utils/socket';
/** /**
@ -28,19 +30,19 @@ export function useRequestDetails(
) { ) {
// State: Stores the fetched and transformed request data // State: Stores the fetched and transformed request data
const [apiRequest, setApiRequest] = useState<any | null>(null); const [apiRequest, setApiRequest] = useState<any | null>(null);
// State: Indicates if data is currently being fetched // State: Indicates if data is currently being fetched
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// State: Loading state for initial fetch // State: Loading state for initial fetch
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// State: Access denied information // State: Access denied information
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null); const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
// State: Stores the current approval level for the logged-in user // State: Stores the current approval level for the logged-in user
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null); const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
// State: Indicates if the current user is a spectator (view-only access) // State: Indicates if the current user is a spectator (view-only access)
const [isSpectator, setIsSpectator] = useState(false); const [isSpectator, setIsSpectator] = useState(false);
@ -101,14 +103,14 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : []; const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {}; const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// Debug: Log TAT alerts for monitoring // Debug: Log TAT alerts for monitoring
if (tatAlerts.length > 0) { if (tatAlerts.length > 0) {
// TAT alerts loaded - logging removed // TAT alerts loaded - logging removed
} }
const currentLevel = summary?.currentLevel || wf.currentLevel || 1; const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
/** /**
* Transform: Map approval levels to UI format with TAT alerts * Transform: Map approval levels to UI format with TAT alerts
* Each approval level includes: * Each approval level includes:
@ -121,10 +123,10 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0; const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase(); const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id; const levelId = a.levelId || a.level_id;
// Determine display status based on workflow progress // Determine display status based on workflow progress
let displayStatus = statusMap(a.status); let displayStatus = statusMap(a.status);
// Future levels that haven't been reached yet show as "waiting" // Future levels that haven't been reached yet show as "waiting"
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') { if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
displayStatus = 'waiting'; displayStatus = 'waiting';
@ -133,10 +135,10 @@ export function useRequestDetails(
else if (levelNumber === currentLevel && levelStatus === 'PENDING') { else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
displayStatus = 'pending'; displayStatus = 'pending';
} }
// Filter: Get TAT alerts that belong to this specific approval level // Filter: Get TAT alerts that belong to this specific approval level
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return { return {
step: levelNumber, step: levelNumber,
levelId, levelId,
@ -150,8 +152,8 @@ export function useRequestDetails(
remainingHours: Number(a.remainingHours || 0), remainingHours: Number(a.remainingHours || 0),
tatPercentageUsed: Number(a.tatPercentageUsed || 0), tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Calculate actual hours taken if level is completed // Calculate actual hours taken if level is completed
actualHours: a.levelEndTime && a.levelStartTime actualHours: a.levelEndTime && a.levelStartTime
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
: undefined, : undefined,
comment: a.comments || undefined, comment: a.comments || undefined,
timestamp: a.actionDate || undefined, timestamp: a.actionDate || undefined,
@ -209,11 +211,11 @@ export function useRequestDetails(
* Filter: Remove TAT breach activities from audit trail * Filter: Remove TAT breach activities from audit trail
* TAT warnings are already shown in approval steps, no need to duplicate in timeline * TAT warnings are already shown in approval steps, no need to duplicate in timeline
*/ */
const filteredActivities = Array.isArray(details.activities) const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => { ? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase(); const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning'; return activityType !== 'sla_warning';
}) })
: []; : [];
/** /**
@ -222,7 +224,7 @@ export function useRequestDetails(
*/ */
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false; const isPaused = (wf as any).isPaused || false;
if (isPaused) { if (isPaused) {
try { try {
pauseInfo = await getPauseDetails(wf.requestId); pauseInfo = await getPauseDetails(wf.requestId);
@ -238,26 +240,24 @@ export function useRequestDetails(
let proposalDetails = null; let proposalDetails = null;
let completionDetails = null; let completionDetails = null;
let internalOrder = null; let internalOrder = null;
let internalOrders = [];
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try { try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data; const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) { if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details; claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details; proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details; completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null; internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables // New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null; const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null; const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null; const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access // Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) { if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking; (claimDetails as any).budgetTracking = budgetTracking;
@ -265,7 +265,7 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote; (claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses; (claimDetails as any).completionExpenses = completionExpenses;
} }
// Extracted details processed // Extracted details processed
} else { } else {
console.warn('[useRequestDetails] No claimData found in response'); console.warn('[useRequestDetails] No claimData found in response');
@ -328,14 +328,13 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null, proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null, completionDetails: completionDetails || null,
internalOrder: internalOrder || null, internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility) // New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null, budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(updatedRequest); setApiRequest(updatedRequest);
/** /**
@ -353,8 +352,8 @@ export function useRequestDetails(
const approvalLevelNumber = a.levelNumber || 0; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level // Include PAUSED status - paused level is still the current level
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED') return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });
setCurrentApprovalLevel(newCurrentLevel || null); setCurrentApprovalLevel(newCurrentLevel || null);
@ -365,8 +364,8 @@ export function useRequestDetails(
*/ */
const viewerId = (user as any)?.userId; const viewerId = (user as any)?.userId;
if (viewerId) { if (viewerId) {
const isSpec = participants.some((p: any) => const isSpec = participants.some((p: any) =>
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' && (p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
(p.userId || p.user_id) === viewerId (p.userId || p.user_id) === viewerId
); );
setIsSpectator(isSpec); setIsSpectator(isSpec);
@ -390,11 +389,11 @@ export function useRequestDetails(
setLoading(false); setLoading(false);
return; return;
} }
let mounted = true; let mounted = true;
setLoading(true); setLoading(true);
setAccessDenied(null); setAccessDenied(null);
(async () => { (async () => {
try { try {
const details = await workflowApi.getWorkflowDetails(requestIdentifier); const details = await workflowApi.getWorkflowDetails(requestIdentifier);
@ -402,7 +401,7 @@ export function useRequestDetails(
if (mounted) setLoading(false); if (mounted) setLoading(false);
return; return;
} }
// Use the same transformation logic as refreshDetails // Use the same transformation logic as refreshDetails
const wf = details.workflow || {}; const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : []; const approvals = Array.isArray(details.approvals) ? details.approvals : [];
@ -410,7 +409,7 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : []; const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {}; const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// TAT alerts received - logging removed // TAT alerts received - logging removed
const priority = (wf.priority || '').toString().toLowerCase(); const priority = (wf.priority || '').toString().toLowerCase();
@ -421,9 +420,9 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0; const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase(); const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id; const levelId = a.levelId || a.level_id;
let displayStatus = statusMap(a.status); let displayStatus = statusMap(a.status);
// If paused, show paused status (don't change it) // If paused, show paused status (don't change it)
if (levelStatus === 'PAUSED') { if (levelStatus === 'PAUSED') {
displayStatus = 'paused'; displayStatus = 'paused';
@ -432,9 +431,9 @@ export function useRequestDetails(
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) { } else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending'; displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
} }
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return { return {
step: levelNumber, step: levelNumber,
levelId, levelId,
@ -449,8 +448,8 @@ export function useRequestDetails(
tatPercentageUsed: Number(a.tatPercentageUsed || 0), tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Use backend-calculated elapsedHours (working hours) for completed approvals // Use backend-calculated elapsedHours (working hours) for completed approvals
// Backend already calculates this correctly using calculateElapsedWorkingHours // Backend already calculates this correctly using calculateElapsedWorkingHours
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
? Number(a.elapsedHours) ? Number(a.elapsedHours)
: undefined, : undefined,
comment: a.comments || undefined, comment: a.comments || undefined,
timestamp: a.actionDate || undefined, timestamp: a.actionDate || undefined,
@ -458,7 +457,7 @@ export function useRequestDetails(
tatAlerts: levelAlerts, tatAlerts: levelAlerts,
}; };
}); });
// Map spectators // Map spectators
const spectators = participants const spectators = participants
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR') .filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
@ -493,18 +492,18 @@ export function useRequestDetails(
}); });
// Filter out TAT warnings from activities // Filter out TAT warnings from activities
const filteredActivities = Array.isArray(details.activities) const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => { ? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase(); const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning'; return activityType !== 'sla_warning';
}) })
: []; : [];
// Fetch pause details only if request is actually paused // Fetch pause details only if request is actually paused
// Use request-level isPaused field from workflow response // Use request-level isPaused field from workflow response
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false; const isPaused = (wf as any).isPaused || false;
if (isPaused) { if (isPaused) {
try { try {
pauseInfo = await getPauseDetails(wf.requestId); pauseInfo = await getPauseDetails(wf.requestId);
@ -520,25 +519,23 @@ export function useRequestDetails(
let proposalDetails = null; let proposalDetails = null;
let completionDetails = null; let completionDetails = null;
let internalOrder = null; let internalOrder = null;
let internalOrders = [];
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try { try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data; const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) { if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details; claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details; proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details; completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null; internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables // New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null; const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null; const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null; const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access // Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) { if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking; (claimDetails as any).budgetTracking = budgetTracking;
@ -546,7 +543,7 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote; (claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses; (claimDetails as any).completionExpenses = completionExpenses;
} }
// Initial load - Extracted details processed // Initial load - Extracted details processed
} }
} catch (error: any) { } catch (error: any) {
@ -596,16 +593,15 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null, proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null, completionDetails: completionDetails || null,
internalOrder: internalOrder || null, internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility) // New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null, budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);
// Find current user's approval level // Find current user's approval level
// Only show approve/reject buttons if user is the CURRENT active approver // Only show approve/reject buttons if user is the CURRENT active approver
// Include PAUSED status - when paused, the paused level is still the current level // Include PAUSED status - when paused, the paused level is still the current level
@ -616,8 +612,8 @@ export function useRequestDetails(
const approvalLevelNumber = a.levelNumber || 0; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level // Include PAUSED status - paused level is still the current level
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });
setCurrentApprovalLevel(userCurrentLevel || null); setCurrentApprovalLevel(userCurrentLevel || null);
@ -625,7 +621,7 @@ export function useRequestDetails(
// Check spectator status // Check spectator status
const viewerId = (user as any)?.userId; const viewerId = (user as any)?.userId;
if (viewerId) { if (viewerId) {
const isSpec = participants.some((p: any) => const isSpec = participants.some((p: any) =>
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId (p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
); );
setIsSpectator(isSpec); setIsSpectator(isSpec);
@ -637,7 +633,7 @@ export function useRequestDetails(
if (mounted) { if (mounted) {
// Check for 403 Forbidden (Access Denied) // Check for 403 Forbidden (Access Denied)
if (error?.response?.status === 403) { if (error?.response?.status === 403) {
const message = error?.response?.data?.message || const message = error?.response?.data?.message ||
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.'; 'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
setAccessDenied({ denied: true, message }); setAccessDenied({ denied: true, message });
} }
@ -649,26 +645,34 @@ export function useRequestDetails(
} }
} }
})(); })();
return () => { mounted = false; }; return () => { mounted = false; };
}, [requestIdentifier, user]); }, [requestIdentifier, user]);
/** /**
* Computed: Get final request object with fallback to static databases * Computed: Get final request object with fallback to static databases
* Priority: API data Custom Database Claim Database Dynamic props null * Priority: API data Custom DB Claim DB Dynamic props null
*/ */
const request = useMemo(() => { const request = useMemo(() => {
// Primary source: API data // Primary source: API data
if (apiRequest) return apiRequest; if (apiRequest) return apiRequest;
// Fallback: Dynamic requests passed as props // Fallback 1: Static custom request database
const dynamicRequest = dynamicRequests.find((req: any) => const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
req.id === requestIdentifier || if (customRequest) return customRequest;
// Fallback 2: Static claim management database
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
if (claimRequest) return claimRequest;
// Fallback 3: Dynamic requests passed as props
const dynamicRequest = dynamicRequests.find((req: any) =>
req.id === requestIdentifier ||
req.requestNumber === requestIdentifier || req.requestNumber === requestIdentifier ||
req.request_number === requestIdentifier req.request_number === requestIdentifier
); );
if (dynamicRequest) return dynamicRequest; if (dynamicRequest) return dynamicRequest;
return null; return null;
}, [requestIdentifier, dynamicRequests, apiRequest]); }, [requestIdentifier, dynamicRequests, apiRequest]);
@ -689,9 +693,9 @@ export function useRequestDetails(
*/ */
const existingParticipants = useMemo(() => { const existingParticipants = useMemo(() => {
if (!request) return []; if (!request) return [];
const participants: Array<{ email: string; participantType: string; name?: string }> = []; const participants: Array<{ email: string; participantType: string; name?: string }> = [];
// Add initiator // Add initiator
if (request.initiator?.email) { if (request.initiator?.email) {
participants.push({ participants.push({
@ -700,7 +704,7 @@ export function useRequestDetails(
name: request.initiator.name name: request.initiator.name
}); });
} }
// Add approvers from approval flow // Add approvers from approval flow
if (request.approvalFlow && Array.isArray(request.approvalFlow)) { if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
request.approvalFlow.forEach((approval: any) => { request.approvalFlow.forEach((approval: any) => {
@ -713,7 +717,7 @@ export function useRequestDetails(
} }
}); });
} }
// Add spectators // Add spectators
if (request.spectators && Array.isArray(request.spectators)) { if (request.spectators && Array.isArray(request.spectators)) {
request.spectators.forEach((spectator: any) => { request.spectators.forEach((spectator: any) => {
@ -726,20 +730,20 @@ export function useRequestDetails(
} }
}); });
} }
// Add from participants array // Add from participants array
if (request.participants && Array.isArray(request.participants)) { if (request.participants && Array.isArray(request.participants)) {
request.participants.forEach((p: any) => { request.participants.forEach((p: any) => {
const email = (p.userEmail || p.email || '').toLowerCase(); const email = (p.userEmail || p.email || '').toLowerCase();
const participantType = (p.participantType || p.participant_type || '').toUpperCase(); const participantType = (p.participantType || p.participant_type || '').toUpperCase();
const name = p.userName || p.user_name || p.name; const name = p.userName || p.user_name || p.name;
if (email && participantType && !participants.find(x => x.email === email)) { if (email && participantType && !participants.find(x => x.email === email)) {
participants.push({ email, participantType, name }); participants.push({ email, participantType, name });
} }
}); });
} }
return participants; return participants;
}, [request]); }, [request]);
@ -758,12 +762,12 @@ export function useRequestDetails(
*/ */
useEffect(() => { useEffect(() => {
if (!requestIdentifier || !apiRequest) return; if (!requestIdentifier || !apiRequest) return;
const socket = getSocket(); const socket = getSocket();
if (!socket) { if (!socket) {
return; return;
} }
/** /**
* Handler: Request updated by another user * Handler: Request updated by another user
* Silently refresh to show latest changes * Silently refresh to show latest changes
@ -775,10 +779,10 @@ export function useRequestDetails(
refreshDetails(); refreshDetails();
} }
}; };
// Register listener // Register listener
socket.on('request:updated', handleRequestUpdated); socket.on('request:updated', handleRequestUpdated);
// Cleanup on unmount // Cleanup on unmount
return () => { return () => {
socket.off('request:updated', handleRequestUpdated); socket.off('request:updated', handleRequestUpdated);

View File

@ -41,21 +41,21 @@ export function Auth() {
} }
}; };
// const handleTanflowLogin = () => { /* const handleTanflowLogin = () => {
// // Clear any existing session data // Clear any existing session data
// localStorage.clear(); localStorage.clear();
// sessionStorage.clear(); sessionStorage.clear();
// setTanflowLoading(true); setTanflowLoading(true);
// try { try {
// initiateTanflowLogin(); initiateTanflowLogin();
// } catch (loginError) { } catch (loginError) {
// console.error('========================================'); console.error('========================================');
// console.error('TANFLOW LOGIN ERROR'); console.error('TANFLOW LOGIN ERROR');
// console.error('Error details:', loginError); console.error('Error details:', loginError);
// setTanflowLoading(false); setTanflowLoading(false);
// } }
// }; }; */
if (error) { if (error) {
console.error('Auth Error in Auth Component:', { console.error('Auth Error in Auth Component:', {
@ -123,8 +123,8 @@ export function Auth() {
</> </>
)} )}
</Button> </Button>
{/*
{/* <div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-700"></span> <span className="w-full border-t border-gray-700"></span>
</div> </div>
@ -141,8 +141,8 @@ export function Auth() {
> >
{tanflowLoading ? ( {tanflowLoading ? (
<> <>
<div <div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/> />
Redirecting... Redirecting...
</> </>

View File

@ -29,14 +29,14 @@ export function ClosedRequestsFilters({
searchTerm, searchTerm,
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter, // templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
activeFiltersCount, activeFiltersCount,
onSearchChange, onSearchChange,
onPriorityChange, onPriorityChange,
onStatusChange, onStatusChange,
onTemplateTypeChange, // onTemplateTypeChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
@ -129,7 +129,7 @@ export function ClosedRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> {/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -138,7 +138,7 @@ export function ClosedRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}> <Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>

View File

@ -22,11 +22,11 @@ export function MyRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter: _templateTypeFilter, // templateTypeFilter,
onSearchChange, onSearchChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
onTemplateTypeChange: _onTemplateTypeChange, // onTemplateTypeChange,
}: MyRequestsFiltersProps) { }: MyRequestsFiltersProps) {
return ( return (
<Card className="border-gray-200" data-testid="my-requests-filters"> <Card className="border-gray-200" data-testid="my-requests-filters">
@ -76,7 +76,7 @@ export function MyRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
{/* <Select value={_templateTypeFilter} onValueChange={_onTemplateTypeChange}> {/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger <SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10" className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
data-testid="template-type-filter" data-testid="template-type-filter"

View File

@ -64,10 +64,10 @@ export function OverviewTab({
const isPaused = pauseInfo?.isPaused || false; const isPaused = pauseInfo?.isPaused || false;
const pausedByUserId = pauseInfo?.pausedBy?.userId; const pausedByUserId = pauseInfo?.pausedBy?.userId;
const currentUserId = (user as any)?.userId || ''; const currentUserId = (user as any)?.userId || '';
// Resume: Can be done by both initiator and approver // Resume: Can be done by both initiator and approver
const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator); const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator);
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume) // Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger; const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
@ -122,8 +122,8 @@ export function OverviewTab({
<div> <div>
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label> <label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300"> <div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300">
<FormattedDescription <FormattedDescription
content={request.description || ''} content={request.description || ''}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
/> />
</div> </div>
@ -184,20 +184,17 @@ export function OverviewTab({
{pauseInfo.pauseReason && ( {pauseInfo.pauseReason && (
<div> <div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label> <label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
<FormattedDescription <p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
content={pauseInfo.pauseReason}
className="text-sm text-gray-900 mt-1"
/>
</div> </div>
)} )}
{pauseInfo.pausedBy && ( {pauseInfo.pausedBy && (
<div> <div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label> <label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p> <p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
</div> </div>
)} )}
{pauseInfo.pauseResumeDate && ( {pauseInfo.pauseResumeDate && (
<div> <div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label> <label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
@ -211,7 +208,7 @@ export function OverviewTab({
</p> </p>
</div> </div>
)} )}
{pauseInfo.pausedAt && ( {pauseInfo.pausedAt && (
<div> <div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label> <label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
@ -292,8 +289,8 @@ export function OverviewTab({
<div className="pt-4 border-t border-gray-300"> <div className="pt-4 border-t border-gray-300">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label> <label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
<div className="mt-2 bg-gray-50 p-3 rounded-lg"> <div className="mt-2 bg-gray-50 p-3 rounded-lg">
<FormattedDescription <FormattedDescription
content={request.claimDetails.requestDescription} content={request.claimDetails.requestDescription}
className="text-sm" className="text-sm"
/> />
</div> </div>
@ -315,8 +312,8 @@ export function OverviewTab({
</CardHeader> </CardHeader>
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> <div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<FormattedDescription <FormattedDescription
content={request.conclusionRemark || ''} content={request.conclusionRemark || ''}
className="text-sm" className="text-sm"
/> />
</div> </div>
@ -334,20 +331,23 @@ export function OverviewTab({
{/* Conclusion Remark Section */} {/* Conclusion Remark Section */}
{needsClosure && ( {needsClosure && (
<Card data-testid="conclusion-remark-card"> <Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${request.status === 'rejected' <CardHeader className={`bg-gradient-to-r border-b ${
? 'from-red-50 to-rose-50 border-red-200' request.status === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 border-green-200' : 'from-green-50 to-emerald-50 border-green-200'
}`}> }`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${request.status === 'rejected' ? 'text-red-700' : 'text-green-700' <CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
}`}> request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
<CheckCircle className={`w-5 h-5 ${request.status === 'rejected' ? 'text-red-600' : 'text-green-600' }`}>
}`} /> <CheckCircle className={`w-5 h-5 ${
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
Conclusion Remark - Final Step Conclusion Remark - Final Step
</CardTitle> </CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm"> <CardDescription className="mt-1 text-xs sm:text-sm">
{request.status === 'rejected' {request.status === 'rejected'
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.' ? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'} : 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
</CardDescription> </CardDescription>
@ -365,7 +365,7 @@ export function OverviewTab({
{aiGenerated ? 'Regenerate' : 'Generate with AI'} {aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button> </Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && ( {aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1"> <span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining {2 - generationAttempts} attempts remaining
</span> </span>
)} )}

View File

@ -92,8 +92,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
</div> </div>
{summary.description && ( {summary.description && (
<div className="mb-4"> <div className="mb-4">
<FormattedDescription <FormattedDescription
content={summary.description} content={summary.description}
className="text-gray-700" className="text-gray-700"
/> />
</div> </div>
@ -163,14 +163,7 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 mb-1">Remarks</p> <p className="text-xs text-gray-500 mb-1">Remarks</p>
{approver.remarks ? ( <p className="text-sm text-gray-700">{approver.remarks || '—'}</p>
<FormattedDescription
content={approver.remarks}
className="text-sm text-gray-700"
/>
) : (
<p className="text-sm text-gray-700"></p>
)}
</div> </div>
</div> </div>
))} ))}
@ -206,8 +199,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
<div> <div>
<p className="text-xs text-gray-500 mb-1">Remarks</p> <p className="text-xs text-gray-500 mb-1">Remarks</p>
{summary.closingRemarks ? ( {summary.closingRemarks ? (
<FormattedDescription <FormattedDescription
content={summary.closingRemarks} content={summary.closingRemarks}
className="text-sm text-gray-700" className="text-sm text-gray-700"
/> />
) : ( ) : (

View File

@ -11,6 +11,7 @@ import { useAppSelector } from '@/redux/hooks';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import type { DateRange } from '@/services/dashboard.service'; import type { DateRange } from '@/services/dashboard.service';
import dashboardService from '@/services/dashboard.service'; import dashboardService from '@/services/dashboard.service';
import userApi from '@/services/userApi';
// Components // Components
import { RequestsHeader } from './components/RequestsHeader'; import { RequestsHeader } from './components/RequestsHeader';
@ -69,6 +70,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); const [backendStats, setBackendStats] = useState<BackendStats | null>(null);
const [departments, setDepartments] = useState<string[]>([]); const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false); const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination (currentPage now in Redux) // Pagination (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@ -77,15 +79,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
// User search hooks // User search hooks
const initiatorSearch = useUserSearch({ const initiatorSearch = useUserSearch({
allUsers,
filterValue: filters.initiatorFilter, filterValue: filters.initiatorFilter,
onFilterChange: filters.setInitiatorFilter, onFilterChange: filters.setInitiatorFilter
source: 'local'
}); });
const approverSearch = useUserSearch({ const approverSearch = useUserSearch({
allUsers,
filterValue: filters.approverFilter, filterValue: filters.approverFilter,
onFilterChange: filters.setApproverFilter, onFilterChange: filters.setApproverFilter
source: 'local'
}); });
// Fetch backend stats // Fetch backend stats
@ -98,7 +100,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
statsEndDate?: Date, statsEndDate?: Date,
filtersWithoutStatus?: { filtersWithoutStatus?: {
priority?: string; priority?: string;
templateType?: string;
department?: string; department?: string;
initiator?: string; initiator?: string;
approver?: string; approver?: string;
@ -184,7 +185,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
statsEndDate ? statsEndDate.toISOString() : undefined, statsEndDate ? statsEndDate.toISOString() : undefined,
undefined, // status - All Requests stats show all statuses, not filtered by status undefined, // status - All Requests stats show all statuses, not filtered by status
filtersWithoutStatus?.priority, filtersWithoutStatus?.priority,
filtersWithoutStatus?.templateType, undefined, // templateType
filtersWithoutStatus?.department, filtersWithoutStatus?.department,
filtersWithoutStatus?.initiator, filtersWithoutStatus?.initiator,
filtersWithoutStatus?.approver, filtersWithoutStatus?.approver,
@ -225,6 +226,20 @@ export function Requests({ onViewRequest }: RequestsProps) {
} }
}, []); }, []);
// Fetch users
const fetchUsers = useCallback(async () => {
try {
const usersData = await userApi.getAllUsers();
const usersList = usersData.map((user: any) => ({
userId: user.userId,
email: user.email,
displayName: user.displayName || user.email
}));
setAllUsers(usersList);
} catch (error) {
console.error('Failed to fetch users:', error);
}
}, []);
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
@ -317,7 +332,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
fetchDepartments(); fetchDepartments();
}, [fetchDepartments]); fetchUsers();
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when filters change (excluding status) // Fetch backend stats when filters change (excluding status)
// Stats should reflect priority, department, initiator, approver, search, and date range filters // Stats should reflect priority, department, initiator, approver, search, and date range filters

View File

@ -11,6 +11,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import dashboardService from '@/services/dashboard.service'; import dashboardService from '@/services/dashboard.service';
import type { DateRange } from '@/services/dashboard.service'; import type { DateRange } from '@/services/dashboard.service';
import userApi from '@/services/userApi';
// Components // Components
import { RequestsHeader } from './components/RequestsHeader'; import { RequestsHeader } from './components/RequestsHeader';
@ -57,7 +58,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Determine once - use this throughout instead of checking repeatedly // Determine once - use this throughout instead of checking repeatedly
const isDealer = userFilterType === 'DEALER'; const isDealer = userFilterType === 'DEALER';
// Helper to get filters for API - excludes dealer-restricted filters // Helper to get filters for API - excludes dealer-restricted filters
// Since we know user type initially, this helper uses that knowledge // Since we know user type initially, this helper uses that knowledge
const getFiltersForApi = useCallback(() => { const getFiltersForApi = useCallback(() => {
@ -69,7 +70,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
} }
return filterOptions; return filterOptions;
}, [filters, isDealer]); }, [filters, isDealer]);
// Helper to calculate active filters count based on user type // Helper to calculate active filters count based on user type
const calculateActiveFiltersCount = useCallback(() => { const calculateActiveFiltersCount = useCallback(() => {
if (isDealer) { if (isDealer) {
@ -95,6 +96,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
const [departments, setDepartments] = useState<string[]>([]); const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false); const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination (currentPage now in Redux) // Pagination (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@ -103,31 +105,31 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// User search hooks // User search hooks
const initiatorSearch = useUserSearch({ const initiatorSearch = useUserSearch({
allUsers,
filterValue: filters.initiatorFilter, filterValue: filters.initiatorFilter,
onFilterChange: filters.setInitiatorFilter, onFilterChange: filters.setInitiatorFilter
source: 'local'
}); });
const approverSearch = useUserSearch({ const approverSearch = useUserSearch({
allUsers,
filterValue: filters.approverFilter, filterValue: filters.approverFilter,
onFilterChange: filters.setApproverFilter, onFilterChange: filters.setApproverFilter
source: 'local'
}); });
// Fetch backend stats using dashboard API // Fetch backend stats using dashboard API
// OPTIMIZED: Uses backend stats API instead of fetching 100 records // OPTIMIZED: Uses backend stats API instead of fetching 100 records
// Stats reflect all filters EXCEPT status - total stays stable when only status changes // Stats reflect all filters EXCEPT status - total stays stable when only status changes
const fetchBackendStats = useCallback(async ( const fetchBackendStats = useCallback(async (
statsDateRange?: DateRange, statsDateRange?: DateRange,
statsStartDate?: Date, statsStartDate?: Date,
statsEndDate?: Date, statsEndDate?: Date,
filtersWithoutStatus?: { filtersWithoutStatus?: {
priority?: string; priority?: string;
templateType?: string; templateType?: string;
department?: string; department?: string;
initiator?: string; initiator?: string;
approver?: string; approver?: string;
approverType?: 'current' | 'any'; approverType?: 'current' | 'any';
search?: string; search?: string;
slaCompliance?: string; slaCompliance?: string;
} }
@ -178,12 +180,26 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
} }
}, []); }, []);
// Fetch users
const fetchUsers = useCallback(async () => {
try {
const usersData = await userApi.getAllUsers();
const usersList = usersData.map((user: any) => ({
userId: user.userId,
email: user.email,
displayName: user.displayName || user.email
}));
setAllUsers(usersList);
} catch (error) {
console.error('Failed to fetch users:', error);
}
}, []);
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
const fetchBackendStatsRef = useRef(fetchBackendStats); const fetchBackendStatsRef = useRef(fetchBackendStats);
const getFiltersForApiRef = useRef(getFiltersForApi); const getFiltersForApiRef = useRef(getFiltersForApi);
// Update refs on each render // Update refs on each render
useEffect(() => { useEffect(() => {
filtersRef.current = filters; filtersRef.current = filters;
@ -237,7 +253,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
fetchDepartments(); fetchDepartments();
}, [fetchDepartments]); fetchUsers();
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when filters change (except status filter) // Fetch backend stats when filters change (except status filter)
// OPTIMIZED: Uses backend stats API instead of fetching 100 records // OPTIMIZED: Uses backend stats API instead of fetching 100 records
@ -258,7 +275,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined, approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
}; };
// Only include priority, templateType, department, and slaCompliance if user is not a dealer // Only include priority, templateType, department, and slaCompliance if user is not a dealer
if (!isDealer) { if (!isDealer) {
if (filters.priorityFilter !== 'all') filtersWithoutStatus.priority = filters.priorityFilter; if (filters.priorityFilter !== 'all') filtersWithoutStatus.priority = filters.priorityFilter;
@ -266,13 +283,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
if (filters.departmentFilter !== 'all') filtersWithoutStatus.department = filters.departmentFilter; if (filters.departmentFilter !== 'all') filtersWithoutStatus.department = filters.departmentFilter;
if (filters.slaComplianceFilter !== 'all') filtersWithoutStatus.slaCompliance = filters.slaComplianceFilter; if (filters.slaComplianceFilter !== 'all') filtersWithoutStatus.slaCompliance = filters.slaComplianceFilter;
} }
// Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month' // Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month'
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month'); const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
fetchBackendStatsRef.current( fetchBackendStatsRef.current(
statsDateRange, statsDateRange,
filters.customStartDate, filters.customStartDate,
filters.customEndDate, filters.customEndDate,
filtersWithoutStatus filtersWithoutStatus
); );
@ -312,7 +329,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
customEndDate: filters.customEndDate, customEndDate: filters.customEndDate,
}); });
const hasInitialFetchRun = useRef(false); const hasInitialFetchRun = useRef(false);
// Initial fetch on mount - use stored page from Redux // Initial fetch on mount - use stored page from Redux
useEffect(() => { useEffect(() => {
const storedPage = filters.currentPage || 1; const storedPage = filters.currentPage || 1;
@ -320,13 +337,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
hasInitialFetchRun.current = true; hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount }, []); // Only on mount
// Fetch when filters change // Fetch when filters change
useEffect(() => { useEffect(() => {
if (!hasInitialFetchRun.current) return; if (!hasInitialFetchRun.current) return;
const prev = prevFiltersRef.current; const prev = prevFiltersRef.current;
const hasChanged = const hasChanged =
prev.searchTerm !== filters.searchTerm || prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter || prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter || prev.priorityFilter !== filters.priorityFilter ||
@ -339,13 +356,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
prev.dateRange !== filters.dateRange || prev.dateRange !== filters.dateRange ||
prev.customStartDate !== filters.customStartDate || prev.customStartDate !== filters.customStartDate ||
prev.customEndDate !== filters.customEndDate; prev.customEndDate !== filters.customEndDate;
if (!hasChanged) return; if (!hasChanged) return;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
filters.setCurrentPage(1); filters.setCurrentPage(1);
fetchRequests(1); fetchRequests(1);
prevFiltersRef.current = { prevFiltersRef.current = {
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
@ -389,7 +406,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Transform requests // Transform requests
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]); const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
// Calculate stats - Use backend stats API (OPTIMIZED) // Calculate stats - Use backend stats API (OPTIMIZED)
const stats = useMemo(() => { const stats = useMemo(() => {
// Use backend stats if available // Use backend stats if available
@ -404,38 +421,38 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
closed: backendStats.closed || 0 closed: backendStats.closed || 0
}; };
} }
// Fallback: calculate from current page (less accurate, but works during initial load) // Fallback: calculate from current page (less accurate, but works during initial load)
const pending = convertedRequests.filter((r: any) => { const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase(); const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress'; return status === 'pending' || status === 'in-progress';
}).length; }).length;
const paused = convertedRequests.filter((r: any) => { const paused = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase(); const status = (r.status || '').toString().toLowerCase();
return status === 'paused'; return status === 'paused';
}).length; }).length;
const approved = convertedRequests.filter((r: any) => { const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase(); const status = (r.status || '').toString().toLowerCase();
return status === 'approved'; return status === 'approved';
}).length; }).length;
const rejected = convertedRequests.filter((r: any) => { const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase(); const status = (r.status || '').toString().toLowerCase();
return status === 'rejected'; return status === 'rejected';
}).length; }).length;
const closed = convertedRequests.filter((r: any) => { const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase(); const status = (r.status || '').toString().toLowerCase();
return status === 'closed'; return status === 'closed';
}).length; }).length;
return { return {
total: totalRecords > 0 ? totalRecords : convertedRequests.length, total: totalRecords > 0 ? totalRecords : convertedRequests.length,
pending, pending,
paused, paused,
approved, approved,
rejected, rejected,
draft: 0, draft: 0,
closed closed
}; };
}, [backendStats, totalRecords, convertedRequests]); }, [backendStats, totalRecords, convertedRequests]);
return ( return (
@ -450,8 +467,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
/> />
{/* Stats */} {/* Stats */}
<RequestsStats <RequestsStats
stats={stats} stats={stats}
onStatusFilter={(status) => { onStatusFilter={(status) => {
filters.setStatusFilter(status); filters.setStatusFilter(status);
}} }}

View File

@ -4,44 +4,30 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import type { User } from '../types/requests.types'; import type { User } from '../types/requests.types';
import { userApi } from '@/services/userApi';
interface UseUserSearchOptions { interface UseUserSearchOptions {
allUsers: User[];
filterValue: string; filterValue: string;
onFilterChange: (userId: string) => void; onFilterChange: (userId: string) => void;
source?: 'local' | 'okta' | 'default';
} }
export function useUserSearch({ filterValue, onFilterChange, source = 'default' }: UseUserSearchOptions) { export function useUserSearch({ allUsers, filterValue, onFilterChange }: UseUserSearchOptions) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<User[]>([]); const [searchResults, setSearchResults] = useState<User[]>([]);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [searching, setSearching] = useState(false);
const searchTimer = useRef<NodeJS.Timeout | null>(null); const searchTimer = useRef<NodeJS.Timeout | null>(null);
// Initialize selected user details if we only have the ID (filterValue) // Initialize selected user from filter value
useEffect(() => { useEffect(() => {
async function fetchUserDetail() { if (filterValue !== 'all' && allUsers.length > 0) {
if (filterValue !== 'all' && !selectedUser) { const user = allUsers.find(u => u.userId === filterValue);
try { if (user) {
// Fetch specific user details by ID setSelectedUser(user);
const user = await userApi.getUserById(filterValue); setSearchQuery(user.displayName || user.email);
if (user) {
setSelectedUser(user);
setSearchQuery(user.displayName || user.email);
}
} catch (err) {
console.error('Failed to fetch user detail for search:', err);
}
} else if (filterValue === 'all') {
setSelectedUser(null);
setSearchQuery('');
} }
} }
}, [filterValue, allUsers]);
fetchUserDetail();
}, [filterValue]);
// Cleanup timer // Cleanup timer
useEffect(() => { useEffect(() => {
@ -65,22 +51,17 @@ export function useUserSearch({ filterValue, onFilterChange, source = 'default'
return; return;
} }
searchTimer.current = setTimeout(async () => { searchTimer.current = setTimeout(() => {
setSearching(true); const searchLower = query.toLowerCase().trim();
try { const filtered = allUsers.filter((user) => {
const response = await userApi.searchUsers(query.trim(), 10, source); const email = (user.email || '').toLowerCase();
const users = response.data?.data || []; const displayName = (user.displayName || '').toLowerCase();
setSearchResults(users); return email.includes(searchLower) || displayName.includes(searchLower);
setShowResults(users.length > 0); });
} catch (err) { setSearchResults(filtered.slice(0, 10));
console.error('Search API failed:', err); setShowResults(filtered.length > 0);
setSearchResults([]); }, 300);
setShowResults(false); }, [allUsers]);
} finally {
setSearching(false);
}
}, 400); // Slightly longer debounce for API calls
}, [source]);
const handleSelect = useCallback((user: User) => { const handleSelect = useCallback((user: User) => {
setSelectedUser(user); setSelectedUser(user);
@ -103,7 +84,6 @@ export function useUserSearch({ filterValue, onFilterChange, source = 'default'
searchResults, searchResults,
showResults, showResults,
selectedUser, selectedUser,
searching,
handleSearch, handleSearch,
handleSelect, handleSelect,
handleClear, handleClear,

View File

@ -1,67 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Lock, Key } from 'lucide-react';
import { ApiTokenManager } from '@/components/settings/ApiTokenManager';
export function SecuritySettings() {
const navigate = useNavigate();
return (
<div className="max-w-7xl mx-auto space-y-6 pb-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Security Settings</h1>
<p className="text-gray-500">Manage your account security and access tokens</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6">
{/* Password Section */}
<Card className="shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-md">
<Lock className="w-5 h-5 text-blue-600" />
</div>
<div>
<CardTitle>Password</CardTitle>
<CardDescription>Manage your sign-in password</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="p-4 bg-gray-50 rounded-md border border-gray-200">
<p className="text-sm text-gray-600">
Your password is managed through your organization's Single Sign-On (SSO) provider.
Please contact your IT administrator to reset or change your password.
</p>
</div>
</CardContent>
</Card>
{/* API Tokens Section */}
<Card className="shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-md">
<Key className="w-5 h-5 text-purple-600" />
</div>
<div>
<CardTitle>API Tokens</CardTitle>
<CardDescription>Manage personal access tokens for external integrations</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ApiTokenManager />
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -19,13 +19,10 @@ import { UserRoleManager } from '@/components/admin/UserRoleManager';
import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager'; import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager';
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal'; import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal'; import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
// import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserSubscriptions } from '@/services/notificationApi'; import { getUserSubscriptions } from '@/services/notificationApi';
export function Settings() { export function Settings() {
const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = checkIsAdmin(user); const isAdmin = checkIsAdmin(user);
const [showNotificationModal, setShowNotificationModal] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false);
@ -200,8 +197,8 @@ export function Settings() {
<span className="hidden sm:inline">Holidays</span> <span className="hidden sm:inline">Holidays</span>
<span className="sm:hidden">Holidays</span> <span className="sm:hidden">Holidays</span>
</TabsTrigger> </TabsTrigger>
{/* <TabsTrigger {/* <TabsTrigger
value="templates" value="templates"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all" className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
> >
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
@ -274,18 +271,9 @@ export function Settings() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-4">
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200"> <div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
<p className="text-sm text-gray-600 mb-4"> <p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
Manage your password, API tokens, and other security preferences.
</p>
<Button
onClick={() => navigate('/settings/security')}
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
>
<Lock className="w-4 h-4 mr-2" />
Manage Security
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -336,9 +324,6 @@ export function Settings() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Additional Settings if needed */}
</div> </div>
</TabsContent> </TabsContent>
@ -506,18 +491,9 @@ export function Settings() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-6"> <div className="space-y-4">
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200"> <div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
<p className="text-sm text-gray-600 mb-4"> <p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
Manage your password, API tokens, and other security preferences.
</p>
<Button
onClick={() => navigate('/settings/security')}
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
>
<Lock className="w-4 h-4 mr-2" />
Manage Security
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -569,8 +545,6 @@ export function Settings() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Additional sections if needed */}
</> </>
)} )}
</div> </div>

View File

@ -135,7 +135,7 @@ export const bulkImportHolidays = async (holidays: Partial<Holiday>[]): Promise<
}; };
/** /**
* Get all active activity types (requires authentication) * Get all activity types (public endpoint - no auth required)
*/ */
export const getActivityTypes = async (): Promise<ActivityType[]> => { export const getActivityTypes = async (): Promise<ActivityType[]> => {
const response = await apiClient.get('/config/activity-types'); const response = await apiClient.get('/config/activity-types');

View File

@ -6,7 +6,7 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import { TokenManager } from '../utils/tokenManager'; import { TokenManager } from '../utils/tokenManager';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
// Create axios instance with default config // Create axios instance with default config
const apiClient: AxiosInstance = axios.create({ const apiClient: AxiosInstance = axios.create({
@ -25,16 +25,16 @@ apiClient.interceptors.request.use(
// In production, cookies are sent automatically with withCredentials: true // In production, cookies are sent automatically with withCredentials: true
// No need to set Authorization header // No need to set Authorization header
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
if (!isProduction) { if (!isProduction) {
// Dev: Get token from localStorage and add to header // Development: Get token from localStorage and add to header
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
}
} }
// Prod: Cookies handle authentication automatically }
// Production: Cookies handle authentication automatically
return config; return config;
}, },
(error) => { (error) => {
@ -51,7 +51,7 @@ apiClient.interceptors.response.use(
// Handle connection errors gracefully in development // Handle connection errors gracefully in development
if (error.code === 'ERR_NETWORK' || error.code === 'ECONNREFUSED' || error.message?.includes('ERR_CONNECTION_REFUSED')) { if (error.code === 'ERR_NETWORK' || error.code === 'ECONNREFUSED' || error.message?.includes('ERR_CONNECTION_REFUSED')) {
const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development'; const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development';
if (isDevelopment) { if (isDevelopment) {
// In development, log a helpful message instead of spamming console // In development, log a helpful message instead of spamming console
console.warn(`[API] Backend not reachable at ${API_BASE_URL}. Make sure the backend server is running on port 5000.`); console.warn(`[API] Backend not reachable at ${API_BASE_URL}. Make sure the backend server is running on port 5000.`);
@ -67,7 +67,7 @@ apiClient.interceptors.response.use(
// If error is 401 and we haven't retried yet // If error is 401 and we haven't retried yet
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
// In production: Cookie is sent automatically via withCredentials // In production: Cookie is sent automatically via withCredentials
// In development: Send refresh token from localStorage // In development: Send refresh token from localStorage
const refreshToken = TokenManager.getRefreshToken(); const refreshToken = TokenManager.getRefreshToken();
// In production, refreshToken will be null but cookie will be sent // In production, refreshToken will be null but cookie will be sent
// In development, we need the token in body // In development, we need the token in body
if (!isProduction && !refreshToken) { if (!isProduction && !refreshToken) {
@ -90,14 +90,14 @@ apiClient.interceptors.response.use(
const responseData = response.data.data || response.data; const responseData = response.data.data || response.data;
const accessToken = responseData.accessToken; const accessToken = responseData.accessToken;
// In production: Backend sets new httpOnly cookie, no token in response // In production: Backend sets new httpOnly cookie, no token in response
// In development: Token is in response, store it and add to header // In development: Token is in response, store it and add to header
if (!isProduction && accessToken) { if (!isProduction && accessToken) {
TokenManager.setAccessToken(accessToken); TokenManager.setAccessToken(accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`; originalRequest.headers.Authorization = `Bearer ${accessToken}`;
} }
// Retry the original request // Retry the original request
// In production: Cookie will be sent automatically // In production: Cookie will be sent automatically
return apiClient(originalRequest); return apiClient(originalRequest);
@ -156,7 +156,7 @@ export async function exchangeCodeForTokens(
}, },
} }
); );
// Check if response is an array (buffer issue) // Check if response is an array (buffer issue)
if (Array.isArray(response.data)) { if (Array.isArray(response.data)) {
console.error('❌ Response is an array (buffer issue):', { console.error('❌ Response is an array (buffer issue):', {
@ -166,28 +166,28 @@ export async function exchangeCodeForTokens(
}); });
throw new Error('Invalid response format: received array instead of JSON. Check Content-Type header.'); throw new Error('Invalid response format: received array instead of JSON. Check Content-Type header.');
} }
const data = response.data as any; const data = response.data as any;
const result = data.data || data; const result = data.data || data;
// Store user data (always available) // Store user data (always available)
if (result.user) { if (result.user) {
TokenManager.setUserData(result.user); TokenManager.setUserData(result.user);
} }
// Store ID token if available (needed for Okta logout) // Store ID token if available (needed for Okta logout)
if (result.idToken) { if (result.idToken) {
TokenManager.setIdToken(result.idToken); TokenManager.setIdToken(result.idToken);
} }
// SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body) // SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body)
// In development, backend returns tokens for cross-port setup // In development, backend returns tokens for cross-port setup
if (result.accessToken && result.refreshToken) { if (result.accessToken && result.refreshToken) {
// Dev mode: Backend returned tokens, store them // Development mode: Backend returned tokens, store them
TokenManager.setAccessToken(result.accessToken); TokenManager.setAccessToken(result.accessToken);
TokenManager.setRefreshToken(result.refreshToken); TokenManager.setRefreshToken(result.refreshToken);
} }
// Prod mode: No tokens in response - they're in httpOnly cookies // Production mode: No tokens in response - they're in httpOnly cookies
// TokenManager.setAccessToken/setRefreshToken are no-ops in production anyway // TokenManager.setAccessToken/setRefreshToken are no-ops in production anyway
return result; return result;
@ -211,15 +211,15 @@ export async function exchangeCodeForTokens(
*/ */
export async function refreshAccessToken(): Promise<string> { export async function refreshAccessToken(): Promise<string> {
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In development, check for refresh token in localStorage // In development, check for refresh token in localStorage
if (!isProduction) { if (!isProduction) {
const refreshToken = TokenManager.getRefreshToken(); const refreshToken = TokenManager.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
throw new Error('No refresh token available'); throw new Error('No refresh token available');
}
} }
}
// In production, httpOnly cookie with refresh token will be sent automatically // In production, httpOnly cookie with refresh token will be sent automatically
// In development, we send the refresh token in the body // In development, we send the refresh token in the body
const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() }; const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() };
@ -234,7 +234,7 @@ export async function refreshAccessToken(): Promise<string> {
TokenManager.setAccessToken(accessToken); TokenManager.setAccessToken(accessToken);
return accessToken; return accessToken;
} }
// In production mode, token is set via httpOnly cookie by the backend // In production mode, token is set via httpOnly cookie by the backend
// Return a placeholder to indicate success // Return a placeholder to indicate success
if (isProduction && (data.success !== false)) { if (isProduction && (data.success !== false)) {
@ -255,7 +255,7 @@ export async function getCurrentUser() {
/** /**
* Logout user * Logout user
* IMPORTANT: This endpoint MUST clear httpOnly cookies set by backend * CRITICAL: This endpoint MUST clear httpOnly cookies set by backend
* Note: TokenManager.clearAll() is called in AuthContext.logout() * Note: TokenManager.clearAll() is called in AuthContext.logout()
* We don't call it here to avoid double clearing * We don't call it here to avoid double clearing
*/ */

View File

@ -105,16 +105,3 @@ export async function verifyDealerLogin(dealerCode: string): Promise<DealerInfo>
} }
} }
/**
* Search dealer by code from external Royal Enfield API
* @param dealerCode - The code to search for
*/
export async function searchExternalDealerByCode(dealerCode: string): Promise<any | null> {
try {
const res = await apiClient.get(`/dealers-external/search/${dealerCode}`);
return res.data?.data || res.data || null;
} catch (error) {
console.error('[DealerAPI] Error searching external dealer:', error);
return null;
}
}

View File

@ -78,22 +78,7 @@ export async function submitProposal(
requestId: string, requestId: string,
proposalData: { proposalData: {
proposalDocument?: File; proposalDocument?: File;
costBreakup?: Array<{ costBreakup?: Array<{ description: string; amount: number }>;
description: string;
amount: number;
hsnCode?: string;
isService?: boolean;
quantity?: number;
gstRate?: number;
gstAmt?: number;
cgstRate?: number;
cgstAmt?: number;
sgstRate?: number;
sgstAmt?: number;
igstRate?: number;
igstAmt?: number;
totalAmt?: number;
}>;
totalEstimatedBudget?: number; totalEstimatedBudget?: number;
timelineMode?: 'date' | 'days'; timelineMode?: 'date' | 'days';
expectedCompletionDate?: string; expectedCompletionDate?: string;
@ -103,31 +88,31 @@ export async function submitProposal(
): Promise<any> { ): Promise<any> {
try { try {
const formData = new FormData(); const formData = new FormData();
if (proposalData.proposalDocument) { if (proposalData.proposalDocument) {
formData.append('proposalDocument', proposalData.proposalDocument); formData.append('proposalDocument', proposalData.proposalDocument);
} }
if (proposalData.costBreakup) { if (proposalData.costBreakup) {
formData.append('costBreakup', JSON.stringify(proposalData.costBreakup)); formData.append('costBreakup', JSON.stringify(proposalData.costBreakup));
} }
if (proposalData.totalEstimatedBudget !== undefined) { if (proposalData.totalEstimatedBudget !== undefined) {
formData.append('totalEstimatedBudget', proposalData.totalEstimatedBudget.toString()); formData.append('totalEstimatedBudget', proposalData.totalEstimatedBudget.toString());
} }
if (proposalData.timelineMode) { if (proposalData.timelineMode) {
formData.append('timelineMode', proposalData.timelineMode); formData.append('timelineMode', proposalData.timelineMode);
} }
if (proposalData.expectedCompletionDate) { if (proposalData.expectedCompletionDate) {
formData.append('expectedCompletionDate', proposalData.expectedCompletionDate); formData.append('expectedCompletionDate', proposalData.expectedCompletionDate);
} }
if (proposalData.expectedCompletionDays !== undefined) { if (proposalData.expectedCompletionDays !== undefined) {
formData.append('expectedCompletionDays', proposalData.expectedCompletionDays.toString()); formData.append('expectedCompletionDays', proposalData.expectedCompletionDays.toString());
} }
if (proposalData.dealerComments) { if (proposalData.dealerComments) {
formData.append('dealerComments', proposalData.dealerComments); formData.append('dealerComments', proposalData.dealerComments);
} }
@ -137,7 +122,7 @@ export async function submitProposal(
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
return response.data?.data || response.data; return response.data?.data || response.data;
} catch (error: any) { } catch (error: any) {
console.error('[DealerClaimAPI] Error submitting proposal:', error); console.error('[DealerClaimAPI] Error submitting proposal:', error);
@ -154,16 +139,7 @@ export async function submitCompletion(
completionData: { completionData: {
activityCompletionDate: string; // ISO date string activityCompletionDate: string; // ISO date string
numberOfParticipants?: number; numberOfParticipants?: number;
closedExpenses?: Array<{ closedExpenses?: Array<{ description: string; amount: number }>;
description: string;
amount: number;
hsnCode?: string;
isService?: boolean;
quantity?: number;
gstRate?: number;
gstAmt?: number;
totalAmt?: number;
}>;
totalClosedExpenses?: number; totalClosedExpenses?: number;
completionDocuments?: File[]; completionDocuments?: File[];
activityPhotos?: File[]; activityPhotos?: File[];
@ -172,31 +148,31 @@ export async function submitCompletion(
): Promise<any> { ): Promise<any> {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('activityCompletionDate', completionData.activityCompletionDate); formData.append('activityCompletionDate', completionData.activityCompletionDate);
if (completionData.numberOfParticipants !== undefined) { if (completionData.numberOfParticipants !== undefined) {
formData.append('numberOfParticipants', completionData.numberOfParticipants.toString()); formData.append('numberOfParticipants', completionData.numberOfParticipants.toString());
} }
if (completionData.closedExpenses) { if (completionData.closedExpenses) {
formData.append('closedExpenses', JSON.stringify(completionData.closedExpenses)); formData.append('closedExpenses', JSON.stringify(completionData.closedExpenses));
} }
if (completionData.totalClosedExpenses !== undefined) { if (completionData.totalClosedExpenses !== undefined) {
formData.append('totalClosedExpenses', completionData.totalClosedExpenses.toString()); formData.append('totalClosedExpenses', completionData.totalClosedExpenses.toString());
} }
if (completionData.completionDescription) { if (completionData.completionDescription) {
formData.append('completionDescription', completionData.completionDescription); formData.append('completionDescription', completionData.completionDescription);
} }
if (completionData.completionDocuments) { if (completionData.completionDocuments) {
completionData.completionDocuments.forEach((file) => { completionData.completionDocuments.forEach((file) => {
formData.append('completionDocuments', file); formData.append('completionDocuments', file);
}); });
} }
if (completionData.activityPhotos) { if (completionData.activityPhotos) {
completionData.activityPhotos.forEach((file) => { completionData.activityPhotos.forEach((file) => {
formData.append('activityPhotos', file); formData.append('activityPhotos', file);
@ -208,7 +184,7 @@ export async function submitCompletion(
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
return response.data?.data || response.data; return response.data?.data || response.data;
} catch (error: any) { } catch (error: any) {
console.error('[DealerClaimAPI] Error submitting completion:', error); console.error('[DealerClaimAPI] Error submitting completion:', error);
@ -264,7 +240,7 @@ export async function updateIODetails(
ioNumber: ioData.ioNumber, ioNumber: ioData.ioNumber,
ioRemark: ioData.ioRemark || '', ioRemark: ioData.ioRemark || '',
}; };
// Only include balance fields if explicitly provided // Only include balance fields if explicitly provided
if (ioData.ioAvailableBalance !== undefined) { if (ioData.ioAvailableBalance !== undefined) {
payload.availableBalance = ioData.ioAvailableBalance; payload.availableBalance = ioData.ioAvailableBalance;
@ -275,7 +251,7 @@ export async function updateIODetails(
if (ioData.ioRemainingBalance !== undefined) { if (ioData.ioRemainingBalance !== undefined) {
payload.remainingBalance = ioData.ioRemainingBalance; payload.remainingBalance = ioData.ioRemainingBalance;
} }
const response = await apiClient.put(`/dealer-claims/${requestId}/io`, payload); const response = await apiClient.put(`/dealer-claims/${requestId}/io`, payload);
return response.data?.data || response.data; return response.data?.data || response.data;
} catch (error) { } catch (error) {

View File

@ -6,8 +6,8 @@
import { TokenManager } from '../utils/tokenManager'; import { TokenManager } from '../utils/tokenManager';
import axios from 'axios'; import axios from 'axios';
const TANFLOW_BASE_URL = import.meta.env.VITE_TANFLOW_BASE_URL || ''; const TANFLOW_BASE_URL = import.meta.env.VITE_TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE';
const TANFLOW_CLIENT_ID = import.meta.env.VITE_TANFLOW_CLIENT_ID || ''; const TANFLOW_CLIENT_ID = import.meta.env.VITE_TANFLOW_CLIENT_ID || 'REFLOW';
const TANFLOW_REDIRECT_URI = `${window.location.origin}/login/callback`; const TANFLOW_REDIRECT_URI = `${window.location.origin}/login/callback`;
/** /**
@ -18,7 +18,7 @@ export function initiateTanflowLogin(): void {
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication // Check if we're coming from a logout - if so, add prompt=login to force re-authentication
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const isAfterLogout = urlParams.has('logout') || urlParams.has('tanflow_logged_out'); const isAfterLogout = urlParams.has('logout') || urlParams.has('tanflow_logged_out');
// Clear any previous logout flags before starting new login // Clear any previous logout flags before starting new login
if (isAfterLogout) { if (isAfterLogout) {
sessionStorage.removeItem('tanflow_logged_out'); sessionStorage.removeItem('tanflow_logged_out');
@ -26,26 +26,26 @@ export function initiateTanflowLogin(): void {
sessionStorage.removeItem('__force_logout__'); sessionStorage.removeItem('__force_logout__');
console.log('🚪 Cleared logout flags before initiating Tanflow login'); console.log('🚪 Cleared logout flags before initiating Tanflow login');
} }
const state = Math.random().toString(36).substring(7); const state = Math.random().toString(36).substring(7);
// Store provider type and state to identify Tanflow callback // Store provider type and state to identify Tanflow callback
sessionStorage.setItem('auth_provider', 'tanflow'); sessionStorage.setItem('auth_provider', 'tanflow');
sessionStorage.setItem('tanflow_auth_state', state); sessionStorage.setItem('tanflow_auth_state', state);
let authUrl = `${TANFLOW_BASE_URL}/protocol/openid-connect/auth?` + let authUrl = `${TANFLOW_BASE_URL}/protocol/openid-connect/auth?` +
`client_id=${TANFLOW_CLIENT_ID}&` + `client_id=${TANFLOW_CLIENT_ID}&` +
`response_type=code&` + `response_type=code&` +
`scope=openid&` + `scope=openid&` +
`redirect_uri=${encodeURIComponent(TANFLOW_REDIRECT_URI)}&` + `redirect_uri=${encodeURIComponent(TANFLOW_REDIRECT_URI)}&` +
`state=${state}`; `state=${state}`;
// Add prompt=login if coming from logout to force re-authentication // Add prompt=login if coming from logout to force re-authentication
// This ensures Tanflow requires login even if a session still exists // This ensures Tanflow requires login even if a session still exists
if (isAfterLogout) { if (isAfterLogout) {
authUrl += `&prompt=login`; authUrl += `&prompt=login`;
console.log('🚪 Adding prompt=login to force re-authentication after logout'); console.log('🚪 Adding prompt=login to force re-authentication after logout');
} }
console.log('🚪 Initiating Tanflow login', { isAfterLogout, hasPrompt: isAfterLogout }); console.log('🚪 Initiating Tanflow login', { isAfterLogout, hasPrompt: isAfterLogout });
window.location.href = authUrl; window.location.href = authUrl;
} }
@ -63,8 +63,8 @@ export async function exchangeTanflowCodeForTokens(
idToken: string; idToken: string;
user: any; user: any;
}> { }> {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
try { try {
const response = await axios.post( const response = await axios.post(
`${API_BASE_URL}/auth/tanflow/token-exchange`, `${API_BASE_URL}/auth/tanflow/token-exchange`,
@ -80,9 +80,9 @@ export async function exchangeTanflowCodeForTokens(
}, },
} }
); );
const data = response.data?.data || response.data; const data = response.data?.data || response.data;
// Store tokens // Store tokens
if (data.accessToken) { if (data.accessToken) {
TokenManager.setAccessToken(data.accessToken); TokenManager.setAccessToken(data.accessToken);
@ -96,7 +96,7 @@ export async function exchangeTanflowCodeForTokens(
if (data.user) { if (data.user) {
TokenManager.setUserData(data.user); TokenManager.setUserData(data.user);
} }
return data; return data;
} catch (error: any) { } catch (error: any) {
console.error('❌ Tanflow token exchange failed:', { console.error('❌ Tanflow token exchange failed:', {
@ -112,13 +112,13 @@ export async function exchangeTanflowCodeForTokens(
* Refresh access token using refresh token * Refresh access token using refresh token
*/ */
export async function refreshTanflowToken(): Promise<string> { export async function refreshTanflowToken(): Promise<string> {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
const refreshToken = TokenManager.getRefreshToken(); const refreshToken = TokenManager.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
throw new Error('No refresh token available'); throw new Error('No refresh token available');
} }
try { try {
const response = await axios.post( const response = await axios.post(
`${API_BASE_URL}/auth/tanflow/refresh`, `${API_BASE_URL}/auth/tanflow/refresh`,
@ -130,15 +130,15 @@ export async function refreshTanflowToken(): Promise<string> {
}, },
} }
); );
const data = response.data?.data || response.data; const data = response.data?.data || response.data;
const accessToken = data.accessToken; const accessToken = data.accessToken;
if (accessToken) { if (accessToken) {
TokenManager.setAccessToken(accessToken); TokenManager.setAccessToken(accessToken);
return accessToken; return accessToken;
} }
throw new Error('Failed to refresh token'); throw new Error('Failed to refresh token');
} catch (error: any) { } catch (error: any) {
console.error('❌ Tanflow token refresh failed:', error); console.error('❌ Tanflow token refresh failed:', error);
@ -160,23 +160,23 @@ export function tanflowLogout(idToken: string): void {
window.location.replace(homeUrl); window.location.replace(homeUrl);
return; return;
} }
// Build Tanflow logout URL with redirect back to login callback // Build Tanflow logout URL with redirect back to login callback
// IMPORTANT: Use the base redirect URI (without query params) to match registered URIs // IMPORTANT: Use the base redirect URI (without query params) to match registered URIs
// Tanflow requires exact match with registered "Valid Post Logout Redirect URIs" // Tanflow requires exact match with registered "Valid Post Logout Redirect URIs"
// The same URI used for login should be registered for logout // The same URI used for login should be registered for logout
// Using the base URI ensures it matches what's registered in Tanflow client config // Using the base URI ensures it matches what's registered in Tanflow client config
const postLogoutRedirectUri = TANFLOW_REDIRECT_URI; // Use base URI without query params const postLogoutRedirectUri = TANFLOW_REDIRECT_URI; // Use base URI without query params
// Construct logout URL - ensure all parameters are properly encoded // Construct logout URL - ensure all parameters are properly encoded
// Keycloak/Tanflow expects: client_id, id_token_hint, post_logout_redirect_uri // Keycloak/Tanflow expects: client_id, id_token_hint, post_logout_redirect_uri
const logoutUrl = new URL(`${TANFLOW_BASE_URL}/protocol/openid-connect/logout`); const logoutUrl = new URL(`${TANFLOW_BASE_URL}/protocol/openid-connect/logout`);
logoutUrl.searchParams.set('client_id', TANFLOW_CLIENT_ID); logoutUrl.searchParams.set('client_id', TANFLOW_CLIENT_ID);
logoutUrl.searchParams.set('id_token_hint', idToken); logoutUrl.searchParams.set('id_token_hint', idToken);
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
const finalLogoutUrl = logoutUrl.toString(); const finalLogoutUrl = logoutUrl.toString();
console.log('🚪 Tanflow logout initiated', { console.log('🚪 Tanflow logout initiated', {
hasIdToken: !!idToken, hasIdToken: !!idToken,
idTokenPrefix: idToken ? idToken.substring(0, 20) + '...' : 'none', idTokenPrefix: idToken ? idToken.substring(0, 20) + '...' : 'none',
@ -184,14 +184,14 @@ export function tanflowLogout(idToken: string): void {
logoutUrlBase: `${TANFLOW_BASE_URL}/protocol/openid-connect/logout`, logoutUrlBase: `${TANFLOW_BASE_URL}/protocol/openid-connect/logout`,
finalLogoutUrl: finalLogoutUrl.replace(idToken.substring(0, 20), '***'), finalLogoutUrl: finalLogoutUrl.replace(idToken.substring(0, 20), '***'),
}); });
// DO NOT clear auth_provider here - we need it to detect Tanflow callback // DO NOT clear auth_provider here - we need it to detect Tanflow callback
// The logout flags should already be set by AuthContext // The logout flags should already be set by AuthContext
// Just ensure they're there // Just ensure they're there
sessionStorage.setItem('__logout_in_progress__', 'true'); sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true'); sessionStorage.setItem('__force_logout__', 'true');
// Don't set tanflow_logged_out here - it will be set when Tanflow redirects back // Don't set tanflow_logged_out here - it will be set when Tanflow redirects back
// Redirect to Tanflow logout endpoint // Redirect to Tanflow logout endpoint
// Tanflow will clear the session and redirect back to post_logout_redirect_uri // Tanflow will clear the session and redirect back to post_logout_redirect_uri
// The redirect will include tanflow_logged_out=true in the query params // The redirect will include tanflow_logged_out=true in the query params

View File

@ -24,8 +24,8 @@ export interface UserSummary {
isActive?: boolean; isActive?: boolean;
} }
export async function searchUsers(query: string, limit: number = 10, source: 'local' | 'okta' | 'default' = 'default') { export async function searchUsers(query: string, limit: number = 10) {
const res = await apiClient.get('/users/search', { params: { q: query, limit, source } }); const res = await apiClient.get('/users/search', { params: { q: query, limit } });
// ResponseHandler.success returns { success: true, data: array } // ResponseHandler.success returns { success: true, data: array }
return res; return res;
} }
@ -66,11 +66,11 @@ export async function ensureUserExists(userData: {
* @param role - Role to assign * @param role - Role to assign
*/ */
export async function assignRole( export async function assignRole(
email: string, email: string,
role: 'USER' | 'MANAGEMENT' | 'ADMIN' role: 'USER' | 'MANAGEMENT' | 'ADMIN'
) { ) {
return await apiClient.post('/admin/users/assign-role', { return await apiClient.post('/admin/users/assign-role', {
email, email,
role role
}); });
} }
@ -90,8 +90,8 @@ export async function getUsersByRole(
page: number = 1, page: number = 1,
limit: number = 10 limit: number = 10
) { ) {
return await apiClient.get('/admin/users/by-role', { return await apiClient.get('/admin/users/by-role', {
params: { role: role || 'ELEVATED', page, limit } params: { role: role || 'ELEVATED', page, limit }
}); });
} }
@ -102,14 +102,6 @@ export async function getRoleStatistics() {
return await apiClient.get('/admin/users/role-statistics'); return await apiClient.get('/admin/users/role-statistics');
} }
/**
* Get user by ID
*/
export async function getUserById(userId: string) {
const res = await apiClient.get(`/users/${userId}`);
return res.data?.data || res.data;
}
/** /**
* Get all users from database (for filtering purposes) * Get all users from database (for filtering purposes)
*/ */
@ -119,9 +111,8 @@ export async function getAllUsers() {
return res.data?.data?.users || []; return res.data?.data?.users || [];
} }
export const userApi = { export const userApi = {
searchUsers, searchUsers,
getUserById,
ensureUserExists, ensureUserExists,
assignRole, assignRole,
updateUserRole, updateUserRole,

View File

@ -362,12 +362,12 @@ export async function getPauseDetails(requestId: string) {
} }
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string { export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || ''; const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`; return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
} }
export function getDocumentPreviewUrl(documentId: string): string { export function getDocumentPreviewUrl(documentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || ''; const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`; return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`;
} }
@ -404,7 +404,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
} }
export async function downloadDocument(documentId: string): Promise<void> { export async function downloadDocument(documentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || ''; const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`; const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
@ -449,7 +449,7 @@ export async function downloadDocument(documentId: string): Promise<void> {
} }
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> { export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
const downloadBaseURL = import.meta.env.VITE_BASE_URL || ''; const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`; const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';

View File

@ -26,17 +26,7 @@ export interface ClaimManagementRequest {
}; };
estimatedBudget?: number; estimatedBudget?: number;
closedExpenses?: number; closedExpenses?: number;
defaultGstRate?: number; closedExpensesBreakdown?: Array<{ description: string; amount: number }>;
closedExpensesBreakdown?: Array<{
description: string;
amount: number;
gstRate?: number;
gstAmt?: number;
cgstAmt?: number;
sgstAmt?: number;
igstAmt?: number;
totalAmt?: number;
}>;
description?: string; description?: string;
}; };
@ -52,16 +42,7 @@ export interface ClaimManagementRequest {
// Proposal Details (Step 1) // Proposal Details (Step 1)
proposalDetails?: { proposalDetails?: {
proposalDocumentUrl?: string; proposalDocumentUrl?: string;
costBreakup: Array<{ costBreakup: Array<{ description: string; amount: number }>;
description: string;
amount: number;
gstRate?: number;
gstAmt?: number;
cgstAmt?: number;
sgstAmt?: number;
igstAmt?: number;
totalAmt?: number;
}>;
totalEstimatedBudget: number; totalEstimatedBudget: number;
timelineMode?: 'date' | 'days'; timelineMode?: 'date' | 'days';
expectedCompletionDate?: string; expectedCompletionDate?: string;
@ -89,12 +70,6 @@ export interface ClaimManagementRequest {
creditNoteNumber?: string; creditNoteNumber?: string;
creditNoteDate?: string; creditNoteDate?: string;
creditNoteAmount?: number; creditNoteAmount?: number;
// PWC Fields
irn?: string;
ackNo?: string;
ackDate?: string;
signedInvoiceUrl?: string;
taxBreakdown?: any;
}; };
// Claim Amount // Claim Amount
@ -133,7 +108,7 @@ export function mapToClaimManagementRequest(
const proposalDetails = apiRequest.proposalDetails || {}; const proposalDetails = apiRequest.proposalDetails || {};
const completionDetails = apiRequest.completionDetails || {}; const completionDetails = apiRequest.completionDetails || {};
const internalOrder = apiRequest.internalOrder || apiRequest.internal_order || {}; const internalOrder = apiRequest.internalOrder || apiRequest.internal_order || {};
// Extract new normalized tables // Extract new normalized tables
const budgetTracking = apiRequest.budgetTracking || apiRequest.budget_tracking || {}; const budgetTracking = apiRequest.budgetTracking || apiRequest.budget_tracking || {};
const invoice = apiRequest.invoice || {}; const invoice = apiRequest.invoice || {};
@ -146,54 +121,47 @@ export function mapToClaimManagementRequest(
// Handle both camelCase and snake_case field names from Sequelize // Handle both camelCase and snake_case field names from Sequelize
const periodStartDate = claimDetails.periodStartDate || claimDetails.period_start_date; const periodStartDate = claimDetails.periodStartDate || claimDetails.period_start_date;
const periodEndDate = claimDetails.periodEndDate || claimDetails.period_end_date; const periodEndDate = claimDetails.periodEndDate || claimDetails.period_end_date;
const activityName = claimDetails.activityName || claimDetails.activity_name || ''; const activityName = claimDetails.activityName || claimDetails.activity_name || '';
const activityType = claimDetails.activityType || claimDetails.activity_type || ''; const activityType = claimDetails.activityType || claimDetails.activity_type || '';
const location = claimDetails.location || ''; const location = claimDetails.location || '';
// Activity fields mapped // Activity fields mapped
// Get budget values from budgetTracking table (new source of truth) // Get budget values from budgetTracking table (new source of truth)
const estimatedBudget = budgetTracking.proposalEstimatedBudget ?? const estimatedBudget = budgetTracking.proposalEstimatedBudget ||
budgetTracking.proposal_estimated_budget ?? budgetTracking.proposal_estimated_budget ||
budgetTracking.initialEstimatedBudget ?? budgetTracking.initialEstimatedBudget ||
budgetTracking.initial_estimated_budget ?? budgetTracking.initial_estimated_budget ||
claimDetails.estimatedBudget ?? claimDetails.estimatedBudget ||
claimDetails.estimated_budget; claimDetails.estimated_budget;
// Get closed expenses - check multiple sources with proper number conversion // Get closed expenses - check multiple sources with proper number conversion
const closedExpensesRaw = budgetTracking?.closedExpenses ?? const closedExpensesRaw = budgetTracking?.closedExpenses ||
budgetTracking?.closed_expenses ?? budgetTracking?.closed_expenses ||
completionDetails?.totalClosedExpenses ?? completionDetails?.totalClosedExpenses ||
completionDetails?.total_closed_expenses ?? completionDetails?.total_closed_expenses ||
claimDetails?.closedExpenses ?? claimDetails?.closedExpenses ||
claimDetails?.closed_expenses; claimDetails?.closed_expenses;
// Convert to number and handle 0 as valid value // Convert to number and handle 0 as valid value
const closedExpenses = closedExpensesRaw !== null && closedExpensesRaw !== undefined const closedExpenses = closedExpensesRaw !== null && closedExpensesRaw !== undefined
? Number(closedExpensesRaw) ? Number(closedExpensesRaw)
: undefined; : undefined;
// Get closed expenses breakdown from new completionExpenses table // Get closed expenses breakdown from new completionExpenses table
const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0 const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0
? completionExpenses.map((exp: any) => ({ ? completionExpenses.map((exp: any) => ({
description: exp.description || exp.itemDescription || exp.item_description || '', description: exp.description || exp.itemDescription || '',
amount: Number(exp.amount) || 0, amount: Number(exp.amount) || 0
gstRate: exp.gstRate ?? exp.gst_rate, }))
gstAmt: exp.gstAmt ?? exp.gst_amt, : (completionDetails?.closedExpenses ||
cgstAmt: exp.cgstAmt ?? exp.cgst_amt, completionDetails?.closed_expenses ||
sgstAmt: exp.sgstAmt ?? exp.sgst_amt, completionDetails?.closedExpensesBreakdown ||
igstAmt: exp.igstAmt ?? exp.igst_amt, []);
totalAmt: exp.totalAmt ?? exp.total_amt
}))
: (completionDetails?.closedExpenses ||
completionDetails?.closed_expenses ||
completionDetails?.closedExpensesBreakdown ||
[]);
const activityInfo = { const activityInfo = {
activityName, activityName,
activityType, activityType,
defaultGstRate: claimDetails.defaultGstRate || 18,
requestedDate: claimDetails.activityDate || claimDetails.activity_date || apiRequest.createdAt, // Use activityDate as requestedDate, fallback to createdAt requestedDate: claimDetails.activityDate || claimDetails.activity_date || apiRequest.createdAt, // Use activityDate as requestedDate, fallback to createdAt
location, location,
period: (periodStartDate && periodEndDate) ? { period: (periodStartDate && periodEndDate) ? {
@ -232,29 +200,7 @@ export function mapToClaimManagementRequest(
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date; const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
const proposal = proposalDetails ? { const proposal = proposalDetails ? {
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url, proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
costBreakup: Array.isArray(proposalDetails.costItems || proposalDetails.cost_items) costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
? (proposalDetails.costItems || proposalDetails.cost_items).map((item: any) => ({
description: item.description || item.itemDescription || item.item_description || '',
amount: Number(item.amount) || 0,
gstRate: Number(item.gstRate ?? item.gst_rate ?? 0),
gstAmt: Number(item.gstAmt ?? item.gst_amt ?? 0),
cgstAmt: Number(item.cgstAmt ?? item.cgst_amt ?? 0),
sgstAmt: Number(item.sgstAmt ?? item.sgst_amt ?? 0),
igstAmt: Number(item.igstAmt ?? item.igst_amt ?? 0),
totalAmt: Number(item.totalAmt ?? item.total_amt ?? 0)
}))
: Array.isArray(proposalDetails.costBreakup || proposalDetails.cost_breakup)
? (proposalDetails.costBreakup || proposalDetails.cost_breakup).map((item: any) => ({
description: item.description || item.itemDescription || item.item_description || '',
amount: Number(item.amount) || 0,
gstRate: item.gstRate ?? item.gst_rate,
gstAmt: item.gstAmt ?? item.gst_amt,
cgstAmt: item.cgstAmt ?? item.cgst_amt,
sgstAmt: item.sgstAmt ?? item.sgst_amt,
igstAmt: item.igstAmt ?? item.igst_amt,
totalAmt: item.totalAmt ?? item.total_amt
}))
: [],
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0, totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode, timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
expectedCompletionDate: expectedCompletionDate, expectedCompletionDate: expectedCompletionDate,
@ -277,27 +223,21 @@ export function mapToClaimManagementRequest(
// Map DMS details from new invoice and credit note tables // Map DMS details from new invoice and credit note tables
const dmsDetails = { const dmsDetails = {
eInvoiceNumber: invoice.invoiceNumber || invoice.invoice_number || eInvoiceNumber: invoice.invoiceNumber || invoice.invoice_number ||
claimDetails.eInvoiceNumber || claimDetails.e_invoice_number, claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
eInvoiceDate: invoice.invoiceDate || invoice.invoice_date || eInvoiceDate: invoice.invoiceDate || invoice.invoice_date ||
claimDetails.eInvoiceDate || claimDetails.e_invoice_date, claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
dmsNumber: invoice.dmsNumber || invoice.dms_number || dmsNumber: invoice.dmsNumber || invoice.dms_number ||
claimDetails.dmsNumber || claimDetails.dms_number, claimDetails.dmsNumber || claimDetails.dms_number,
creditNoteNumber: creditNote.creditNoteNumber || creditNote.credit_note_number || creditNoteNumber: creditNote.creditNoteNumber || creditNote.credit_note_number ||
claimDetails.creditNoteNumber || claimDetails.credit_note_number, claimDetails.creditNoteNumber || claimDetails.credit_note_number,
creditNoteDate: creditNote.creditNoteDate || creditNote.credit_note_date || creditNoteDate: creditNote.creditNoteDate || creditNote.credit_note_date ||
claimDetails.creditNoteDate || claimDetails.credit_note_date, claimDetails.creditNoteDate || claimDetails.credit_note_date,
creditNoteAmount: creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) : creditNoteAmount: creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) : (creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) :
(creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) : (creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) : (claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) :
(claimDetails.credit_note_amount ? Number(claimDetails.credit_note_amount) : undefined)))), (claimDetails.credit_note_amount ? Number(claimDetails.credit_note_amount) : undefined)))),
// PWC fields
irn: invoice.irn || claimDetails.irn,
ackNo: invoice.ackNo || claimDetails.ackNo,
ackDate: invoice.ackDate || claimDetails.ackDate,
signedInvoiceUrl: invoice.signedInvoiceUrl || claimDetails.signedInvoiceUrl,
taxBreakdown: invoice.taxBreakdown || claimDetails.taxBreakdown,
}; };
// Map claim amounts // Map claim amounts
@ -326,15 +266,15 @@ export function mapToClaimManagementRequest(
export function determineUserRole(apiRequest: any, currentUserId: string): RequestRole { export function determineUserRole(apiRequest: any, currentUserId: string): RequestRole {
try { try {
// Check if user is the initiator // Check if user is the initiator
if (apiRequest.initiatorId === currentUserId || if (apiRequest.initiatorId === currentUserId ||
apiRequest.initiator?.userId === currentUserId || apiRequest.initiator?.userId === currentUserId ||
apiRequest.requestedBy?.userId === currentUserId) { apiRequest.requestedBy?.userId === currentUserId) {
return 'INITIATOR'; return 'INITIATOR';
} }
// Check if user is a dealer (participant with DEALER type) // Check if user is a dealer (participant with DEALER type)
const participants = apiRequest.participants || []; const participants = apiRequest.participants || [];
const dealerParticipant = participants.find((p: any) => const dealerParticipant = participants.find((p: any) =>
(p.userId === currentUserId || p.user?.userId === currentUserId) && (p.userId === currentUserId || p.user?.userId === currentUserId) &&
(p.participantType === 'DEALER' || p.type === 'DEALER') (p.participantType === 'DEALER' || p.type === 'DEALER')
); );
@ -344,7 +284,7 @@ export function determineUserRole(apiRequest: any, currentUserId: string): Reque
// Check if user is a department lead (approver at level 3) // Check if user is a department lead (approver at level 3)
const approvalLevels = apiRequest.approvalLevels || []; const approvalLevels = apiRequest.approvalLevels || [];
const deptLeadLevel = approvalLevels.find((level: any) => const deptLeadLevel = approvalLevels.find((level: any) =>
level.levelNumber === 3 && level.levelNumber === 3 &&
(level.approverId === currentUserId || level.approver?.userId === currentUserId) (level.approverId === currentUserId || level.approver?.userId === currentUserId)
); );
@ -353,7 +293,7 @@ export function determineUserRole(apiRequest: any, currentUserId: string): Reque
} }
// Check if user is an approver // Check if user is an approver
const approverLevel = approvalLevels.find((level: any) => const approverLevel = approvalLevels.find((level: any) =>
(level.approverId === currentUserId || level.approver?.userId === currentUserId) && (level.approverId === currentUserId || level.approver?.userId === currentUserId) &&
level.status === 'PENDING' level.status === 'PENDING'
); );

View File

@ -2,7 +2,166 @@
// This database is exclusively for claim management requests created via ClaimManagementWizard // This database is exclusively for claim management requests created via ClaimManagementWizard
// Template: Claim Management (8-step workflow) // Template: Claim Management (8-step workflow)
export const CLAIM_MANAGEMENT_DATABASE: any = {}; export const CLAIM_MANAGEMENT_DATABASE: any = {
'RE-REQ-2024-CM-001': {
id: 'RE-REQ-2024-CM-001',
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
description: 'Claim request for dealer-led Diwali festival marketing campaign including showroom decoration, test ride events, customer engagement activities, and promotional merchandise distribution. Activity conducted at Royal Motors Mumbai dealership.',
category: 'Dealer Operations',
subcategory: 'Claim Management',
status: 'pending',
priority: 'standard',
amount: 'TBD',
slaProgress: 35,
slaRemaining: '4 days 12 hours',
slaEndDate: 'Oct 16, 2024 5:00 PM',
currentStep: 1,
totalSteps: 8,
template: 'claim-management',
templateName: 'Claim Management',
initiator: {
name: 'Sneha Patil',
role: 'Regional Marketing Coordinator',
department: 'Marketing - West Zone',
email: 'sneha.patil@royalenfield.com',
phone: '+91 98765 43250',
avatar: 'SP'
},
department: 'Marketing - West Zone',
createdAt: 'Oct 7, 2024 9:30 AM',
updatedAt: 'Oct 7, 2024 9:30 AM',
dueDate: '2024-10-16T17:00:00Z',
conclusionRemark: '',
claimDetails: {
activityName: 'Diwali Festival Campaign 2024',
activityType: 'Marketing Activity',
activityDate: 'Oct 5, 2024',
location: 'Mumbai, Maharashtra',
dealerCode: 'RE-MH-001',
dealerName: 'Royal Motors Mumbai',
dealerEmail: 'dealer@royalmotorsmumbai.com',
dealerPhone: '+91 98765 12345',
dealerAddress: '123 Main Street, Andheri West, Mumbai, Maharashtra 400053',
requestDescription: 'Marketing campaign for Diwali festival including showroom decoration, test ride events, customer engagement activities, and promotional merchandise distribution at Royal Motors Mumbai dealership.',
estimatedBudget: '₹2,45,000',
periodStart: 'Oct 1, 2024',
periodEnd: 'Oct 10, 2024'
},
approvalFlow: [
{
step: 1,
approver: 'Royal Motors Mumbai (Dealer)',
role: 'Dealer - Document Upload',
status: 'pending',
tatHours: 72,
elapsedHours: 12,
assignedAt: '2024-10-07T09:30:00Z',
comment: null,
timestamp: null,
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
},
{
step: 2,
approver: 'Sneha Patil (Initiator)',
role: 'Initiator Evaluation',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator reviews dealer documents and approves or requests modifications'
},
{
step: 3,
approver: 'System Auto-Process',
role: 'IO Confirmation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Automatic IO (Internal Order) confirmation generated upon initiator approval'
},
{
step: 4,
approver: 'Rajesh Kumar',
role: 'Department Lead Approval',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Department head approves and blocks budget in IO for this activity'
},
{
step: 5,
approver: 'Royal Motors Mumbai (Dealer)',
role: 'Dealer - Completion Documents',
status: 'waiting',
tatHours: 120,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Dealer submits activity completion documents and description'
},
{
step: 6,
approver: 'Sneha Patil (Initiator)',
role: 'Initiator Verification',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator verifies completion documents and can modify approved amount'
},
{
step: 7,
approver: 'System Auto-Process',
role: 'E-Invoice Generation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Auto-generate e-invoice based on final approved amount'
},
{
step: 8,
approver: 'Meera Patel',
role: 'Finance - Credit Note Issuance',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Finance team issues credit note to dealer'
}
],
documents: [
{ name: 'Claim_Proposal_Diwali_2024.pdf', size: '1.8 MB', type: 'PDF', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:35 AM' },
{ name: 'Cost_Breakup_Detailed.xlsx', size: '450 KB', type: 'Excel', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:38 AM' },
{ name: 'Activity_Timeline.pdf', size: '320 KB', type: 'PDF', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:40 AM' }
],
spectators: [
{ name: 'Arjun Menon', role: 'Brand Manager', avatar: 'AM' },
{ name: 'Finance Team', role: 'Budget Monitoring', avatar: 'FT' }
],
auditTrail: [
{ type: 'created', action: 'Claim Request Created', details: 'Diwali festival campaign claim initiated using Claim Management template', user: 'Sneha Patil', timestamp: 'Oct 7, 2024 9:30 AM' },
{ type: 'assignment', action: 'Assigned to Dealer', details: 'Dealer Royal Motors Mumbai assigned for document upload', user: 'System', timestamp: 'Oct 7, 2024 9:31 AM' },
{ type: 'status_change', action: 'Workflow Started', details: 'Step 1: Dealer document upload phase initiated', user: 'System', timestamp: 'Oct 7, 2024 9:31 AM' }
],
tags: ['claim-management', 'dealer-activity', 'marketing', 'diwali-campaign', 'template']
}
};
// API Endpoints for Claim Management (to be implemented with backend) // API Endpoints for Claim Management (to be implemented with backend)
export const CLAIM_MANAGEMENT_API_ENDPOINTS = { export const CLAIM_MANAGEMENT_API_ENDPOINTS = {

View File

@ -2,7 +2,720 @@
// This database is exclusively for custom requests created via NewRequestWizard // This database is exclusively for custom requests created via NewRequestWizard
// Users define their own workflow, approvers, spectators, and tagged participants // Users define their own workflow, approvers, spectators, and tagged participants
export const CUSTOM_REQUEST_DATABASE: any = {}; export const CUSTOM_REQUEST_DATABASE: any = {
'RE-REQ-2024-001': {
id: 'RE-REQ-2024-001',
title: 'Himalayan 450 Launch Campaign - Digital Media Blitz',
description: 'Comprehensive digital marketing campaign for Himalayan 450 adventure motorcycle launch. Includes social media campaigns, influencer partnerships, performance marketing, content creation, and digital advertising across platforms. Target: Reach 10M adventure enthusiasts across India.\n\nEquipment Specifications:\n• 10x MacBook Pro 16-inch (M2 Pro chip)\n• 5x Professional Camera Kits (Canon EOS R5)\n• Video Editing Workstations\n• Social Media Management Tools',
category: 'Marketing & Campaigns',
subcategory: 'Digital Marketing',
status: 'pending',
priority: 'express',
amount: '₹3,75,00,000',
slaProgress: 65,
slaRemaining: '8 hours 45 minutes',
slaEndDate: 'Oct 9, 2024 5:00 PM',
currentStep: 1,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Priya Sharma',
role: 'Senior Digital Marketing Manager',
department: 'Marketing',
email: 'priya.sharma@royalenfield.com',
phone: '+91 98765 43210',
avatar: 'PS'
},
department: 'Marketing',
createdAt: 'Oct 6, 2024 10:30 AM',
updatedAt: 'Oct 7, 2024 2:15 PM',
dueDate: '2024-10-09T17:00:00Z',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Rajesh Kumar',
role: 'Marketing Director - India',
status: 'pending',
tatHours: 24,
elapsedHours: 22,
assignedAt: '2024-10-06T10:30:00Z',
comment: null,
timestamp: null
},
{
step: 2,
approver: 'Amit Desai',
role: 'VP Product Marketing',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Himalayan_450_Digital_Strategy.pdf', size: '5.2 MB', type: 'PDF', uploadedBy: 'Priya Sharma', uploadedAt: 'Oct 6, 2024 10:45 AM' },
{ name: 'Budget_Breakdown_Q4_2024.xlsx', size: '980 KB', type: 'Excel', uploadedBy: 'Priya Sharma', uploadedAt: 'Oct 6, 2024 11:15 AM' },
{ name: 'Influencer_Partnership_List.xlsx', size: '450 KB', type: 'Excel', uploadedBy: 'Marketing Team', uploadedAt: 'Oct 6, 2024 2:30 PM' },
{ name: 'Creative_Campaign_Assets.zip', size: '125 MB', type: 'ZIP', uploadedBy: 'Creative Team', uploadedAt: 'Oct 6, 2024 4:15 PM' }
],
spectators: [
{ name: 'Sarah Khan', role: 'Brand Strategy Lead', avatar: 'SK' },
{ name: 'Finance Team', role: 'Budget Approval', avatar: 'FT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'New digital marketing campaign request submitted', user: 'Priya Sharma', timestamp: 'Oct 6, 2024 10:30 AM' },
{ type: 'assignment', action: 'Assigned to Rajesh Kumar', details: 'Forwarded to Marketing Director for review', user: 'System', timestamp: 'Oct 6, 2024 10:31 AM' },
{ type: 'comment', action: 'Work Note Added', details: 'Reviewed budget allocation and target metrics', user: 'Rajesh Kumar', timestamp: 'Oct 7, 2024 2:15 PM' },
{ type: 'reminder', action: 'SLA Reminder', details: 'TAT approaching - 8 hours remaining', user: 'System', timestamp: 'Oct 7, 2024 8:15 AM' }
],
tags: ['digital-marketing', 'launch-campaign', 'himalayan-450', 'high-priority']
},
'RE-REQ-2024-002': {
id: 'RE-REQ-2024-002',
title: 'New Laptop Procurement - Design Team Expansion',
description: 'Purchase of 10 high-performance laptops for the newly expanded Product Design team. Required specifications: Latest generation processor, 32GB RAM, dedicated graphics card for 3D modeling and rendering work.',
category: 'IT & Infrastructure',
subcategory: 'Hardware Procurement',
status: 'in-review',
priority: 'standard',
amount: '₹12,50,000',
slaProgress: 45,
slaRemaining: '2 days 8 hours',
slaEndDate: 'Oct 11, 2024 5:00 PM',
currentStep: 2,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Vikram Singh',
role: 'Head - IT Operations',
department: 'Information Technology',
email: 'vikram.singh@royalenfield.com',
phone: '+91 98765 43221',
avatar: 'VS'
},
department: 'Information Technology',
createdAt: 'Oct 5, 2024 9:15 AM',
updatedAt: 'Oct 7, 2024 3:45 PM',
dueDate: '2024-10-11T17:00:00Z',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Meera Patel',
role: 'IT Manager',
status: 'approved',
tatHours: 24,
actualHours: 18,
assignedAt: '2024-10-05T09:15:00Z',
comment: 'Technical specifications verified. Hardware meets design team requirements.',
timestamp: '2024-10-06T03:15:00Z'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'in-review',
tatHours: 48,
elapsedHours: 32,
assignedAt: '2024-10-06T03:15:00Z',
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Laptop_Specifications.pdf', size: '850 KB', type: 'PDF', uploadedBy: 'Vikram Singh', uploadedAt: 'Oct 5, 2024 9:20 AM' },
{ name: 'Vendor_Quotations.xlsx', size: '1.2 MB', type: 'Excel', uploadedBy: 'Procurement Team', uploadedAt: 'Oct 5, 2024 11:45 AM' },
{ name: 'Team_Expansion_Plan.pdf', size: '620 KB', type: 'PDF', uploadedBy: 'Design Team', uploadedAt: 'Oct 5, 2024 2:30 PM' }
],
spectators: [
{ name: 'Design Team Lead', role: 'End Users', avatar: 'DT' },
{ name: 'Procurement Team', role: 'Vendor Management', avatar: 'PT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Laptop procurement request for design team', user: 'Vikram Singh', timestamp: 'Oct 5, 2024 9:15 AM' },
{ type: 'assignment', action: 'Assigned to Meera Patel', details: 'IT Manager to verify specifications', user: 'System', timestamp: 'Oct 5, 2024 9:16 AM' },
{ type: 'approval', action: 'Approved by Meera Patel', details: 'Technical specifications approved', user: 'Meera Patel', timestamp: 'Oct 6, 2024 3:15 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance for budget approval', user: 'System', timestamp: 'Oct 6, 2024 3:15 AM' }
],
tags: ['hardware', 'procurement', 'design-team', 'laptops']
},
'RE-REQ-2024-003': {
id: 'RE-REQ-2024-003',
title: 'Annual Service Center Expansion - Western Region',
description: 'Proposal for opening 15 new authorized service centers across tier-2 cities in Western region. Includes infrastructure setup, technician training, spare parts inventory, and marketing support. Expected to improve service accessibility by 35% in the target region.',
category: 'Operations & Expansion',
subcategory: 'Service Network',
status: 'pending',
priority: 'standard',
amount: '₹8,75,00,000',
slaProgress: 78,
slaRemaining: '1 day 4 hours',
slaEndDate: 'Oct 10, 2024 5:00 PM',
currentStep: 1,
totalSteps: 4,
template: 'custom',
initiator: {
name: 'Sanjay Reddy',
role: 'Regional Service Manager - West',
department: 'After Sales Service',
email: 'sanjay.reddy@royalenfield.com',
phone: '+91 98765 43232',
avatar: 'SR'
},
department: 'After Sales Service',
createdAt: 'Oct 3, 2024 8:45 AM',
updatedAt: 'Oct 6, 2024 5:45 PM',
dueDate: '2024-10-10T17:00:00Z',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Ramesh Kulkarni',
role: 'Head - After Sales Service',
status: 'pending',
tatHours: 72,
elapsedHours: 85,
assignedAt: '2024-10-03T08:45:00Z',
comment: null,
timestamp: null
},
{
step: 2,
approver: 'Finance Team',
role: 'Budget Allocation',
status: 'waiting',
tatHours: 96,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Legal Team',
role: 'Compliance Review',
status: 'waiting',
tatHours: 120,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 4,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Western_Region_Expansion_Plan.pdf', size: '7.5 MB', type: 'PDF', uploadedBy: 'Sanjay Reddy', uploadedAt: 'Oct 3, 2024 9:00 AM' },
{ name: 'Service_Center_Requirements.xlsx', size: '2.8 MB', type: 'Excel', uploadedBy: 'Planning Team', uploadedAt: 'Oct 3, 2024 11:30 AM' },
{ name: 'Customer_Demand_Analysis.pptx', size: '4.2 MB', type: 'PowerPoint', uploadedBy: 'Analytics Team', uploadedAt: 'Oct 4, 2024 2:15 PM' },
{ name: 'ROI_Projections_Service_Network.xlsx', size: '1.9 MB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Oct 5, 2024 10:45 AM' }
],
spectators: [
{ name: 'Regional Managers', role: 'Service Operations', avatar: 'RM' },
{ name: 'Training Team', role: 'Technician Development', avatar: 'TT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Service center expansion proposal submitted', user: 'Sanjay Reddy', timestamp: 'Oct 3, 2024 8:45 AM' },
{ type: 'assignment', action: 'Assigned to Ramesh Kulkarni', details: 'Forwarded to Head of After Sales Service', user: 'System', timestamp: 'Oct 3, 2024 8:46 AM' },
{ type: 'reminder', action: 'Reminder Sent', details: 'TAT breach reminder sent to approver', user: 'System', timestamp: 'Oct 6, 2024 5:45 PM' },
{ type: 'updated', action: 'Additional Documents', details: 'ROI projections added by finance team', user: 'Finance Team', timestamp: 'Oct 5, 2024 10:45 AM' }
],
tags: ['service-expansion', 'western-region', 'tier2-cities', 'overdue']
},
'RE-REQ-2024-004': {
id: 'RE-REQ-2024-004',
title: 'Employee Training Program - Advanced Motorcycle Mechanics',
description: 'Comprehensive training program for 50 service center technicians covering advanced diagnostics, electrical systems, fuel injection troubleshooting, and customer service excellence. Program duration: 3 weeks. Includes certification upon completion.',
category: 'Human Resources',
subcategory: 'Training & Development',
status: 'approved',
priority: 'standard',
amount: '₹18,50,000',
slaProgress: 100,
slaRemaining: 'Completed',
slaEndDate: 'Oct 5, 2024 5:00 PM',
currentStep: 3,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Kavita Menon',
role: 'Training Manager',
department: 'Human Resources',
email: 'kavita.menon@royalenfield.com',
phone: '+91 98765 43243',
avatar: 'KM'
},
department: 'Human Resources',
createdAt: 'Sep 28, 2024 11:00 AM',
updatedAt: 'Oct 5, 2024 4:30 PM',
dueDate: '2024-10-05T17:00:00Z',
submittedDate: '2024-09-28T11:00:00Z',
estimatedCompletion: 'Oct 5, 2024',
currentApprover: 'Completed',
approverLevel: '3 of 3',
conclusionRemark: 'All approvals completed. Training program scheduled for November 2024.',
approvalFlow: [
{
step: 1,
approver: 'Ramesh Kulkarni',
role: 'Head - After Sales Service',
status: 'approved',
tatHours: 48,
actualHours: 36,
assignedAt: '2024-09-28T11:00:00Z',
comment: 'Excellent initiative. Training content approved.',
timestamp: 'Sep 29, 2024 11:00 PM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'approved',
tatHours: 72,
actualHours: 48,
assignedAt: '2024-09-29T23:00:00Z',
comment: 'Budget approved. Cost per participant is reasonable.',
timestamp: 'Oct 1, 2024 11:00 PM'
},
{
step: 3,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'approved',
tatHours: 96,
actualHours: 72,
assignedAt: '2024-10-01T23:00:00Z',
comment: 'Final approval granted. Proceed with program execution.',
timestamp: 'Oct 5, 2024 4:30 PM'
}
],
documents: [
{ name: 'Training_Curriculum.pdf', size: '3.2 MB', type: 'PDF', uploadedBy: 'Kavita Menon', uploadedAt: 'Sep 28, 2024 11:15 AM' },
{ name: 'Trainer_Profiles.pdf', size: '1.8 MB', type: 'PDF', uploadedBy: 'HR Team', uploadedAt: 'Sep 28, 2024 2:45 PM' },
{ name: 'Budget_Training_Program.xlsx', size: '680 KB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Sep 29, 2024 10:30 AM' }
],
spectators: [
{ name: 'Service Center Managers', role: 'Participant Coordination', avatar: 'SC' },
{ name: 'Quality Assurance', role: 'Training Quality', avatar: 'QA' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Training program proposal submitted', user: 'Kavita Menon', timestamp: 'Sep 28, 2024 11:00 AM' },
{ type: 'assignment', action: 'Assigned to Ramesh Kulkarni', details: 'Forwarded to After Sales Service Head', user: 'System', timestamp: 'Sep 28, 2024 11:01 AM' },
{ type: 'approval', action: 'Approved by Ramesh Kulkarni', details: 'Level 1 approval completed', user: 'Ramesh Kulkarni', timestamp: 'Sep 29, 2024 11:00 PM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Sep 29, 2024 11:01 PM' },
{ type: 'approval', action: 'Approved by Anil Kapoor', details: 'Budget approval completed', user: 'Anil Kapoor', timestamp: 'Oct 1, 2024 11:00 PM' },
{ type: 'assignment', action: 'Assigned to Deepika Sharma', details: 'Forwarded to VP for final approval', user: 'System', timestamp: 'Oct 1, 2024 11:01 PM' },
{ type: 'approval', action: 'Approved by Deepika Sharma', details: 'Final approval - Request completed', user: 'Deepika Sharma', timestamp: 'Oct 5, 2024 4:30 PM' },
{ type: 'completed', action: 'Request Completed', details: 'All approvals obtained. Training scheduled.', user: 'System', timestamp: 'Oct 5, 2024 4:31 PM' }
],
tags: ['training', 'technicians', 'approved', 'completed']
},
'RE-REQ-2024-005': {
id: 'RE-REQ-2024-005',
title: 'Showroom Renovation - Chennai Flagship Store',
description: 'Complete renovation of Chennai flagship showroom including modern interior design, interactive display zones, customer lounge upgrade, motorcycle test ride facility, and digital experience center. Project timeline: 8 weeks.',
category: 'Infrastructure',
subcategory: 'Retail & Showroom',
status: 'rejected',
priority: 'standard',
amount: '₹65,00,000',
slaProgress: 100,
slaRemaining: 'Rejected',
slaEndDate: 'Oct 4, 2024 5:00 PM',
currentStep: 2,
totalSteps: 4,
template: 'custom',
initiator: {
name: 'Arjun Nair',
role: 'Showroom Manager - South',
department: 'Retail Operations',
email: 'arjun.nair@royalenfield.com',
phone: '+91 98765 43254',
avatar: 'AN'
},
department: 'Retail Operations',
createdAt: 'Oct 1, 2024 9:30 AM',
updatedAt: 'Oct 4, 2024 3:15 PM',
dueDate: '2024-10-04T17:00:00Z',
submittedDate: '2024-10-01T09:30:00Z',
estimatedCompletion: 'N/A',
currentApprover: 'Rejected by Anil Kapoor',
approverLevel: '2 of 4',
conclusionRemark: 'Request rejected due to insufficient budget justification. Please revise with detailed ROI analysis.',
approvalFlow: [
{
step: 1,
approver: 'Suresh Iyer',
role: 'Regional Manager - South',
status: 'approved',
tatHours: 48,
actualHours: 24,
assignedAt: '2024-10-01T09:30:00Z',
comment: 'Renovation is necessary. Current showroom needs upgrade.',
timestamp: 'Oct 2, 2024 9:30 AM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'rejected',
tatHours: 72,
actualHours: 48,
assignedAt: '2024-10-02T09:30:00Z',
comment: 'Budget allocation not justified. Need detailed ROI analysis and comparison with alternative renovation options. Please revise and resubmit with comprehensive financial projections.',
timestamp: 'Oct 4, 2024 3:15 PM'
},
{
step: 3,
approver: 'Legal Team',
role: 'Compliance & Contracts',
status: 'cancelled',
tatHours: 96,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 4,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'cancelled',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Showroom_Renovation_Plan.pdf', size: '12.5 MB', type: 'PDF', uploadedBy: 'Arjun Nair', uploadedAt: 'Oct 1, 2024 9:45 AM' },
{ name: 'Interior_Design_Mockups.zip', size: '85 MB', type: 'ZIP', uploadedBy: 'Design Team', uploadedAt: 'Oct 1, 2024 2:30 PM' },
{ name: 'Contractor_Quotations.xlsx', size: '2.1 MB', type: 'Excel', uploadedBy: 'Procurement Team', uploadedAt: 'Oct 2, 2024 11:15 AM' }
],
spectators: [
{ name: 'Marketing Team', role: 'Brand Experience', avatar: 'MT' },
{ name: 'Customer Experience', role: 'Feedback & Analysis', avatar: 'CX' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Showroom renovation request submitted', user: 'Arjun Nair', timestamp: 'Oct 1, 2024 9:30 AM' },
{ type: 'assignment', action: 'Assigned to Suresh Iyer', details: 'Forwarded to Regional Manager', user: 'System', timestamp: 'Oct 1, 2024 9:31 AM' },
{ type: 'approval', action: 'Approved by Suresh Iyer', details: 'Level 1 approval completed', user: 'Suresh Iyer', timestamp: 'Oct 2, 2024 9:30 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 2, 2024 9:31 AM' },
{ type: 'rejection', action: 'Rejected by Anil Kapoor', details: 'Budget justification insufficient', user: 'Anil Kapoor', timestamp: 'Oct 4, 2024 3:15 PM' },
{ type: 'completed', action: 'Request Rejected', details: 'Workflow terminated. Requires resubmission with revisions.', user: 'System', timestamp: 'Oct 4, 2024 3:16 PM' }
],
tags: ['showroom', 'renovation', 'rejected', 'south-region']
},
'RE-REQ-2024-006': {
id: 'RE-REQ-2024-006',
title: 'Spare Parts Inventory Optimization System',
description: 'Implementation of AI-powered inventory management system for spare parts across all service centers. Features include demand forecasting, automated reordering, stock level optimization, and real-time tracking. Expected to reduce inventory costs by 20% and improve part availability.',
category: 'Technology & Innovation',
subcategory: 'Software Implementation',
status: 'pending',
priority: 'express',
amount: '₹42,00,000',
slaProgress: 35,
slaRemaining: '1 day 16 hours',
slaEndDate: 'Oct 12, 2024 5:00 PM',
currentStep: 1,
totalSteps: 4,
template: 'custom',
initiator: {
name: 'Rahul Deshmukh',
role: 'Head - Supply Chain Technology',
department: 'Supply Chain',
email: 'rahul.deshmukh@royalenfield.com',
phone: '+91 98765 43265',
avatar: 'RD'
},
department: 'Supply Chain',
createdAt: 'Oct 7, 2024 10:00 AM',
updatedAt: 'Oct 8, 2024 9:15 AM',
dueDate: '2024-10-12T17:00:00Z',
submittedDate: '2024-10-07T10:00:00Z',
estimatedCompletion: 'Oct 12, 2024',
currentApprover: 'Vikram Singh',
approverLevel: '1 of 4',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Vikram Singh',
role: 'Head - IT Operations',
status: 'pending',
tatHours: 48,
elapsedHours: 23,
assignedAt: '2024-10-07T10:00:00Z',
comment: null,
timestamp: null
},
{
step: 2,
approver: 'Supply Chain Director',
role: 'Operations Approval',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'waiting',
tatHours: 96,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 4,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'AI_Inventory_System_Proposal.pdf', size: '8.9 MB', type: 'PDF', uploadedBy: 'Rahul Deshmukh', uploadedAt: 'Oct 7, 2024 10:15 AM' },
{ name: 'Vendor_Comparison_Analysis.xlsx', size: '3.4 MB', type: 'Excel', uploadedBy: 'IT Procurement', uploadedAt: 'Oct 7, 2024 2:45 PM' },
{ name: 'Cost_Benefit_Analysis.pptx', size: '6.2 MB', type: 'PowerPoint', uploadedBy: 'Analytics Team', uploadedAt: 'Oct 7, 2024 4:30 PM' },
{ name: 'Implementation_Timeline.pdf', size: '1.5 MB', type: 'PDF', uploadedBy: 'Project Management', uploadedAt: 'Oct 8, 2024 9:15 AM' }
],
spectators: [
{ name: 'Service Center Network', role: 'End Users', avatar: 'SN' },
{ name: 'Data Analytics Team', role: 'System Integration', avatar: 'DA' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'AI inventory system proposal submitted', user: 'Rahul Deshmukh', timestamp: 'Oct 7, 2024 10:00 AM' },
{ type: 'assignment', action: 'Assigned to Vikram Singh', details: 'Forwarded to IT Operations Head', user: 'System', timestamp: 'Oct 7, 2024 10:01 AM' },
{ type: 'updated', action: 'Documents Added', details: 'Implementation timeline document uploaded', user: 'Project Management', timestamp: 'Oct 8, 2024 9:15 AM' }
],
tags: ['technology', 'ai', 'inventory', 'supply-chain', 'high-priority']
},
'RE-REQ-2024-007': {
id: 'RE-REQ-2024-007',
title: 'Dealer Network Meeting - Q4 Business Review',
description: 'Quarterly business review meeting for all authorized dealers across India. Venue: Bangalore. Topics include Q3 performance review, Q4 targets, new model launches, marketing initiatives, service excellence programs, and dealer support policies. Expected attendance: 250 dealers.',
category: 'Events & Conferences',
subcategory: 'Dealer Meetings',
status: 'in-review',
priority: 'standard',
amount: '₹28,50,000',
slaProgress: 58,
slaRemaining: '1 day 12 hours',
slaEndDate: 'Oct 11, 2024 5:00 PM',
currentStep: 2,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Neha Kapoor',
role: 'Dealer Network Manager',
department: 'Sales & Distribution',
email: 'neha.kapoor@royalenfield.com',
phone: '+91 98765 43276',
avatar: 'NK'
},
department: 'Sales & Distribution',
createdAt: 'Oct 6, 2024 2:00 PM',
updatedAt: 'Oct 8, 2024 11:30 AM',
dueDate: '2024-10-11T17:00:00Z',
submittedDate: '2024-10-06T14:00:00Z',
estimatedCompletion: 'Oct 11, 2024',
currentApprover: 'Anil Kapoor',
approverLevel: '2 of 3',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Suresh Mehta',
role: 'Sales Director',
status: 'approved',
tatHours: 48,
actualHours: 36,
assignedAt: '2024-10-06T14:00:00Z',
comment: 'Dealer meeting approved. Agenda looks comprehensive.',
timestamp: 'Oct 8, 2024 2:00 AM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'in-review',
tatHours: 72,
elapsedHours: 33,
assignedAt: '2024-10-08T02:00:00Z',
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Q4_Dealer_Meeting_Agenda.pdf', size: '2.8 MB', type: 'PDF', uploadedBy: 'Neha Kapoor', uploadedAt: 'Oct 6, 2024 2:15 PM' },
{ name: 'Venue_Booking_Confirmation.pdf', size: '980 KB', type: 'PDF', uploadedBy: 'Events Team', uploadedAt: 'Oct 6, 2024 4:45 PM' },
{ name: 'Event_Budget_Breakdown.xlsx', size: '1.2 MB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Oct 7, 2024 10:30 AM' },
{ name: 'Dealer_Invitations_List.xlsx', size: '580 KB', type: 'Excel', uploadedBy: 'Sales Team', uploadedAt: 'Oct 7, 2024 3:15 PM' }
],
spectators: [
{ name: 'Marketing Team', role: 'Presentation Support', avatar: 'MT' },
{ name: 'Events Management', role: 'Logistics Coordination', avatar: 'EM' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Dealer meeting proposal submitted', user: 'Neha Kapoor', timestamp: 'Oct 6, 2024 2:00 PM' },
{ type: 'assignment', action: 'Assigned to Suresh Mehta', details: 'Forwarded to Sales Director', user: 'System', timestamp: 'Oct 6, 2024 2:01 PM' },
{ type: 'approval', action: 'Approved by Suresh Mehta', details: 'Sales approval completed', user: 'Suresh Mehta', timestamp: 'Oct 8, 2024 2:00 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 8, 2024 2:01 AM' },
{ type: 'updated', action: 'Documents Added', details: 'Dealer invitations list uploaded', user: 'Sales Team', timestamp: 'Oct 7, 2024 3:15 PM' }
],
tags: ['dealer-meeting', 'q4-review', 'event', 'bangalore']
},
'RE-REQ-2024-008': {
id: 'RE-REQ-2024-008',
title: 'Cybersecurity Infrastructure Upgrade',
description: 'Comprehensive upgrade of cybersecurity infrastructure including next-gen firewall, intrusion detection system, endpoint protection for 500+ devices, security information and event management (SIEM) system, and employee security awareness training. Critical for protecting customer data and business operations.',
category: 'IT & Infrastructure',
subcategory: 'Security & Compliance',
status: 'pending',
priority: 'urgent',
amount: '₹52,00,000',
slaProgress: 82,
slaRemaining: '4 hours 20 minutes',
slaEndDate: 'Oct 8, 2024 6:00 PM',
currentStep: 2,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Sameer Joshi',
role: 'Chief Information Security Officer',
department: 'Information Technology',
email: 'sameer.joshi@royalenfield.com',
phone: '+91 98765 43287',
avatar: 'SJ'
},
department: 'Information Technology',
createdAt: 'Oct 5, 2024 11:30 AM',
updatedAt: 'Oct 8, 2024 12:45 PM',
dueDate: '2024-10-08T18:00:00Z',
submittedDate: '2024-10-05T11:30:00Z',
estimatedCompletion: 'Oct 8, 2024',
currentApprover: 'Anil Kapoor',
approverLevel: '2 of 3',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Vikram Singh',
role: 'Head - IT Operations',
status: 'approved',
tatHours: 24,
actualHours: 18,
assignedAt: '2024-10-05T11:30:00Z',
comment: 'Critical security upgrade. Approve immediately.',
timestamp: 'Oct 6, 2024 5:30 AM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'pending',
tatHours: 48,
elapsedHours: 55,
assignedAt: '2024-10-06T05:30:00Z',
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Security_Assessment_Report.pdf', size: '15.3 MB', type: 'PDF', uploadedBy: 'Sameer Joshi', uploadedAt: 'Oct 5, 2024 11:45 AM' },
{ name: 'Vendor_Solutions_Comparison.xlsx', size: '4.8 MB', type: 'Excel', uploadedBy: 'IT Security Team', uploadedAt: 'Oct 5, 2024 3:30 PM' },
{ name: 'Implementation_Roadmap.pptx', size: '7.6 MB', type: 'PowerPoint', uploadedBy: 'Project Management', uploadedAt: 'Oct 6, 2024 10:15 AM' },
{ name: 'Risk_Analysis_Report.pdf', size: '5.9 MB', type: 'PDF', uploadedBy: 'Security Consultant', uploadedAt: 'Oct 6, 2024 4:45 PM' }
],
spectators: [
{ name: 'Legal & Compliance', role: 'Data Protection', avatar: 'LC' },
{ name: 'IT Infrastructure', role: 'System Integration', avatar: 'IT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Cybersecurity upgrade proposal submitted', user: 'Sameer Joshi', timestamp: 'Oct 5, 2024 11:30 AM' },
{ type: 'assignment', action: 'Assigned to Vikram Singh', details: 'Forwarded to IT Operations Head', user: 'System', timestamp: 'Oct 5, 2024 11:31 AM' },
{ type: 'approval', action: 'Approved by Vikram Singh', details: 'IT approval - marked as critical', user: 'Vikram Singh', timestamp: 'Oct 6, 2024 5:30 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 6, 2024 5:31 AM' },
{ type: 'reminder', action: 'Urgent Reminder', details: 'TAT breach warning - 4 hours remaining', user: 'System', timestamp: 'Oct 8, 2024 12:45 PM' }
],
tags: ['cybersecurity', 'urgent', 'critical', 'infrastructure', 'overdue']
}
};
// API Endpoints for Custom Requests (to be implemented with backend) // API Endpoints for Custom Requests (to be implemented with backend)
export const CUSTOM_REQUEST_API_ENDPOINTS = { export const CUSTOM_REQUEST_API_ENDPOINTS = {

188
src/utils/dealerDatabase.ts Normal file
View File

@ -0,0 +1,188 @@
// Mock Dealer Database - In production, this would be fetched from API
export interface DealerInfo {
code: string;
name: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
region: string;
managerName: string;
}
export const DEALER_DATABASE: Record<string, DealerInfo> = {
'RE-MH-001': {
code: 'RE-MH-001',
name: 'Royal Motors Mumbai',
email: 'dealer@royalmotorsmumbai.com',
phone: '+91 98765 12345',
address: 'Shop No. 12-15, Central Avenue, Andheri West',
city: 'Mumbai',
state: 'Maharashtra',
region: 'West',
managerName: 'Rahul Deshmukh'
},
'RE-DL-002': {
code: 'RE-DL-002',
name: 'Delhi Enfield Center',
email: 'contact@delhienfield.com',
phone: '+91 98765 23456',
address: '45-48, Rajouri Garden, Main Market',
city: 'New Delhi',
state: 'Delhi',
region: 'North',
managerName: 'Vikram Singh'
},
'RE-BLR-003': {
code: 'RE-BLR-003',
name: 'Bangalore Royal Bikes',
email: 'info@bangaloreroyalbikes.com',
phone: '+91 98765 34567',
address: '123, MG Road, Near Trinity Metro',
city: 'Bangalore',
state: 'Karnataka',
region: 'South',
managerName: 'Suresh Kumar'
},
'RE-CHN-004': {
code: 'RE-CHN-004',
name: 'Chennai Enfield Hub',
email: 'chennai@enfieldhub.com',
phone: '+91 98765 45678',
address: '78-80, Anna Salai, T Nagar',
city: 'Chennai',
state: 'Tamil Nadu',
region: 'South',
managerName: 'Venkat Ramanan'
},
'RE-HYD-005': {
code: 'RE-HYD-005',
name: 'Hyderabad Royal Motorcycles',
email: 'hyderabad@royalmotorcycles.com',
phone: '+91 98765 56789',
address: '234, Banjara Hills, Road No. 12',
city: 'Hyderabad',
state: 'Telangana',
region: 'South',
managerName: 'Anil Reddy'
},
'RE-KOL-006': {
code: 'RE-KOL-006',
name: 'Kolkata Enfield Motors',
email: 'kolkata@enfieldmotors.com',
phone: '+91 98765 67890',
address: '56-58, Park Street, Near Park Hotel',
city: 'Kolkata',
state: 'West Bengal',
region: 'East',
managerName: 'Amit Chatterjee'
},
'RE-PUN-007': {
code: 'RE-PUN-007',
name: 'Pune Royal Dealership',
email: 'pune@royaldealership.com',
phone: '+91 98765 78901',
address: '345, FC Road, Deccan Gymkhana',
city: 'Pune',
state: 'Maharashtra',
region: 'West',
managerName: 'Sandeep Patil'
},
'RE-AHM-008': {
code: 'RE-AHM-008',
name: 'Ahmedabad Enfield Plaza',
email: 'ahmedabad@enfieldplaza.com',
phone: '+91 98765 89012',
address: '123, CG Road, Navrangpura',
city: 'Ahmedabad',
state: 'Gujarat',
region: 'West',
managerName: 'Kiran Patel'
},
'RE-JP-009': {
code: 'RE-JP-009',
name: 'Jaipur Royal Enfield',
email: 'jaipur@royalenfield.com',
phone: '+91 98765 90123',
address: '67, MI Road, C-Scheme',
city: 'Jaipur',
state: 'Rajasthan',
region: 'North',
managerName: 'Rajesh Sharma'
},
'RE-LKO-010': {
code: 'RE-LKO-010',
name: 'Lucknow Enfield Showroom',
email: 'lucknow@enfieldshowroom.com',
phone: '+91 98765 01234',
address: '89, Hazratganj, Near Halwasiya Crossing',
city: 'Lucknow',
state: 'Uttar Pradesh',
region: 'North',
managerName: 'Ankit Verma'
}
};
/**
* Get dealer information by dealer code
* @param dealerCode - The dealer code (e.g., 'RE-MH-001')
* @returns DealerInfo object or null if not found
*/
export function getDealerInfo(dealerCode: string): DealerInfo | null {
return DEALER_DATABASE[dealerCode] || null;
}
/**
* Get all dealers for a specific region
* @param region - Region name (North, South, East, West)
* @returns Array of DealerInfo objects
*/
export function getDealersByRegion(region: string): DealerInfo[] {
return Object.values(DEALER_DATABASE).filter(
dealer => dealer.region.toLowerCase() === region.toLowerCase()
);
}
/**
* Get all dealers for a specific state
* @param state - State name
* @returns Array of DealerInfo objects
*/
export function getDealersByState(state: string): DealerInfo[] {
return Object.values(DEALER_DATABASE).filter(
dealer => dealer.state.toLowerCase() === state.toLowerCase()
);
}
/**
* Get all dealers as an array (for dropdowns, etc.)
* @returns Array of DealerInfo objects
*/
export function getAllDealers(): DealerInfo[] {
return Object.values(DEALER_DATABASE);
}
/**
* Search dealers by name or code
* @param searchTerm - Search term
* @returns Array of matching DealerInfo objects
*/
export function searchDealers(searchTerm: string): DealerInfo[] {
const term = searchTerm.toLowerCase();
return Object.values(DEALER_DATABASE).filter(
dealer =>
dealer.name.toLowerCase().includes(term) ||
dealer.code.toLowerCase().includes(term) ||
dealer.city.toLowerCase().includes(term)
);
}
/**
* Format dealer address for display
* @param dealer - DealerInfo object
* @returns Formatted address string
*/
export function formatDealerAddress(dealer: DealerInfo): string {
return `${dealer.address}, ${dealer.city}, ${dealer.state}`;
}

View File

@ -1,122 +0,0 @@
/**
* GST Utility for state validation and tax calculations
* Contains state codes and helper functions for determining GST components
*/
export const STATE_CODES: Record<string, string> = {
'01': 'Jammu and Kashmir',
'02': 'Himachal Pradesh',
'03': 'Punjab',
'04': 'Chandigarh',
'05': 'Uttarakhand',
'06': 'Haryana',
'07': 'Delhi',
'08': 'Rajasthan',
'09': 'Uttar Pradesh',
'10': 'Bihar',
'11': 'Sikkim',
'12': 'Arunachal Pradesh',
'13': 'Nagaland',
'14': 'Manipur',
'15': 'Mizoram',
'16': 'Tripura',
'17': 'Meghalaya',
'18': 'Assam',
'19': 'West Bengal',
'20': 'Jharkhand',
'21': 'Odisha',
'22': 'Chhattisgarh',
'23': 'Madhya Pradesh',
'24': 'Gujarat',
'25': 'Daman and Diu',
'26': 'Dadra and Nagar Haveli',
'27': 'Maharashtra',
'29': 'Karnataka',
'30': 'Goa',
'31': 'Lakshadweep Islands',
'32': 'Kerala',
'33': 'Tamil Nadu',
'34': 'Pondicherry',
'35': 'Andaman and Nicobar',
'36': 'Telangana',
'37': 'Andhra Pradesh',
'38': 'Ladakh',
'97': 'Others',
};
/**
* Royal Enfield State Code (Tamil Nadu)
*/
export const COMPANY_STATE_CODE = '33';
/**
* State codes that use UTGST instead of SGST
* Andaman and Nicobar Islands, Chandigarh, Dadra and Nagar Haveli and Daman and Diu, Ladakh, Lakshadweep
*/
export const UT_STATE_CODES = new Set(['04', '25', '26', '31', '35', '38']);
/**
* Extracts state code from GSTIN
* @param gstin The 15-digit GSTIN string
* @returns 2-digit state code or null
*/
export const getStateCodeFromGSTIN = (gstin: string | undefined | null): string | null => {
if (!gstin || gstin.length < 2) return null;
const stateCode = gstin.substring(0, 2);
return STATE_CODES[stateCode] ? stateCode : null;
};
/**
* Checks if a state code corresponds to a Union Territory (requiring UTGST)
* @param stateCode 2-digit state code
* @returns boolean
*/
export const isUnionTerritory = (stateCode: string | undefined | null): boolean => {
if (!stateCode) return false;
return UT_STATE_CODES.has(stateCode);
};
/**
* Determines if a transaction is Inter-state (IGST) or Intra-state (CGST+SGST/UTGST)
* @param dealerStateCode 2-digit state code of the dealer
* @returns true if IGST, false if CGST+SGST/UTGST
*/
export const isInterState = (dealerStateCode: string | undefined | null): boolean => {
if (!dealerStateCode) return false; // Default to intra-state if unknown
return dealerStateCode !== COMPANY_STATE_CODE;
};
/**
* Gets the tax components for a given dealer state
* @param dealerStateCode 2-digit state code of the dealer
* @returns Object indicating which tax components are active
*/
export const getActiveTaxComponents = (dealerStateCode: string | undefined | null) => {
if (!dealerStateCode) {
return {
isIGST: false,
isCGST: true,
isSGST: true,
isUTGST: false,
};
}
const isInter = isInterState(dealerStateCode);
if (isInter) {
return {
isIGST: true,
isCGST: false,
isSGST: false,
isUTGST: false,
};
}
const isUT = isUnionTerritory(dealerStateCode);
return {
isIGST: false,
isCGST: true,
isSGST: !isUT,
isUTGST: isUT,
};
};

View File

@ -21,8 +21,5 @@ export function sanitizeHTML(html: string): string {
// 5. Remove meta and link tags (except for purely visual ones if needed, but safer to remove) // 5. Remove meta and link tags (except for purely visual ones if needed, but safer to remove)
sanitized = sanitized.replace(/<(meta|link|iframe|object|embed|applet)[^>]*>/gi, ''); sanitized = sanitized.replace(/<(meta|link|iframe|object|embed|applet)[^>]*>/gi, '');
// 6. Explicitly remove <a> tags to prevent HTML injection of links (VAPT compliance)
sanitized = sanitized.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, '$1');
return sanitized; return sanitized;
} }

View File

@ -1,82 +0,0 @@
/**
* Security Toast Helper
* Shows distinct, styled toast messages for antivirus/security scan errors.
* Each error type (malware, file validation, XSS, scan unavailable) gets
* its own clear message so the user knows exactly what happened.
*/
import { toast } from 'sonner';
// Security error codes returned by the backend
const SECURITY_ERROR_CODES = [
'MALWARE_DETECTED',
'FILE_VALIDATION_FAILED',
'CONTENT_THREAT_DETECTED',
'SCAN_UNAVAILABLE',
'SCAN_ERROR',
] as const;
type SecurityErrorCode = typeof SECURITY_ERROR_CODES[number];
interface SecurityErrorResponse {
error?: string;
message?: string;
details?: {
errors?: string[];
warnings?: string[];
scanEngine?: string;
signatures?: string[];
scanType?: string;
threats?: Array<{ description: string; severity: string }>;
};
scanEventId?: string;
}
// User-friendly titles for each error type
const ERROR_TITLES: Record<SecurityErrorCode, string> = {
MALWARE_DETECTED: '🛑 Malware Detected',
FILE_VALIDATION_FAILED: '⛔ File Rejected',
CONTENT_THREAT_DETECTED: '⚠️ Malicious Content Detected',
SCAN_UNAVAILABLE: '🔒 Security Scan Unavailable',
SCAN_ERROR: '❌ Security Scan Error',
};
/**
* Check if an API error response is a security/scan error.
* Returns true if it was handled (showed a toast), false otherwise.
*/
export function handleSecurityError(error: any): boolean {
const responseData: SecurityErrorResponse = error?.response?.data;
if (!responseData?.error) return false;
const errorCode = responseData.error as SecurityErrorCode;
if (!SECURITY_ERROR_CODES.includes(errorCode)) return false;
const title = ERROR_TITLES[errorCode] || 'Security Error';
const message = responseData.message || 'File was blocked by security scan';
// Build detail text
let detailText = '';
if (responseData.details) {
if (responseData.details.signatures?.length) {
detailText = `Virus: ${responseData.details.signatures.join(', ')}`;
} else if (responseData.details.errors?.length) {
detailText = responseData.details.errors[0] || '';
} else if (responseData.details.threats?.length) {
detailText = responseData.details.threats.map(t => t.description).join(', ');
}
}
// Show custom styled toast
toast.error(title, {
description: detailText || message,
duration: 8000,
style: {
background: '#fef2f2',
border: '1px solid #fca5a5',
color: '#991b1b',
},
});
return true;
}

View File

@ -16,7 +16,7 @@ let configLoaded = false;
// Lazy initialization of configuration // Lazy initialization of configuration
async function ensureConfigLoaded() { async function ensureConfigLoaded() {
if (configLoaded) return; if (configLoaded) return;
try { try {
const config = await configService.getConfig(); const config = await configService.getConfig();
WORK_START_HOUR = config.workingHours.START_HOUR; WORK_START_HOUR = config.workingHours.START_HOUR;
@ -30,7 +30,7 @@ async function ensureConfigLoaded() {
} }
// Initialize config on first import (non-blocking) // Initialize config on first import (non-blocking)
ensureConfigLoaded().catch(() => { }); ensureConfigLoaded().catch(() => {});
/** /**
* Check if current time is within working hours * Check if current time is within working hours
@ -40,7 +40,7 @@ ensureConfigLoaded().catch(() => { });
export function isWorkingTime(date: Date = new Date(), priority: string = 'standard'): boolean { export function isWorkingTime(date: Date = new Date(), priority: string = 'standard'): boolean {
const day = date.getDay(); // 0 = Sunday, 6 = Saturday const day = date.getDay(); // 0 = Sunday, 6 = Saturday
const hour = date.getHours(); const hour = date.getHours();
// For standard priority: exclude weekends // For standard priority: exclude weekends
// For express priority: include weekends (calendar days) // For express priority: include weekends (calendar days)
if (priority === 'standard') { if (priority === 'standard') {
@ -48,13 +48,14 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
return false; return false;
} }
} }
// Working hours check (applies to both priorities) // Working hours check (applies to both priorities)
if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) { if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) {
return false; return false;
} }
// TODO: Add holiday check if holiday API is available
return true; return true;
} }
@ -65,12 +66,12 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
*/ */
export function getNextWorkingTime(date: Date = new Date(), priority: string = 'standard'): Date { export function getNextWorkingTime(date: Date = new Date(), priority: string = 'standard'): Date {
const result = new Date(date); const result = new Date(date);
// If already in working time, return as is // If already in working time, return as is
if (isWorkingTime(result, priority)) { if (isWorkingTime(result, priority)) {
return result; return result;
} }
// For standard priority: skip weekends // For standard priority: skip weekends
if (priority === 'standard') { if (priority === 'standard') {
const day = result.getDay(); const day = result.getDay();
@ -85,13 +86,13 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
return result; return result;
} }
} }
// If before work hours, move to work start // If before work hours, move to work start
if (result.getHours() < WORK_START_HOUR) { if (result.getHours() < WORK_START_HOUR) {
result.setHours(WORK_START_HOUR, 0, 0, 0); result.setHours(WORK_START_HOUR, 0, 0, 0);
return result; return result;
} }
// If after work hours, move to next day work start // If after work hours, move to next day work start
if (result.getHours() >= WORK_END_HOUR) { if (result.getHours() >= WORK_END_HOUR) {
result.setDate(result.getDate() + 1); result.setDate(result.getDate() + 1);
@ -99,7 +100,7 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
// Check if next day is weekend (only for standard priority) // Check if next day is weekend (only for standard priority)
return getNextWorkingTime(result, priority); return getNextWorkingTime(result, priority);
} }
return result; return result;
} }
@ -113,19 +114,19 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
let current = new Date(startDate); let current = new Date(startDate);
const end = new Date(endDate); const end = new Date(endDate);
let elapsedMinutes = 0; let elapsedMinutes = 0;
// Move minute by minute and count only working minutes // Move minute by minute and count only working minutes
while (current < end) { while (current < end) {
if (isWorkingTime(current, priority)) { if (isWorkingTime(current, priority)) {
elapsedMinutes++; elapsedMinutes++;
} }
current.setMinutes(current.getMinutes() + 1); current.setMinutes(current.getMinutes() + 1);
// Safety: stop if calculating more than 1 year // Safety: stop if calculating more than 1 year
const hoursSoFar = elapsedMinutes / 60; const hoursSoFar = elapsedMinutes / 60;
if (hoursSoFar > 8760) break; if (hoursSoFar > 8760) break;
} }
// Convert minutes to hours (with decimal precision) // Convert minutes to hours (with decimal precision)
return elapsedMinutes / 60; return elapsedMinutes / 60;
} }
@ -139,12 +140,12 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date(), priority: string = 'standard'): number { export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date(), priority: string = 'standard'): number {
const deadlineTime = new Date(deadline).getTime(); const deadlineTime = new Date(deadline).getTime();
const currentTime = new Date(fromDate).getTime(); const currentTime = new Date(fromDate).getTime();
// If deadline has passed // If deadline has passed
if (deadlineTime <= currentTime) { if (deadlineTime <= currentTime) {
return 0; return 0;
} }
// Calculate remaining working hours // Calculate remaining working hours
return calculateElapsedWorkingHours(fromDate, deadline, priority); return calculateElapsedWorkingHours(fromDate, deadline, priority);
} }
@ -159,9 +160,9 @@ export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date =
export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date(), priority: string = 'standard'): number { export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date(), priority: string = 'standard'): number {
const totalHours = calculateElapsedWorkingHours(startDate, deadline, priority); const totalHours = calculateElapsedWorkingHours(startDate, deadline, priority);
const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate, priority); const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate, priority);
if (totalHours === 0) return 0; if (totalHours === 0) return 0;
const progress = (elapsedHours / totalHours) * 100; const progress = (elapsedHours / totalHours) * 100;
return Math.min(Math.max(progress, 0), 100); // Clamp between 0 and 100 return Math.min(Math.max(progress, 0), 100); // Clamp between 0 and 100
} }
@ -184,17 +185,17 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
const start = new Date(startDate); const start = new Date(startDate);
const end = new Date(deadline); const end = new Date(deadline);
const now = new Date(); const now = new Date();
const isWorking = isWorkingTime(now, priority); const isWorking = isWorkingTime(now, priority);
const elapsedHours = calculateElapsedWorkingHours(start, now, priority); const elapsedHours = calculateElapsedWorkingHours(start, now, priority);
const totalHours = calculateElapsedWorkingHours(start, end, priority); const totalHours = calculateElapsedWorkingHours(start, end, priority);
const remainingHours = Math.max(0, totalHours - elapsedHours); const remainingHours = Math.max(0, totalHours - elapsedHours);
const progress = calculateSLAProgress(start, end, now, priority); const progress = calculateSLAProgress(start, end, now, priority);
let statusText = ''; let statusText = '';
if (!isWorking) { if (!isWorking) {
statusText = priority === 'express' statusText = priority === 'express'
? 'SLA tracking paused (outside working hours)' ? 'SLA tracking paused (outside working hours)'
: 'SLA tracking paused (outside working hours/days)'; : 'SLA tracking paused (outside working hours/days)';
} else if (remainingHours === 0) { } else if (remainingHours === 0) {
statusText = 'SLA deadline reached'; statusText = 'SLA deadline reached';
@ -207,7 +208,7 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
} else { } else {
statusText = 'On track'; statusText = 'On track';
} }
return { return {
isWorkingTime: isWorking, isWorkingTime: isWorking,
progress, progress,
@ -230,38 +231,38 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
export function formatHoursMinutes(hours: number | null | undefined): string { export function formatHoursMinutes(hours: number | null | undefined): string {
if (hours === null || hours === undefined || hours < 0) return '0 hours'; if (hours === null || hours === undefined || hours < 0) return '0 hours';
if (hours === 0) return '0 hours'; if (hours === 0) return '0 hours';
const WORKING_HOURS_PER_DAY = 8; const WORKING_HOURS_PER_DAY = 8;
// If less than 1 hour, show minutes only // If less than 1 hour, show minutes only
if (hours < 1) { if (hours < 1) {
const m = Math.round(hours * 60); const m = Math.round(hours * 60);
return m > 0 ? `${m}m` : '0 hours'; return m > 0 ? `${m}m` : '0 hours';
} }
// Calculate days and remaining hours (8 hours = 1 day) // Calculate days and remaining hours (8 hours = 1 day)
// Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts // Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts
const days = Math.floor(hours / WORKING_HOURS_PER_DAY); const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY); const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY);
const minutes = Math.round((hours % 1) * 60); const minutes = Math.round((hours % 1) * 60);
// If we have days, format with days (matching backend format) // If we have days, format with days (matching backend format)
if (days > 0) { if (days > 0) {
const dayLabel = days === 1 ? 'day' : 'days'; const dayLabel = days === 1 ? 'day' : 'days';
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours'; const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
const minuteLabel = minutes === 1 ? 'min' : 'm'; const minuteLabel = minutes === 1 ? 'min' : 'm';
if (minutes > 0) { if (minutes > 0) {
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`; return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
} else { } else {
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`; return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`;
} }
} }
// No days, just hours and minutes // No days, just hours and minutes
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours'; const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
const minuteLabel = minutes === 1 ? 'min' : 'm'; const minuteLabel = minutes === 1 ? 'min' : 'm';
if (minutes > 0) { if (minutes > 0) {
return `${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`; return `${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
} else { } else {
@ -275,13 +276,13 @@ export function formatHoursMinutes(hours: number | null | undefined): string {
export function formatWorkingHours(hours: number): string { export function formatWorkingHours(hours: number): string {
if (hours === 0) return '0h'; if (hours === 0) return '0h';
if (hours < 0) return '0h'; if (hours < 0) return '0h';
const totalMinutes = Math.round(hours * 60); const totalMinutes = Math.round(hours * 60);
const days = Math.floor(totalMinutes / (8 * 60)); // 8 working hours per day const days = Math.floor(totalMinutes / (8 * 60)); // 8 working hours per day
const remainingMinutes = totalMinutes % (8 * 60); const remainingMinutes = totalMinutes % (8 * 60);
const remainingHours = Math.floor(remainingMinutes / 60); const remainingHours = Math.floor(remainingMinutes / 60);
const minutes = remainingMinutes % 60; const minutes = remainingMinutes % 60;
if (days > 0 && remainingHours > 0 && minutes > 0) { if (days > 0 && remainingHours > 0 && minutes > 0) {
return `${days}d ${remainingHours}h ${minutes}m`; return `${days}d ${remainingHours}h ${minutes}m`;
} else if (days > 0 && remainingHours > 0) { } else if (days > 0 && remainingHours > 0) {
@ -305,14 +306,14 @@ export function getTimeUntilNextWorking(priority: string = 'standard'): string {
if (isWorkingTime(new Date(), priority)) { if (isWorkingTime(new Date(), priority)) {
return 'In working hours'; return 'In working hours';
} }
const now = new Date(); const now = new Date();
const next = getNextWorkingTime(now, priority); const next = getNextWorkingTime(now, priority);
const diff = next.getTime() - now.getTime(); const diff = next.getTime() - now.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) { if (hours > 24) {
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
return `Resumes in ${days}d ${hours % 24}h`; return `Resumes in ${days}d ${hours % 24}h`;

View File

@ -12,22 +12,23 @@ export function getSocketBaseUrl(): string {
if (baseUrl) { if (baseUrl) {
return baseUrl; return baseUrl;
} }
// Fallback: derive from VITE_API_BASE_URL by removing /api/v1 // Fallback: derive from VITE_API_BASE_URL by removing /api/v1
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
if (apiBaseUrl) { if (apiBaseUrl) {
return apiBaseUrl.replace(/\/api\/v1\/?$/, ''); return apiBaseUrl.replace(/\/api\/v1\/?$/, '');
} }
// Dev fallback // Development fallback
return ''; console.warn('[Socket] No VITE_BASE_URL or VITE_API_BASE_URL found, using localhost:5000');
return 'http://localhost:5000';
} }
export function getSocket(baseUrl?: string): Socket { export function getSocket(baseUrl?: string): Socket {
// Use provided baseUrl or get from environment // Use provided baseUrl or get from environment
const url = baseUrl || getSocketBaseUrl(); const url = baseUrl || getSocketBaseUrl();
if (socket) return socket; if (socket) return socket;
socket = io(url, { socket = io(url, {
withCredentials: true, withCredentials: true,
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
@ -36,19 +37,19 @@ export function getSocket(baseUrl?: string): Socket {
reconnectionDelay: 1000, reconnectionDelay: 1000,
reconnectionAttempts: 5 reconnectionAttempts: 5
}); });
socket.on('connect', () => { socket.on('connect', () => {
// Socket connected // Socket connected
}); });
socket.on('connect_error', (error) => { socket.on('connect_error', (error) => {
console.error('[Socket] Connection error:', error.message); console.error('[Socket] Connection error:', error.message);
}); });
socket.on('disconnect', (_reason) => { socket.on('disconnect', (_reason) => {
// Socket disconnected // Socket disconnected
}); });
return socket; return socket;
} }

View File

@ -57,14 +57,14 @@ export const cookieUtils = {
*/ */
clearAll(): void { clearAll(): void {
const cookieNames = [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY, USER_DATA_KEY]; const cookieNames = [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY, USER_DATA_KEY];
cookieNames.forEach(name => { cookieNames.forEach(name => {
// Remove with default path // Remove with default path
this.remove(name); this.remove(name);
// Remove with root path explicitly // Remove with root path explicitly
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
// Remove with domain (if applicable) // Remove with domain (if applicable)
const hostname = window.location.hostname; const hostname = window.location.hostname;
if (hostname !== 'localhost' && hostname !== '127.0.0.1') { if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
@ -75,60 +75,82 @@ export const cookieUtils = {
}, },
}; };
/**
* Token Manager - Handles token storage and retrieval
*
* SECURITY MODES:
* - Production: Tokens stored in httpOnly cookies by backend only
* Frontend does NOT store access/refresh tokens anywhere
* All API requests rely on cookies being sent automatically
*
* - Development: Tokens stored in localStorage for debugging
* Needed because frontend/backend run on different ports
*/
export class TokenManager { export class TokenManager {
/** /**
* Store access token * Store access token
* In production: No-op (backend handles via httpOnly cookies)
* In development: Store in localStorage for Authorization header
*/ */
static setAccessToken(token: string): void { static setAccessToken(token: string): void {
// SECURITY: In production, don't store tokens client-side
// Backend sets httpOnly cookies that are sent automatically
if (isProduction()) { if (isProduction()) {
return; // No-op - rely on httpOnly cookies return; // No-op - rely on httpOnly cookies
} }
// Dev only: Store for debugging and cross-port requests // Development only: Store for debugging and cross-port requests
localStorage.setItem(ACCESS_TOKEN_KEY, token); localStorage.setItem(ACCESS_TOKEN_KEY, token);
} }
/** /**
* Get access token * Get access token
* * In production: Returns null (cookies are sent automatically)
* In development: Returns from localStorage
*/ */
static getAccessToken(): string | null { static getAccessToken(): string | null {
// SECURITY: In production, return null - cookies are used instead
if (isProduction()) { if (isProduction()) {
return null; return null; // API calls use cookies via withCredentials: true
} }
// Dev: Return from localStorage // Development: Return from localStorage
return localStorage.getItem(ACCESS_TOKEN_KEY); return localStorage.getItem(ACCESS_TOKEN_KEY);
} }
/** /**
* Store refresh token * Store refresh token
* In production: No-op (backend handles via httpOnly cookies)
* In development: Store in localStorage
*/ */
static setRefreshToken(token: string): void { static setRefreshToken(token: string): void {
// SECURITY: In production, don't store tokens client-side // SECURITY: In production, don't store tokens client-side
if (isProduction()) { if (isProduction()) {
return; // No-op - rely on httpOnly cookies return; // No-op - rely on httpOnly cookies
} }
// Dev only // Development only
localStorage.setItem(REFRESH_TOKEN_KEY, token); localStorage.setItem(REFRESH_TOKEN_KEY, token);
} }
/** /**
* Get refresh token * Get refresh token
* In production: Returns null (cookies are used)
* In development: Returns from localStorage
*/ */
static getRefreshToken(): string | null { static getRefreshToken(): string | null {
// SECURITY: In production, return null - backend reads from cookie // SECURITY: In production, return null - backend reads from cookie
if (isProduction()) { if (isProduction()) {
return null; return null;
} }
return localStorage.getItem(REFRESH_TOKEN_KEY); return localStorage.getItem(REFRESH_TOKEN_KEY);
} }
/**
* Store ID token (from Okta) - needed for logout
* Stored in sessionStorage (cleared when tab closes)
*/
static setIdToken(token: string): void { static setIdToken(token: string): void {
// ID token is needed for Okta logout, use sessionStorage (more secure than localStorage) // ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
sessionStorage.setItem(ID_TOKEN_KEY, token); sessionStorage.setItem(ID_TOKEN_KEY, token);
@ -161,9 +183,20 @@ export class TokenManager {
} }
} }
/**
* Clear all tokens and user data
*
* PRODUCTION MODE:
* - Clears user data from localStorage
* - Clears ID token from sessionStorage
* - Backend logout endpoint clears httpOnly cookies
*
* DEVELOPMENT MODE:
* - Clears all localStorage and sessionStorage
* - Clears client-side cookies
*/
static clearAll(): void { static clearAll(): void {
//: Set logout flag in sessionStorage FIRST (before clearing) // CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
// This flag survives the redirect and prevents auto-authentication // This flag survives the redirect and prevents auto-authentication
try { try {
sessionStorage.setItem('__logout_in_progress__', 'true'); sessionStorage.setItem('__logout_in_progress__', 'true');
@ -171,7 +204,7 @@ export class TokenManager {
} catch (e) { } catch (e) {
console.warn('Could not set logout flags:', e); console.warn('Could not set logout flags:', e);
} }
// Clear user data (stored in both modes) // Clear user data (stored in both modes)
try { try {
localStorage.removeItem(USER_DATA_KEY); localStorage.removeItem(USER_DATA_KEY);
@ -179,7 +212,7 @@ export class TokenManager {
} catch (e) { } catch (e) {
console.warn('Error clearing user data:', e); console.warn('Error clearing user data:', e);
} }
// In production, httpOnly cookies are cleared by backend // In production, httpOnly cookies are cleared by backend
// Only need to clear user data above // Only need to clear user data above
if (isProduction()) { if (isProduction()) {
@ -192,8 +225,8 @@ export class TokenManager {
} }
return; return;
} }
// Dev MODE: Clear everything // DEVELOPMENT MODE: Clear everything
const authKeys = [ const authKeys = [
ACCESS_TOKEN_KEY, ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY, REFRESH_TOKEN_KEY,
@ -213,7 +246,7 @@ export class TokenManager {
'persist:auth', 'persist:auth',
'redux-persist', 'redux-persist',
]; ];
authKeys.forEach(key => { authKeys.forEach(key => {
try { try {
localStorage.removeItem(key); localStorage.removeItem(key);
@ -222,14 +255,14 @@ export class TokenManager {
console.warn(`Error removing ${key}:`, e); console.warn(`Error removing ${key}:`, e);
} }
}); });
// Clear ALL localStorage // Clear ALL localStorage
try { try {
localStorage.clear(); localStorage.clear();
} catch (e) { } catch (e) {
console.error('Error clearing localStorage:', e); console.error('Error clearing localStorage:', e);
} }
// Clear ALL sessionStorage except logout flags // Clear ALL sessionStorage except logout flags
try { try {
const keysToKeep = ['__logout_in_progress__', '__force_logout__']; const keysToKeep = ['__logout_in_progress__', '__force_logout__'];
@ -244,7 +277,7 @@ export class TokenManager {
} catch (e) { } catch (e) {
console.error('Error clearing sessionStorage:', e); console.error('Error clearing sessionStorage:', e);
} }
// Clear client-side cookies (development only) // Clear client-side cookies (development only)
cookieUtils.clearAll(); cookieUtils.clearAll();
} }
@ -263,7 +296,11 @@ export class TokenManager {
return !!this.getAccessToken(); return !!this.getAccessToken();
} }
/**
* Check if refresh token exists
* In production: Always returns true if user data exists
* In development: Checks localStorage
*/
static hasRefreshToken(): boolean { static hasRefreshToken(): boolean {
if (isProduction()) { if (isProduction()) {
return !!this.getUserData(); return !!this.getUserData();
@ -281,7 +318,7 @@ export class TokenManager {
window.location.hostname === '' window.location.hostname === ''
); );
} }
/** /**
* Check if we're in production mode * Check if we're in production mode
*/ */

View File

@ -1,50 +0,0 @@
/**
* Validation utilities for HSN and SAC codes
*/
export interface ValidationResult {
isValid: boolean;
message: string;
}
/**
* Validates HSN or SAC code based on GST rules
* @param code The HSN/SAC code string
* @param isService Boolean indicating if it's a Service (SAC) or Goods (HSN)
* @returns ValidationResult object
*/
export const validateHSNSAC = (code: string, isService: boolean): ValidationResult => {
if (!code) return { isValid: true, message: '' };
const cleanCode = code.trim();
// Basic check for digits only
if (!/^\d+$/.test(cleanCode)) {
return { isValid: false, message: 'Code must contain only digits' };
}
if (isService) {
// SAC (Services Accounting Code)
// Must start with 99 and typically has 6 digits
if (!cleanCode.startsWith('99')) {
return { isValid: false, message: 'SAC (Service) code must start with 99' };
}
if (cleanCode.length !== 6) {
return { isValid: false, message: 'SAC code must be exactly 6 digits' };
}
} else {
// HSN (Harmonized System of Nomenclature) for Goods
// Usually 4, 6, or 8 digits in India
const validHSNLengths = [4, 6, 8];
if (!validHSNLengths.includes(cleanCode.length)) {
return { isValid: false, message: 'HSN code must be 4, 6, or 8 digits' };
}
// HSN codes for goods should generally not start with 99 (that's reserved for SAC)
if (cleanCode.startsWith('99')) {
return { isValid: false, message: 'HSN code should not start with 99 (use SAC type for services)' };
}
}
return { isValid: true, message: '' };
};

2
src/vite-env.d.ts vendored
View File

@ -7,8 +7,6 @@ interface ImportMetaEnv {
readonly VITE_APP_VERSION: string; readonly VITE_APP_VERSION: string;
readonly VITE_ENABLE_ANALYTICS: string; readonly VITE_ENABLE_ANALYTICS: string;
readonly VITE_ENABLE_DEBUG: string; readonly VITE_ENABLE_DEBUG: string;
readonly VITE_TANFLOW_BASE_URL: string;
readonly VITE_TANFLOW_CLIENT_ID: string;
} }
interface ImportMeta { interface ImportMeta {

View File

@ -60,24 +60,9 @@ const ensureChunkOrder = () => {
}; };
}; };
// Plugin to replace axios localhost fallback for VAPT compliance
const replaceAxiosLocalhost = () => {
return {
name: 'replace-axios-localhost',
transform(code: string, id: string) {
// Target the specific utils.js file in axios where the localhost string exists
if (id.includes('node_modules') && id.includes('axios') && id.includes('utils.js')) {
// Replace 'http://localhost' with empty string
return code.replace(/'http:\/\/localhost'/g, "''");
}
return null;
},
};
};
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), suppressCssWarnings(), ensureChunkOrder(), replaceAxiosLocalhost()], plugins: [react(), suppressCssWarnings(), ensureChunkOrder()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
@ -93,7 +78,7 @@ export default defineConfig({
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: false, sourcemap: true,
// CSS minification warning is harmless - it's a known issue with certain Tailwind class combinations // CSS minification warning is harmless - it's a known issue with certain Tailwind class combinations
// Re-enable minification with settings that preserve initialization order // Re-enable minification with settings that preserve initialization order
// The "Cannot access 'React' before initialization" error is fixed by keeping React in main bundle // The "Cannot access 'React' before initialization" error is fixed by keeping React in main bundle
@ -134,7 +119,7 @@ export default defineConfig({
chunkFileNames: 'assets/[name]-[hash].js', chunkFileNames: 'assets/[name]-[hash].js',
// Explicitly define chunk order - React must load before Radix UI // Explicitly define chunk order - React must load before Radix UI
manualChunks(id) { manualChunks(id) {
// IMPORTANT: Keep React in main bundle OR ensure it loads first // CRITICAL FIX: Keep React in main bundle OR ensure it loads first
// The "Cannot access 'React' before initialization" error occurs when // The "Cannot access 'React' before initialization" error occurs when
// Radix UI components try to access React before it's initialized // Radix UI components try to access React before it's initialized
// Option 1: Don't split React - keep it in main bundle (most reliable) // Option 1: Don't split React - keep it in main bundle (most reliable)
@ -143,7 +128,7 @@ export default defineConfig({
// For now, let's keep React in main bundle to avoid initialization issues // For now, let's keep React in main bundle to avoid initialization issues
// Only split other vendors // Only split other vendors
// Radix UI - IMPORTANT: ALL Radix packages MUST stay together in ONE chunk // Radix UI - CRITICAL: ALL Radix packages MUST stay together in ONE chunk
// This chunk will import React from the main bundle, avoiding initialization issues // This chunk will import React from the main bundle, avoiding initialization issues
if (id.includes('node_modules/@radix-ui')) { if (id.includes('node_modules/@radix-ui')) {
return 'radix-vendor'; return 'radix-vendor';
@ -187,7 +172,7 @@ export default defineConfig({
chunkSizeWarningLimit: 1500, // Increased limit since we have manual chunks chunkSizeWarningLimit: 1500, // Increased limit since we have manual chunks
}, },
esbuild: { esbuild: {
//: Strip all legal comments to prevent "Suspicious Comments" alerts (e.g. from Redux docs) // CRITICAL: Strip all legal comments to prevent "Suspicious Comments" alerts (e.g. from Redux docs)
legalComments: 'none', legalComments: 'none',
}, },
optimizeDeps: { optimizeDeps: {