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" />
<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.gstatic.com" crossorigin>
</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 { CreateRequest } from '@/pages/CreateRequest';
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 { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
@ -18,21 +18,21 @@ import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings';
import { SecuritySettings } from '@/pages/Settings/SecuritySettings';
import { Notifications } from '@/pages/Notifications';
import { DetailedReports } from '@/pages/DetailedReports';
import { Admin } from '@/pages/Admin';
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi';
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
import { navigateToRequest } from '@/utils/requestNavigation';
import { TokenManager } from '@/utils/tokenManager';
// import { TokenManager } from '@/utils/tokenManager';
interface AppProps {
onLogout?: () => void;
@ -61,8 +61,8 @@ function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: stri
useEffect(() => {
try {
const userData = TokenManager.getUserData();
setIsDealer(userData?.jobTitle === 'Dealer');
// const userData = TokenManager.getUserData();
// // setIsDealer(userData?.jobTitle === 'Dealer');
} catch (error) {
console.error('[App] Error checking dealer status:', error);
setIsDealer(false);
@ -84,8 +84,7 @@ function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: stri
// Render dealer-specific dashboard if user is a dealer
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
@ -194,7 +193,7 @@ function AppRoutes({ onLogout }: AppProps) {
// Regular custom request submission (old flow without API)
// 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
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 (
@ -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 */}
<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 */}
<Route
path="/notifications"

View File

@ -12,13 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
FileText,
Plus,
@ -96,17 +89,16 @@ export function ActivityTypeManager() {
try {
setError(null);
if (!formData.title.trim() || !formData.taxationType.trim() || !formData.sapRefNo.trim()) {
setError('Title, Taxation Type, and Claim Document Type (SAP Ref) are required');
toast.error('Please fill in all mandatory fields');
if (!formData.title.trim()) {
setError('Activity type title is required');
return;
}
const payload: Partial<ActivityType> = {
title: formData.title.trim(),
itemCode: formData.itemCode.trim() || null,
taxationType: formData.taxationType.trim(),
sapRefNo: formData.sapRefNo.trim()
taxationType: formData.taxationType.trim() || null,
sapRefNo: formData.sapRefNo.trim() || null
};
if (editingActivityType) {
@ -405,37 +397,32 @@ export function ActivityTypeManager() {
{/* Taxation Type Field */}
<div className="space-y-2">
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Taxation Type <span className="text-red-500">*</span>
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900">
Taxation Type <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Select
<Input
id="taxationType"
placeholder="e.g., GST, VAT, Exempt"
value={formData.taxationType}
onValueChange={(value) => setFormData({ ...formData, taxationType: value })}
>
<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" />
</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>
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"
/>
<p className="text-xs text-slate-500">Optional taxation type for the activity</p>
</div>
{/* SAP Reference Number Field */}
<div className="space-y-2">
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Claim Document Type (SAP Ref) <span className="text-red-500">*</span>
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900">
SAP Reference Number <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="sapRefNo"
placeholder="e.g., ZCNS, ZRE"
placeholder="e.g., SAP-12345"
value={formData.sapRefNo}
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"
/>
<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>
@ -449,7 +436,7 @@ export function ActivityTypeManager() {
</Button>
<Button
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"
>
<FileText className="w-4 h-4 mr-2" />

View File

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

View File

@ -59,7 +59,7 @@ export function DashboardConfig() {
});
const handleSave = () => {
// TODO: Implement API call to save dashboard configuration
toast.success('Dashboard layout saved successfully');
};

View File

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

View File

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

View File

@ -318,7 +318,7 @@ export function UserManagement() {
const user = users.find(u => u.userId === userId);
if (!user) return;
// TODO: Implement backend API for toggling user status
toast.info('User status toggle functionality coming soon');
};
@ -332,6 +332,7 @@ export function UserManagement() {
return;
}
// TODO: Implement backend API for deleting users
toast.info('User deletion functionality coming soon');
};
@ -514,10 +515,11 @@ export function UserManagement() {
{/* Message */}
{message && (
<div className={`border-2 rounded-lg p-4 ${message.type === 'success'
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
}`}>
<div className={`border-2 rounded-lg p-4 ${
message.type === 'success'
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
}`}>
<div className="flex items-start gap-3">
{message.type === 'success' ? (
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
@ -662,10 +664,11 @@ export function UserManagement() {
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(pageNum)}
className={`w-9 h-9 p-0 ${currentPage === pageNum
? 'bg-re-green hover:bg-re-green/90'
: ''
}`}
className={`w-9 h-9 p-0 ${
currentPage === pageNum
? 'bg-re-green hover:bg-re-green/90'
: ''
}`}
>
{pageNum}
</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 = [
{ id: 'dashboard', label: 'Dashboard', icon: Home },
// 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: 'my-requests', label: 'My Requests', icon: User }
{ id: 'requests', label: 'All Requests', icon: List },
{ id: 'my-requests', label: 'My Requests', icon: User, adminOnly: false },
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
];

View File

@ -151,12 +151,13 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
whileTap={isDisabled ? {} : { scale: 0.98 }}
>
<Card
className={`h-full transition-all duration-300 border-2 ${isDisabled
? 'opacity-50 cursor-not-allowed border-gray-200'
: isSelected
? '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'
}`}
className={`h-full transition-all duration-300 border-2 ${
isDisabled
? 'opacity-50 cursor-not-allowed border-gray-200'
: isSelected
? '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)}
>
<CardHeader className="space-y-4 pb-4">
@ -261,10 +262,11 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
onClick={handleContinue}
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
size="lg"
className={`gap-2 px-8 ${selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400 cursor-not-allowed'
}`}
className={`gap-2 px-8 ${
selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400 cursor-not-allowed'
}`}
>
Continue with Template
<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

@ -297,15 +297,17 @@ export function ApprovalWorkflowStep({
<div className="w-px h-6 bg-gray-300"></div>
</div>
<div className={`p-4 rounded-lg border-2 transition-all ${approver.email
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className={`p-4 rounded-lg border-2 transition-all ${
approver.email
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${approver.email
? 'bg-green-600'
: 'bg-gray-400'
}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
approver.email
? 'bg-green-600'
: 'bg-gray-400'
}`}>
<span className="text-white font-semibold">{level}</span>
</div>
<div className="flex-1">
@ -334,7 +336,7 @@ export function ApprovalWorkflowStep({
<Input
id={`approver-${level}`}
type="email"
placeholder={`approver@${import.meta.env.VITE_APP_DOMAIN}`}
placeholder="approver@royalenfield.com"
value={approver.email || ''}
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
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>
</div>
) : (
displayTemplates.map((template, index) => {
const isComingSoon = index === 1;
displayTemplates.map((template) => {
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
const isDisabled = isComingSoon;
const isCategoryCard = template.id === 'admin-templates-category';
// const isCustomCard = template.id === 'custom';
@ -124,7 +124,7 @@ export function TemplateSelectionStep({
>
<Card
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
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer'
: isCategoryCard

View File

@ -129,7 +129,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
}
// 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
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
@ -149,14 +149,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// In production: Always verify with server (cookies are sent automatically)
// In development: Check local auth data first
if (isProductionMode) {
// Prod: Verify session with server via httpOnly cookie
// Production: Verify session with server via httpOnly cookie
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
} else {
// Dev: If no auth data exists, user is not authenticated
// Development: If no auth data exists, user is not authenticated
if (!hasAuthData) {
setIsAuthenticated(false);
setUser(null);
@ -323,7 +323,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
try {
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)
if (isProductionMode) {
const storedUser = TokenManager.getUserData();
@ -369,7 +369,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return;
}
// Dev MODE: Check local token
// DEVELOPMENT MODE: Check local token
const token = TokenManager.getAccessToken();
const storedUser = TokenManager.getUserData();
@ -454,7 +454,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
try {
setError(null);
// 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 redirectUri = `${window.location.origin}/login/callback`;
const responseType = 'code';
@ -490,15 +490,15 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const logout = async () => {
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
const idToken = TokenManager.getIdToken();
// 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
const authProvider = sessionStorage.getItem('auth_provider') ||
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
'okta'; // Default to OKTA if unknown
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
'okta'; // Default to OKTA if unknown
// Set logout flag to prevent auto-authentication after redirect
// This must be set BEFORE clearing storage so it survives
@ -609,7 +609,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
}
}
// Dev mode: tokens in localStorage
// Development mode: tokens in localStorage
const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) {
return token;
@ -672,7 +672,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
return (
<Auth0Provider
domain="{{IDP_DOMAIN}}/oauth2/default/v1"
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
clientId="0oa2j8slwj5S4bG5k0h8"
authorizationParams={{
redirect_uri: window.location.origin + '/login/callback',

View File

@ -31,14 +31,14 @@ export function StandardClosedRequestsFilters({
searchTerm,
priorityFilter,
statusFilter,
templateTypeFilter: _templateTypeFilter,
// templateTypeFilter,
sortBy,
sortOrder,
activeFiltersCount,
onSearchChange,
onPriorityChange,
onStatusChange,
onTemplateTypeChange: _onTemplateTypeChange,
// onTemplateTypeChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
@ -130,8 +130,8 @@ export function StandardClosedRequestsFilters({
</SelectItem>
</SelectContent>
</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">
<SelectValue placeholder="All Templates" />
</SelectTrigger>

View File

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

View File

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

View File

@ -141,7 +141,7 @@ export function ClaimApproverSelectionStep({
// Create new approver only if it doesn't exist
if (step.isAuto) {
// 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';
newApprovers.push({
email: systemEmail,
@ -193,7 +193,7 @@ export function ClaimApproverSelectionStep({
// Only update if there are actual changes (to avoid infinite loops)
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) {
updateFormData('approvers', newApprovers);
@ -876,258 +876,237 @@ export function ClaimApproverSelectionStep({
<div className="w-px h-3 bg-gray-300"></div>
</div>
{/* Render additional approvers before this step if any */}
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
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">
Additional Approver
</span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL
</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}
</p>
<div className="text-xs text-gray-500">
<div>Email: {addApprover.email}</div>
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
</div>
</div>
{/* Render additional approvers before this step if any */}
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
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">
Additional Approver
</span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL
</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}
</p>
<div className="text-xs text-gray-500">
<div>Email: {addApprover.email}</div>
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
})}
<div className={`p-3 rounded-lg border-2 transition-all ${approver.email && approver.userId
<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
? '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>
? 'bg-blue-600'
: 'bg-gray-400'
}`}>
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
</div>
</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>
{/* 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
{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>
{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 className="relative">
<Input
id={`approver-${step.level}`}
type="text"
placeholder={isPreFilled ? approver.email : "@approver@royalenfield.com"}
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 border-gray-300 focus:border-blue-500 mt-1 w-full text-sm"
/>
{/* 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-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>
);
});
})()}
</CardContent>

View File

@ -29,7 +29,7 @@ import {
} from 'lucide-react';
import { format } from 'date-fns';
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 { useAuth } from '@/contexts/AuthContext';
import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi';
@ -194,26 +194,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Debounce search
dealerSearchTimer.current = setTimeout(async () => {
try {
const result = await searchExternalDealerByCode(value);
if (result) {
// 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([]);
}
const results = await fetchDealersFromAPI(value, 10); // Limit to 10 results
setDealerSearchResults(results);
} catch (error) {
console.error('Error searching external dealer:', error);
console.error('Error searching dealers:', error);
setDealerSearchResults([]);
} finally {
setDealerSearchLoading(false);
@ -250,12 +234,12 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
switch (currentStep) {
case 1:
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
case 2:
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
const approvers = formData.approvers || [];
@ -898,8 +882,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}
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-1">
<div className="flex items-center gap-2 mb-1">
@ -1065,8 +1050,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{STEP_NAMES.map((_name, index) => (
<span
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}
</span>
@ -1099,10 +1085,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{currentStep < totalSteps ? (
<Button
onClick={nextStep}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${!isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
!isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
>
Next
<ArrowRight className="w-4 h-4" />

View File

@ -5,13 +5,13 @@
* 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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 { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
import { useAuth } from '@/contexts/AuthContext';
@ -30,87 +30,95 @@ interface IOBlockedDetails {
blockedDate: string;
blockedBy: string; // User who blocked
sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed' | 'pending';
status: 'blocked' | 'released' | 'failed';
}
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const { user } = useAuth();
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
const organizer = internalOrder?.organizer || null;
// Get estimated budget from proposal details
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)
// 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 [ioNumber, setIoNumber] = useState(existingIONumber);
const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO blocks
// Load existing IO block details from apiRequest
useEffect(() => {
if (internalOrdersList.length > 0) {
const formattedIOs = internalOrdersList.map((io: any) => {
const org = io.organizer || null;
const blockedByName = org?.displayName ||
org?.display_name ||
org?.name ||
(org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) ||
org?.email ||
'Unknown User';
return {
ioNumber: io.ioNumber || io.io_number,
blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0),
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0),
remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0),
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '',
status: (io.status === 'BLOCKED' ? 'blocked' :
io.status === 'RELEASED' ? 'released' :
io.status === 'PENDING' ? 'pending' : 'blocked') as any,
};
});
setBlockedIOs(formattedIOs);
if (internalOrder && existingIONumber) {
// IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking
// We should NOT add blockedAmount to it - that would cause double deduction
// Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block)
const availableBeforeBlock = Number(existingAvailableBalance) || 0;
// 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);
// Get blocked by user name from organizer association (who blocked the amount)
// When amount is blocked, organizedBy stores the user who blocked it
const blockedByName = organizer?.displayName ||
organizer?.display_name ||
organizer?.name ||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
organizer?.email ||
'Unknown User';
// 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,
sapDocumentNumber: sapDocNumber,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
});
// Set fetched amount if available balance exists
if (availableBeforeBlock > 0) {
setFetchedAmount(availableBeforeBlock);
}
}
}
}, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]);
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/**
* Fetch available budget from SAP
@ -135,22 +143,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance);
// Calculate total already blocked amount
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
// 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)));
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
if (estimatedBudget > 0) {
setAmountToBlock(String(estimatedBudget));
} 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 })}`);
} else {
toast.error('Invalid IO number or no available balance found');
@ -201,18 +199,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
return;
}
// 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
// Validate that amount to block must exactly match estimated budget
if (estimatedBudget > 0) {
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
const roundedTotalPlanned = parseFloat(totalPlanned.toFixed(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')})`);
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 })})`);
return;
}
}
@ -271,11 +262,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
// 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
const blockedByName = currentUser?.displayName ||
currentUser?.display_name ||
currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email ||
'Current User';
currentUser?.display_name ||
currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email ||
'Current User';
const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
@ -288,9 +279,8 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
status: 'blocked',
};
setBlockedIOs(prev => [...prev, blocked]);
setBlockedDetails(blocked);
setAmountToBlock(''); // Clear the input
setFetchedAmount(null); // Reset fetched state
toast.success('IO budget blocked successfully in SAP');
// Refresh request details
@ -331,12 +321,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
disabled={fetchingAmount || !!blockedDetails}
className="flex-1"
/>
<Button
onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Download className="w-4 h-4 mr-2" />
@ -346,7 +336,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</div>
{/* 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">
<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.
@ -355,7 +345,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
)}
{/* 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="flex items-center justify-between">
@ -406,7 +396,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
!amountToBlock ||
parseFloat(amountToBlock) <= 0 ||
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]"
>
@ -430,57 +420,71 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</CardDescription>
</CardHeader>
<CardContent>
{blockedIOs.length > 0 ? (
<div className="space-y-6">
{isAdditionalBlockingNeeded && (
<div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse">
<div className="flex items-start gap-3">
<CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-amber-900">Additional Budget Blocking Required</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>
</div>
{blockedDetails ? (
<div className="space-y-4">
{/* Success Banner */}
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
<div className="flex items-start gap-3">
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
</div>
</div>
)}
</div>
{blockedIOs.slice().reverse().map((io, idx) => (
<div key={idx} className="border rounded-lg overflow-hidden">
<div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
<span className="font-semibold text-sm">IO: {io.ioNumber}</span>
<Badge className={
io.status === 'blocked' ? 'bg-green-100 text-green-800' :
io.status === 'pending' ? 'bg-amber-100 text-amber-800' :
'bg-blue-100 text-blue-800'
}>
{io.status === 'blocked' ? 'Blocked' :
io.status === 'pending' ? 'Provisioned' : 'Released'}
</Badge>
</div>
<div className="grid grid-cols-2 divide-x divide-y">
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Amount</p>
<p className="text-sm font-bold text-green-700">{io.blockedAmount.toLocaleString('en-IN')}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">SAP Doc</p>
<p className="text-sm font-medium">{io.sapDocumentNumber || 'N/A'}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Blocked By</p>
<p className="text-xs">{io.blockedBy}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Date</p>
<p className="text-[10px]">{new Date(io.blockedDate).toLocaleString()}</p>
</div>
</div>
{/* Blocked Details */}
<div className="border rounded-lg divide-y">
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
</div>
<div className="p-4">
<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>
</div>
<div className="p-4 bg-green-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
<p className="text-xl font-bold text-green-700">
{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
<p className="text-sm font-medium text-gray-900">
{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4 bg-blue-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
<p className="text-sm font-bold text-blue-700">
{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
<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 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>
) : (

File diff suppressed because it is too large Load Diff

View File

@ -109,7 +109,7 @@ export function ActivityInformationCard({
</label>
<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" />
{activityInfo.estimatedBudget !== undefined && activityInfo.estimatedBudget !== null
{activityInfo.estimatedBudget
? formatCurrency(activityInfo.estimatedBudget)
: 'TBD'}
</p>
@ -123,11 +123,7 @@ export function ActivityInformationCard({
</label>
<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" />
{formatCurrency(
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
)}
{formatCurrency(activityInfo.closedExpenses)}
</p>
</div>
)}
@ -151,40 +147,23 @@ export function ActivityInformationCard({
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
Closed Expenses Breakdown
</label>
<div className="bg-blue-50 border border-blue-200 rounded-lg overflow-hidden">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-blue-100/50">
<tr>
<th className="px-3 py-2 text-left font-semibold text-blue-900">Description</th>
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">Base</th>
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">GST</th>
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-28">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-blue-200/50">
{activityInfo.closedExpensesBreakdown.map((item: any, index: number) => (
<tr key={index} className="hover:bg-blue-100/30">
<td className="px-3 py-2 text-gray-700">
{item.description}
{item.gstRate ? <span className="text-[10px] text-gray-400 block">{item.gstRate}% GST</span> : null}
</td>
<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 className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
{activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
<span className="font-semibold text-gray-900">Total</span>
<span className="font-bold text-blue-600">
{formatCurrency(
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
)}
</span>
</div>
</div>
</div>
)}

View File

@ -1,6 +1,6 @@
/**
* 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
*/
@ -26,11 +26,6 @@ interface DMSDetails {
remarks?: string;
createdByName?: string;
createdAt?: string;
// PWC fields
irn?: string;
ackNo?: string;
ackDate?: string;
signedInvoiceUrl?: string;
}
interface ClaimAmountDetails {
@ -42,8 +37,6 @@ interface ClaimAmountDetails {
interface CostBreakdownItem {
description: string;
amount: number;
gstAmt?: number;
totalAmt?: number;
}
interface RoleBasedVisibility {
@ -92,7 +85,7 @@ export function ProcessDetailsCard({
const calculateTotal = (items?: CostBreakdownItem[]) => {
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
@ -172,57 +165,27 @@ export function ProcessDetailsCard({
</div>
)}
{/* E-Invoice Details */}
{/* DMS Details */}
{visibility.showDMSDetails && dmsDetails && (
<div className="bg-white rounded-lg p-3 border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" />
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
E-Invoice Details
DMS Number
</Label>
</div>
<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>
)}
<p className="font-bold text-gray-900 mb-2">{dmsDetails.dmsNumber}</p>
{dmsDetails.remarks && (
<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>
</div>
)}
<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-[10px] text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
<p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
<p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
</div>
</div>
)}
@ -278,10 +241,10 @@ export function ProcessDetailsCard({
</div>
<div className="space-y-1.5 pt-1">
{estimatedBudgetBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
<span className="font-medium text-gray-900 whitespace-nowrap">
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
@ -306,10 +269,10 @@ export function ProcessDetailsCard({
</div>
<div className="space-y-1.5 pt-1">
{closedExpensesBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
<span className="font-medium text-gray-900 whitespace-nowrap">
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}

View File

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

View File

@ -40,7 +40,6 @@ interface CreditNoteSAPModalProps {
requestNumber?: string;
requestId?: string;
dueDate?: string;
taxationType?: string | null;
}
export function CreditNoteSAPModal({
@ -54,13 +53,10 @@ export function CreditNoteSAPModal({
requestNumber,
requestId: _requestId,
dueDate,
taxationType,
}: CreditNoteSAPModalProps) {
const [downloading, setDownloading] = useState(false);
const [sending, setSending] = useState(false);
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
const creditNoteDate = creditNoteData?.creditNoteDate
@ -122,16 +118,9 @@ export function CreditNoteSAPModal({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl flex-wrap">
<div className="flex items-center gap-2">
<Receipt className="w-6 h-6 text-[--re-green]" />
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 className="font-semibold flex items-center gap-2 text-2xl">
<Receipt className="w-6 h-6 text-[--re-green]" />
Credit Note from SAP
</DialogTitle>
<DialogDescription className="text-base">
Review and send credit note to dealer

View File

@ -1,16 +1,12 @@
.settlement-push-modal {
.dms-push-modal {
width: 90vw !important;
max-width: 1000px !important;
min-width: 320px !important;
max-width: 90vw !important;
max-height: 95vh !important;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Mobile responsive */
@media (max-width: 640px) {
.settlement-push-modal {
.dms-push-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
@ -19,48 +15,25 @@
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.settlement-push-modal {
.dms-push-modal {
width: 90vw !important;
max-width: 900px !important;
max-width: 90vw !important;
}
}
/* Scrollable content area */
.settlement-push-modal .flex-1 {
overflow-y: auto;
padding-right: 4px;
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dms-push-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Custom scrollbar for the modal content */
.settlement-push-modal .flex-1::-webkit-scrollbar {
width: 6px;
/* Extra large screens */
@media (min-width: 1536px) {
.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) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 1200px !important;
max-width: 1000px !important;
}
}
@ -33,7 +33,7 @@
@media (min-width: 1536px) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 1200px !important;
max-width: 1000px !important;
}
}
@ -65,3 +65,4 @@
cursor: pointer;
opacity: 1;
}

View File

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

View File

@ -37,7 +37,6 @@ interface DeptLeadIOApprovalModalProps {
preFilledIONumber?: string;
preFilledBlockedAmount?: number;
preFilledRemainingBalance?: number;
taxationType?: string | null;
}
export function DeptLeadIOApprovalModal({
@ -50,16 +49,11 @@ export function DeptLeadIOApprovalModal({
preFilledIONumber,
preFilledBlockedAmount,
preFilledRemainingBalance,
taxationType,
}: DeptLeadIOApprovalModalProps) {
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
const [comments, setComments] = useState('');
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)
const ioNumber = preFilledIONumber || '';
@ -144,13 +138,8 @@ export function DeptLeadIOApprovalModal({
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-lg lg:text-xl flex items-center gap-2 flex-wrap">
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 className="font-semibold text-lg lg:text-xl">
Review and Approve
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm mt-1">
Review IO details and provide your approval comments
@ -185,10 +174,11 @@ export function DeptLeadIOApprovalModal({
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 text-sm lg:text-base ${actionType === 'approve'
? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
className={`flex-1 text-sm lg:text-base ${
actionType === 'approve'
? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'}
>
<CircleCheckBig className="w-4 h-4 mr-1" />
@ -197,10 +187,11 @@ export function DeptLeadIOApprovalModal({
<Button
type="button"
onClick={() => setActionType('reject')}
className={`flex-1 text-sm lg:text-base ${actionType === 'reject'
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
className={`flex-1 text-sm lg:text-base ${
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
>
<CircleX className="w-4 h-4 mr-1" />
@ -318,10 +309,11 @@ export function DeptLeadIOApprovalModal({
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className={`text-sm lg:text-base ${actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
} text-white`}
className={`text-sm lg:text-base ${
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
} text-white`}
>
{submitting ? (
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`

View File

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

View File

@ -39,7 +39,6 @@ interface CostItem {
id: string;
description: string;
amount: number;
quantity?: number;
}
interface ProposalData {
@ -71,7 +70,6 @@ interface InitiatorProposalApprovalModalProps {
requestId?: string;
request?: any; // Request object to check IO blocking status
previousProposalData?: any;
taxationType?: string | null;
}
export function InitiatorProposalApprovalModal({
@ -86,83 +84,16 @@ export function InitiatorProposalApprovalModal({
requestId: _requestId, // Prefix with _ to indicate intentionally unused
request,
previousProposalData,
taxationType,
}: InitiatorProposalApprovalModalProps) {
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
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)
// Sum up all successful blocks from internalOrders array
const totalBlockedAmount = useMemo(() => {
const internalOrders = request?.internalOrders || request?.internal_orders || [];
// 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 internalOrder = request?.internalOrder || request?.internal_order;
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const isIOBlocked = ioBlockedAmount > 0;
const [previewDoc, setPreviewDoc] = useState<{
name: string;
url: string;
@ -171,6 +102,25 @@ export function InitiatorProposalApprovalModal({
id?: string;
} | 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
const formatDate = (dateString: string) => {
if (!dateString) return '—';
@ -191,11 +141,11 @@ export function InitiatorProposalApprovalModal({
if (!doc.name) return false;
const name = doc.name.toLowerCase();
return name.endsWith('.pdf') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.gif') ||
name.endsWith('.webp');
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.gif') ||
name.endsWith('.webp');
};
// Handle document preview - leverage FilePreview's internal fetching
@ -323,16 +273,9 @@ export function InitiatorProposalApprovalModal({
<Dialog open={isOpen} onOpenChange={handleClose}>
<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">
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl flex-wrap">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
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 className="flex items-center gap-2 text-lg lg:text-xl">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
Requestor Evaluation & Confirmation
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm">
Step 2: Review dealer proposal and make a decision
@ -378,43 +321,43 @@ export function InitiatorProposalApprovalModal({
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
{/* Header Info: Date & Document */}
<div className="flex flex-wrap gap-4 text-xs mt-3">
{previousProposalData.expectedCompletionDate && (
<div className="flex items-center gap-1.5 text-gray-700">
<Calendar className="w-3.5 h-3.5 text-gray-500" />
<span className="font-medium">Expected Completion:</span>
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
</div>
)}
{previousProposalData.expectedCompletionDate && (
<div className="flex items-center gap-1.5 text-gray-700">
<Calendar className="w-3.5 h-3.5 text-gray-500" />
<span className="font-medium">Expected Completion:</span>
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
</div>
)}
{previousProposalData.documentUrl && (
<div className="flex items-center gap-1.5">
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
<>
<Eye className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
View Previous Document
</a>
</>
) : (
<>
<Download className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
Download Previous Document
</a>
</>
)}
</div>
)}
{previousProposalData.documentUrl && (
<div className="flex items-center gap-1.5">
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
<>
<Eye className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
View Previous Document
</a>
</>
) : (
<>
<Download className="w-3.5 h-3.5 text-blue-500" />
<a
href={previousProposalData.documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
>
Download Previous Document
</a>
</>
)}
</div>
)}
</div>
{/* Cost Breakdown */}
@ -455,40 +398,40 @@ export function InitiatorProposalApprovalModal({
{/* Additional/Supporting Documents */}
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
<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">
<FileText className="w-3 h-3" />
Supporting Documents
</p>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard
key={idx}
document={{
documentId: doc.documentId || doc.id || '',
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
uploadedAt: doc.uploadedAt || new Date().toISOString()
}}
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
onDownload={async (id) => {
if (id) {
await downloadDocument(id);
} else {
let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
if (downloadUrl) window.open(downloadUrl, '_blank');
}
}}
/>
))}
<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">
<FileText className="w-3 h-3" />
Supporting Documents
</p>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard
key={idx}
document={{
documentId: doc.documentId || doc.id || '',
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
uploadedAt: doc.uploadedAt || new Date().toISOString()
}}
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
onDownload={async (id) => {
if (id) {
await downloadDocument(id);
} else {
let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
if (downloadUrl) window.open(downloadUrl, '_blank');
}
}}
/>
))}
</div>
</div>
</div>
)}
{/* 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">
{/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Proposal Document 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">
<FileText className="w-4 h-4 text-blue-600" />
Proposal Document
</h3>
{/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Proposal Document 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">
<FileText className="w-4 h-4 text-blue-600" />
Proposal Document
</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>
{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 flex-shrink-0">
{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">
<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>
<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>
<div className="flex items-center gap-2 flex-shrink-0">
{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>
)}
{doc.id && (
<div className="flex items-center gap-1 flex-shrink-0">
{canPreviewDocument(doc) && (
<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');
}
}}
onClick={() => handlePreviewDocument(doc)}
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>
</>
)}
</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">
<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>
)}
<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>
{/* 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'
{/* 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 ${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>
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 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 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>
</>
) : (
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
);
})()}
</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 */}
{/* 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>
</div>
</div>
</div>
{/* Comments Section - Side by Side */}
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
{/* Dealer Comments */}
<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
<MessageSquare className="w-4 h-4 text-blue-600" />
Dealer Comments
</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'}
<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>
</div>
{/* Comments Section - Side by Side */}
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
{/* Dealer Comments */}
<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">
<MessageSquare className="w-4 h-4 text-blue-600" />
Dealer Comments
</h3>
</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>
{/* 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>
{/* Warning for missing comments */}
{!comments.trim() && (
<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" />
<p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
{/* Warning for missing comments */}
{!comments.trim() && (
<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" />
<p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
</div>
</div>
@ -839,10 +756,8 @@ export function InitiatorProposalApprovalModal({
</div>
{/* Warning for IO not blocked - shown below Approve button */}
{!isIOBlocked && (
<p className="text-xs text-red-600 text-center sm:text-left font-medium">
{totalBlockedAmount > 0
? `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 className="text-xs text-red-600 text-center sm:text-left">
Please block IO budget in the IO Tab before approving
</p>
)}
</div>

View File

@ -41,7 +41,7 @@ import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
// Dealer Claim Components (import from index to get properly aliased exports)
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
@ -155,12 +155,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims
// Restricted: Hide from Dealers, show for internal roles (Initiator, Dept Lead, Finance, Admin)
const isDealer = (user as any)?.jobTitle === 'Dealer' || (user as any)?.designation === 'Dealer';
const isClaimManagement = request?.workflowType === 'CLAIM_MANAGEMENT' ||
apiRequest?.workflowType === 'CLAIM_MANAGEMENT' ||
request?.templateType === 'claim-management';
const showIOTab = isClaimManagement && !isDealer;
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
const showIOTab = isInitiator;
const {
mergedMessages,
@ -381,8 +377,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
@ -677,7 +673,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
request={request}
isInitiator={isInitiator}
isSpectator={isSpectator}
currentApprovalLevel={currentApprovalLevel}
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel}
onAddApprover={() => setShowAddApproverModal(true)}
onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)}

View File

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { uploadDocument } from '@/services/documentApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner';
import { handleSecurityError } from '@/utils/securityToast';
/**
* Custom Hook: useDocumentUpload
@ -202,10 +201,8 @@ export function useDocumentUpload(
} catch (error: any) {
console.error('[useDocumentUpload] Upload error:', error);
// Show security-specific red toast for scan errors, or generic error toast
if (!handleSecurityError(error)) {
toast.error(error?.response?.data?.message || 'Failed to upload document');
}
// Error feedback with backend error message if available
toast.error(error?.response?.data?.error || 'Failed to upload document');
} finally {
setUploadingDocument(false);

View File

@ -1,6 +1,8 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
import apiClient from '@/services/authApi';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { getSocket } from '@/utils/socket';
/**
@ -211,9 +213,9 @@ export function useRequestDetails(
*/
const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
: [];
/**
@ -238,7 +240,6 @@ export function useRequestDetails(
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
let internalOrders = [];
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
@ -251,7 +252,6 @@ export function useRequestDetails(
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null;
@ -328,7 +328,6 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null,
internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null,
@ -495,9 +494,9 @@ export function useRequestDetails(
// Filter out TAT warnings from activities
const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
: [];
// Fetch pause details only if request is actually paused
@ -520,7 +519,6 @@ export function useRequestDetails(
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
let internalOrders = [];
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
@ -532,7 +530,6 @@ export function useRequestDetails(
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null;
@ -596,7 +593,6 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null,
internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null,
@ -655,13 +651,21 @@ export function useRequestDetails(
/**
* 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(() => {
// Primary source: API data
if (apiRequest) return apiRequest;
// Fallback: Dynamic requests passed as props
// Fallback 1: Static custom request database
const customRequest = CUSTOM_REQUEST_DATABASE[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 ||

View File

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

View File

@ -29,14 +29,14 @@ export function ClosedRequestsFilters({
searchTerm,
priorityFilter,
statusFilter,
templateTypeFilter,
// templateTypeFilter,
sortBy,
sortOrder,
activeFiltersCount,
onSearchChange,
onPriorityChange,
onStatusChange,
onTemplateTypeChange,
// onTemplateTypeChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
@ -129,7 +129,7 @@ export function ClosedRequestsFilters({
</SelectContent>
</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">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
@ -138,7 +138,7 @@ export function ClosedRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
</Select> */}
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>

View File

@ -22,11 +22,11 @@ export function MyRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
templateTypeFilter: _templateTypeFilter,
// templateTypeFilter,
onSearchChange,
onStatusChange,
onPriorityChange,
onTemplateTypeChange: _onTemplateTypeChange,
// onTemplateTypeChange,
}: MyRequestsFiltersProps) {
return (
<Card className="border-gray-200" data-testid="my-requests-filters">
@ -76,7 +76,7 @@ export function MyRequestsFilters({
</SelectContent>
</Select>
{/* <Select value={_templateTypeFilter} onValueChange={_onTemplateTypeChange}>
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<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"
data-testid="template-type-filter"

View File

@ -184,10 +184,7 @@ export function OverviewTab({
{pauseInfo.pauseReason && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
<FormattedDescription
content={pauseInfo.pauseReason}
className="text-sm text-gray-900 mt-1"
/>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
</div>
)}
@ -334,16 +331,19 @@ export function OverviewTab({
{/* Conclusion Remark Section */}
{needsClosure && (
<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 ${
request.status === 'rejected'
? 'from-red-50 to-rose-50 border-red-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>
<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'
}`} />
<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'
}`} />
Conclusion Remark - Final Step
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">
@ -365,7 +365,7 @@ export function OverviewTab({
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
{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
</span>
)}

View File

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

View File

@ -11,6 +11,7 @@ import { useAppSelector } from '@/redux/hooks';
import { Pagination } from '@/components/common/Pagination';
import type { DateRange } from '@/services/dashboard.service';
import dashboardService from '@/services/dashboard.service';
import userApi from '@/services/userApi';
// Components
import { RequestsHeader } from './components/RequestsHeader';
@ -69,6 +70,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
const [backendStats, setBackendStats] = useState<BackendStats | null>(null);
const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1);
@ -77,15 +79,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
// User search hooks
const initiatorSearch = useUserSearch({
allUsers,
filterValue: filters.initiatorFilter,
onFilterChange: filters.setInitiatorFilter,
source: 'local'
onFilterChange: filters.setInitiatorFilter
});
const approverSearch = useUserSearch({
allUsers,
filterValue: filters.approverFilter,
onFilterChange: filters.setApproverFilter,
source: 'local'
onFilterChange: filters.setApproverFilter
});
// Fetch backend stats
@ -98,7 +100,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
statsEndDate?: Date,
filtersWithoutStatus?: {
priority?: string;
templateType?: string;
department?: string;
initiator?: string;
approver?: string;
@ -184,7 +185,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
statsEndDate ? statsEndDate.toISOString() : undefined,
undefined, // status - All Requests stats show all statuses, not filtered by status
filtersWithoutStatus?.priority,
filtersWithoutStatus?.templateType,
undefined, // templateType
filtersWithoutStatus?.department,
filtersWithoutStatus?.initiator,
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
const filtersRef = useRef(filters);
@ -317,7 +332,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Initial fetch
useEffect(() => {
fetchDepartments();
}, [fetchDepartments]);
fetchUsers();
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when filters change (excluding status)
// 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 dashboardService from '@/services/dashboard.service';
import type { DateRange } from '@/services/dashboard.service';
import userApi from '@/services/userApi';
// Components
import { RequestsHeader } from './components/RequestsHeader';
@ -95,6 +96,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1);
@ -103,15 +105,15 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// User search hooks
const initiatorSearch = useUserSearch({
allUsers,
filterValue: filters.initiatorFilter,
onFilterChange: filters.setInitiatorFilter,
source: 'local'
onFilterChange: filters.setInitiatorFilter
});
const approverSearch = useUserSearch({
allUsers,
filterValue: filters.approverFilter,
onFilterChange: filters.setApproverFilter,
source: 'local'
onFilterChange: filters.setApproverFilter
});
// Fetch backend stats using dashboard API
@ -178,6 +180,20 @@ 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
const filtersRef = useRef(filters);
@ -237,7 +253,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Initial fetch
useEffect(() => {
fetchDepartments();
}, [fetchDepartments]);
fetchUsers();
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when filters change (except status filter)
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
@ -406,36 +423,36 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
}
// Fallback: calculate from current page (less accurate, but works during initial load)
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const paused = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'paused';
}).length;
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
return {
return {
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
pending,
pending,
paused,
approved,
rejected,
draft: 0,
closed
};
approved,
rejected,
draft: 0,
closed
};
}, [backendStats, totalRecords, convertedRequests]);
return (

View File

@ -4,44 +4,30 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { User } from '../types/requests.types';
import { userApi } from '@/services/userApi';
interface UseUserSearchOptions {
allUsers: User[];
filterValue: string;
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 [searchResults, setSearchResults] = useState<User[]>([]);
const [showResults, setShowResults] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [searching, setSearching] = useState(false);
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(() => {
async function fetchUserDetail() {
if (filterValue !== 'all' && !selectedUser) {
try {
// Fetch specific user details by ID
const user = await userApi.getUserById(filterValue);
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('');
if (filterValue !== 'all' && allUsers.length > 0) {
const user = allUsers.find(u => u.userId === filterValue);
if (user) {
setSelectedUser(user);
setSearchQuery(user.displayName || user.email);
}
}
fetchUserDetail();
}, [filterValue]);
}, [filterValue, allUsers]);
// Cleanup timer
useEffect(() => {
@ -65,22 +51,17 @@ export function useUserSearch({ filterValue, onFilterChange, source = 'default'
return;
}
searchTimer.current = setTimeout(async () => {
setSearching(true);
try {
const response = await userApi.searchUsers(query.trim(), 10, source);
const users = response.data?.data || [];
setSearchResults(users);
setShowResults(users.length > 0);
} catch (err) {
console.error('Search API failed:', err);
setSearchResults([]);
setShowResults(false);
} finally {
setSearching(false);
}
}, 400); // Slightly longer debounce for API calls
}, [source]);
searchTimer.current = setTimeout(() => {
const searchLower = query.toLowerCase().trim();
const filtered = allUsers.filter((user) => {
const email = (user.email || '').toLowerCase();
const displayName = (user.displayName || '').toLowerCase();
return email.includes(searchLower) || displayName.includes(searchLower);
});
setSearchResults(filtered.slice(0, 10));
setShowResults(filtered.length > 0);
}, 300);
}, [allUsers]);
const handleSelect = useCallback((user: User) => {
setSelectedUser(user);
@ -103,7 +84,6 @@ export function useUserSearch({ filterValue, onFilterChange, source = 'default'
searchResults,
showResults,
selectedUser,
searching,
handleSearch,
handleSelect,
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 { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
// import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserSubscriptions } from '@/services/notificationApi';
export function Settings() {
const navigate = useNavigate();
const { user } = useAuth();
const isAdmin = checkIsAdmin(user);
const [showNotificationModal, setShowNotificationModal] = useState(false);
@ -274,18 +271,9 @@ export function Settings() {
</div>
</CardHeader>
<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">
<p className="text-sm text-gray-600 mb-4">
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>
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
</div>
</div>
</CardContent>
@ -336,9 +324,6 @@ export function Settings() {
</Button>
</CardContent>
</Card>
{/* Additional Settings if needed */}
</div>
</TabsContent>
@ -506,18 +491,9 @@ export function Settings() {
</div>
</CardHeader>
<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">
<p className="text-sm text-gray-600 mb-4">
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>
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
</div>
</div>
</CardContent>
@ -569,8 +545,6 @@ export function Settings() {
</CardContent>
</Card>
</div>
{/* Additional sections if needed */}
</>
)}
</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[]> => {
const response = await apiClient.get('/config/activity-types');

View File

@ -6,7 +6,7 @@
import axios, { AxiosInstance } from 'axios';
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
const apiClient: AxiosInstance = axios.create({
@ -27,13 +27,13 @@ apiClient.interceptors.request.use(
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
if (!isProduction) {
// Dev: Get token from localStorage and add to header
const token = TokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Development: Get token from localStorage and add to header
const token = TokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Prod: Cookies handle authentication automatically
}
// Production: Cookies handle authentication automatically
return config;
},
@ -183,11 +183,11 @@ export async function exchangeCodeForTokens(
// SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body)
// In development, backend returns tokens for cross-port setup
if (result.accessToken && result.refreshToken) {
// Dev mode: Backend returned tokens, store them
// Development mode: Backend returned tokens, store them
TokenManager.setAccessToken(result.accessToken);
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
return result;
@ -214,10 +214,10 @@ export async function refreshAccessToken(): Promise<string> {
// In development, check for refresh token in localStorage
if (!isProduction) {
const refreshToken = TokenManager.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const refreshToken = TokenManager.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
}
// In production, httpOnly cookie with refresh token will be sent automatically
@ -255,7 +255,7 @@ export async function getCurrentUser() {
/**
* 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()
* 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,
proposalData: {
proposalDocument?: File;
costBreakup?: Array<{
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;
}>;
costBreakup?: Array<{ description: string; amount: number }>;
totalEstimatedBudget?: number;
timelineMode?: 'date' | 'days';
expectedCompletionDate?: string;
@ -154,16 +139,7 @@ export async function submitCompletion(
completionData: {
activityCompletionDate: string; // ISO date string
numberOfParticipants?: number;
closedExpenses?: Array<{
description: string;
amount: number;
hsnCode?: string;
isService?: boolean;
quantity?: number;
gstRate?: number;
gstAmt?: number;
totalAmt?: number;
}>;
closedExpenses?: Array<{ description: string; amount: number }>;
totalClosedExpenses?: number;
completionDocuments?: File[];
activityPhotos?: File[];

View File

@ -6,8 +6,8 @@
import { TokenManager } from '../utils/tokenManager';
import axios from 'axios';
const TANFLOW_BASE_URL = import.meta.env.VITE_TANFLOW_BASE_URL || '';
const TANFLOW_CLIENT_ID = import.meta.env.VITE_TANFLOW_CLIENT_ID || '';
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 || 'REFLOW';
const TANFLOW_REDIRECT_URI = `${window.location.origin}/login/callback`;
/**
@ -63,7 +63,7 @@ export async function exchangeTanflowCodeForTokens(
idToken: string;
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 {
const response = await axios.post(
@ -112,7 +112,7 @@ export async function exchangeTanflowCodeForTokens(
* Refresh access token using refresh token
*/
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();
if (!refreshToken) {

View File

@ -24,8 +24,8 @@ export interface UserSummary {
isActive?: boolean;
}
export async function searchUsers(query: string, limit: number = 10, source: 'local' | 'okta' | 'default' = 'default') {
const res = await apiClient.get('/users/search', { params: { q: query, limit, source } });
export async function searchUsers(query: string, limit: number = 10) {
const res = await apiClient.get('/users/search', { params: { q: query, limit } });
// ResponseHandler.success returns { success: true, data: array }
return res;
}
@ -102,14 +102,6 @@ export async function getRoleStatistics() {
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)
*/
@ -121,7 +113,6 @@ export async function getAllUsers() {
export const userApi = {
searchUsers,
getUserById,
ensureUserExists,
assignRole,
updateUserRole,

View File

@ -362,12 +362,12 @@ export async function getPauseDetails(requestId: 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`;
}
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`;
}
@ -404,7 +404,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
}
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 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> {
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 isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';

View File

@ -26,17 +26,7 @@ export interface ClaimManagementRequest {
};
estimatedBudget?: number;
closedExpenses?: number;
defaultGstRate?: number;
closedExpensesBreakdown?: Array<{
description: string;
amount: number;
gstRate?: number;
gstAmt?: number;
cgstAmt?: number;
sgstAmt?: number;
igstAmt?: number;
totalAmt?: number;
}>;
closedExpensesBreakdown?: Array<{ description: string; amount: number }>;
description?: string;
};
@ -52,16 +42,7 @@ export interface ClaimManagementRequest {
// Proposal Details (Step 1)
proposalDetails?: {
proposalDocumentUrl?: string;
costBreakup: Array<{
description: string;
amount: number;
gstRate?: number;
gstAmt?: number;
cgstAmt?: number;
sgstAmt?: number;
igstAmt?: number;
totalAmt?: number;
}>;
costBreakup: Array<{ description: string; amount: number }>;
totalEstimatedBudget: number;
timelineMode?: 'date' | 'days';
expectedCompletionDate?: string;
@ -89,12 +70,6 @@ export interface ClaimManagementRequest {
creditNoteNumber?: string;
creditNoteDate?: string;
creditNoteAmount?: number;
// PWC Fields
irn?: string;
ackNo?: string;
ackDate?: string;
signedInvoiceUrl?: string;
taxBreakdown?: any;
};
// Claim Amount
@ -154,20 +129,20 @@ export function mapToClaimManagementRequest(
// Activity fields mapped
// Get budget values from budgetTracking table (new source of truth)
const estimatedBudget = budgetTracking.proposalEstimatedBudget ??
budgetTracking.proposal_estimated_budget ??
budgetTracking.initialEstimatedBudget ??
budgetTracking.initial_estimated_budget ??
claimDetails.estimatedBudget ??
claimDetails.estimated_budget;
const estimatedBudget = budgetTracking.proposalEstimatedBudget ||
budgetTracking.proposal_estimated_budget ||
budgetTracking.initialEstimatedBudget ||
budgetTracking.initial_estimated_budget ||
claimDetails.estimatedBudget ||
claimDetails.estimated_budget;
// Get closed expenses - check multiple sources with proper number conversion
const closedExpensesRaw = budgetTracking?.closedExpenses ??
budgetTracking?.closed_expenses ??
completionDetails?.totalClosedExpenses ??
completionDetails?.total_closed_expenses ??
claimDetails?.closedExpenses ??
claimDetails?.closed_expenses;
const closedExpensesRaw = budgetTracking?.closedExpenses ||
budgetTracking?.closed_expenses ||
completionDetails?.totalClosedExpenses ||
completionDetails?.total_closed_expenses ||
claimDetails?.closedExpenses ||
claimDetails?.closed_expenses;
// Convert to number and handle 0 as valid value
const closedExpenses = closedExpensesRaw !== null && closedExpensesRaw !== undefined
? Number(closedExpensesRaw)
@ -176,24 +151,17 @@ export function mapToClaimManagementRequest(
// Get closed expenses breakdown from new completionExpenses table
const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0
? completionExpenses.map((exp: any) => ({
description: exp.description || exp.itemDescription || exp.item_description || '',
amount: Number(exp.amount) || 0,
gstRate: exp.gstRate ?? exp.gst_rate,
gstAmt: exp.gstAmt ?? exp.gst_amt,
cgstAmt: exp.cgstAmt ?? exp.cgst_amt,
sgstAmt: exp.sgstAmt ?? exp.sgst_amt,
igstAmt: exp.igstAmt ?? exp.igst_amt,
totalAmt: exp.totalAmt ?? exp.total_amt
}))
description: exp.description || exp.itemDescription || '',
amount: Number(exp.amount) || 0
}))
: (completionDetails?.closedExpenses ||
completionDetails?.closed_expenses ||
completionDetails?.closedExpensesBreakdown ||
[]);
completionDetails?.closed_expenses ||
completionDetails?.closedExpensesBreakdown ||
[]);
const activityInfo = {
activityName,
activityType,
defaultGstRate: claimDetails.defaultGstRate || 18,
requestedDate: claimDetails.activityDate || claimDetails.activity_date || apiRequest.createdAt, // Use activityDate as requestedDate, fallback to createdAt
location,
period: (periodStartDate && periodEndDate) ? {
@ -232,29 +200,7 @@ export function mapToClaimManagementRequest(
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
const proposal = proposalDetails ? {
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
costBreakup: Array.isArray(proposalDetails.costItems || proposalDetails.cost_items)
? (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
}))
: [],
costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
expectedCompletionDate: expectedCompletionDate,
@ -278,26 +224,20 @@ export function mapToClaimManagementRequest(
// Map DMS details from new invoice and credit note tables
const dmsDetails = {
eInvoiceNumber: invoice.invoiceNumber || invoice.invoice_number ||
claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
eInvoiceDate: invoice.invoiceDate || invoice.invoice_date ||
claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
dmsNumber: invoice.dmsNumber || invoice.dms_number ||
claimDetails.dmsNumber || claimDetails.dms_number,
claimDetails.dmsNumber || claimDetails.dms_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 ||
claimDetails.creditNoteDate || claimDetails.credit_note_date,
claimDetails.creditNoteDate || claimDetails.credit_note_date,
creditNoteAmount: creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) :
(creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) :
(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,
(creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) :
(creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) :
(claimDetails.credit_note_amount ? Number(claimDetails.credit_note_amount) : undefined)))),
};
// Map claim amounts
@ -327,8 +267,8 @@ export function determineUserRole(apiRequest: any, currentUserId: string): Reque
try {
// Check if user is the initiator
if (apiRequest.initiatorId === currentUserId ||
apiRequest.initiator?.userId === currentUserId ||
apiRequest.requestedBy?.userId === currentUserId) {
apiRequest.initiator?.userId === currentUserId ||
apiRequest.requestedBy?.userId === currentUserId) {
return 'INITIATOR';
}

View File

@ -2,7 +2,166 @@
// This database is exclusively for claim management requests created via ClaimManagementWizard
// 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)
export const CLAIM_MANAGEMENT_API_ENDPOINTS = {

View File

@ -2,7 +2,720 @@
// This database is exclusively for custom requests created via NewRequestWizard
// 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)
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)
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;
}

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

@ -30,7 +30,7 @@ async function ensureConfigLoaded() {
}
// Initialize config on first import (non-blocking)
ensureConfigLoaded().catch(() => { });
ensureConfigLoaded().catch(() => {});
/**
* Check if current time is within working hours
@ -54,6 +54,7 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
return false;
}
// TODO: Add holiday check if holiday API is available
return true;
}

View File

@ -19,8 +19,9 @@ export function getSocketBaseUrl(): string {
return apiBaseUrl.replace(/\/api\/v1\/?$/, '');
}
// Dev fallback
return '';
// Development fallback
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 {

View File

@ -75,37 +75,53 @@ 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 {
/**
* 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 {
// SECURITY: In production, don't store tokens client-side
// Backend sets httpOnly cookies that are sent automatically
if (isProduction()) {
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);
}
/**
* Get access token
*
* In production: Returns null (cookies are sent automatically)
* In development: Returns from localStorage
*/
static getAccessToken(): string | null {
// SECURITY: In production, return null - cookies are used instead
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);
}
/**
* Store refresh token
* In production: No-op (backend handles via httpOnly cookies)
* In development: Store in localStorage
*/
static setRefreshToken(token: string): void {
// SECURITY: In production, don't store tokens client-side
@ -113,12 +129,14 @@ export class TokenManager {
return; // No-op - rely on httpOnly cookies
}
// Dev only
// Development only
localStorage.setItem(REFRESH_TOKEN_KEY, token);
}
/**
* Get refresh token
* In production: Returns null (cookies are used)
* In development: Returns from localStorage
*/
static getRefreshToken(): string | null {
// SECURITY: In production, return null - backend reads from cookie
@ -129,6 +147,10 @@ export class TokenManager {
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 {
// ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
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 {
//: 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
try {
sessionStorage.setItem('__logout_in_progress__', 'true');
@ -193,7 +226,7 @@ export class TokenManager {
return;
}
// Dev MODE: Clear everything
// DEVELOPMENT MODE: Clear everything
const authKeys = [
ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY,
@ -263,7 +296,11 @@ export class TokenManager {
return !!this.getAccessToken();
}
/**
* Check if refresh token exists
* In production: Always returns true if user data exists
* In development: Checks localStorage
*/
static hasRefreshToken(): boolean {
if (isProduction()) {
return !!this.getUserData();

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_ENABLE_ANALYTICS: string;
readonly VITE_ENABLE_DEBUG: string;
readonly VITE_TANFLOW_BASE_URL: string;
readonly VITE_TANFLOW_CLIENT_ID: string;
}
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/
export default defineConfig({
plugins: [react(), suppressCssWarnings(), ensureChunkOrder(), replaceAxiosLocalhost()],
plugins: [react(), suppressCssWarnings(), ensureChunkOrder()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@ -93,7 +78,7 @@ export default defineConfig({
},
build: {
outDir: 'dist',
sourcemap: false,
sourcemap: true,
// CSS minification warning is harmless - it's a known issue with certain Tailwind class combinations
// Re-enable minification with settings that preserve initialization order
// 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',
// Explicitly define chunk order - React must load before Radix UI
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
// Radix UI components try to access React before it's initialized
// 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
// 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
if (id.includes('node_modules/@radix-ui')) {
return 'radix-vendor';
@ -187,7 +172,7 @@ export default defineConfig({
chunkSizeWarningLimit: 1500, // Increased limit since we have manual chunks
},
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',
},
optimizeDeps: {