Compare commits

..

19 Commits

Author SHA1 Message Date
6d6b2a3f9c multi iteration flow added for re-quotation and multiple io block feature added 2026-03-03 18:14:10 +05:30
e11f13d248 dealer from external source implemented and re-iteration and multiple io block implemented need to test end to end 2026-03-02 21:31:40 +05:30
b04776a5f8 added gst non gst lable hided hsn and gst related fields for the non-gst claims 2026-02-26 16:47:13 +05:30
170f9a1788 invoice elae changes done 2026-02-25 19:15:14 +05:30
32a486d6f4 added antivirus and new sanitization for inputs 2026-02-24 19:47:06 +05:30
dfe94555ab cost item ui enhanced and export csv feture added for ivoice line items and validation added for gst inputs based on inter-state and intra-state 2026-02-20 20:41:30 +05:30
5dce660f05 added hsn validation and removed quatity part from the cost related items 2026-02-17 20:37:45 +05:30
5e91b85854 token generation from profile added and cost item enhnced to support hsn 2026-02-16 20:01:02 +05:30
d2d75d93f7 template type filter issue for admin resolved 2026-02-13 18:27:42 +05:30
3a6cc6894c add domain was showing undefined 2026-02-13 15:51:23 +05:30
a16346effd vulnnearable comments removed and source exposing to frobrowser disabled worknote XSS fixed 2026-02-13 15:00:42 +05:30
2fa52b90e3 code sanitized removed mail refernces and url refernces ualong with that routes are secured 2026-02-12 20:54:03 +05:30
80ed407cd8 in pwc implemention implemented upto the invoice genration ui altred to show total amout that may change later afer clarification 2026-02-10 20:12:26 +05:30
7ae9133b98 removed suspicious comments 2026-02-10 09:54:54 +05:30
08cda349f3 started implementing pwc invoice enhanced cost and expence capturing 2026-02-09 20:53:39 +05:30
edd1967336 afteer enabling dealer on frontend db_password fetch from google sectrets resolved , secret fech db connection order enhanced 2026-02-09 15:19:56 +05:30
d285ea88d8 changes made to sanitize html to overcome the VAPT alets 2026-02-09 11:22:40 +05:30
81565d294b changes made to fix the VAPT testing 2026-02-07 14:57:21 +05:30
c97053e0e3 save draft an submit rquest adddd isDraft flag to support postman submit and dealer related code commented and made it completely non-templatized for production 2026-02-06 20:12:28 +05:30
123 changed files with 6495 additions and 5796 deletions

27
.env.local.backup Normal file
View File

@ -0,0 +1,27 @@
#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

@ -1,61 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" /> <link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" /> <meta name="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" /> <meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title> <title>Royal Enfield | Approval Portal</title>
<!-- Preload critical fonts and icons --> <!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Ensure proper icon rendering and layout -->
<style>
/* Ensure Lucide icons render properly */
svg {
display: inline-block;
vertical-align: middle;
}
/* Fix for icon alignment in buttons */
button svg {
flex-shrink: 0;
}
/* Ensure proper text rendering */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Fix for mobile viewport and sidebar */
@media (max-width: 768px) {
html {
overflow-x: hidden;
}
}
/* Ensure proper sidebar toggle behavior */
.sidebar-toggle {
transition: all 0.3s ease-in-out;
}
/* Fix for icon button hover states */
button:hover svg {
transform: scale(1.05);
transition: transform 0.2s ease;
}
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html>
</html>

4
public/robots.txt Normal file
View File

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

9
public/sitemap.xml Normal file
View File

@ -0,0 +1,9 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -318,7 +318,7 @@ export function UserManagement() {
const user = users.find(u => u.userId === userId); const user = users.find(u => u.userId === userId);
if (!user) return; if (!user) return;
// TODO: Implement backend API for toggling user status
toast.info('User status toggle functionality coming soon'); toast.info('User status toggle functionality coming soon');
}; };
@ -332,7 +332,6 @@ export function UserManagement() {
return; return;
} }
// TODO: Implement backend API for deleting users
toast.info('User deletion functionality coming soon'); toast.info('User deletion functionality coming soon');
}; };
@ -515,8 +514,7 @@ export function UserManagement() {
{/* Message */} {/* Message */}
{message && ( {message && (
<div className={`border-2 rounded-lg p-4 ${ <div className={`border-2 rounded-lg p-4 ${message.type === 'success'
message.type === 'success'
? 'border-green-200 bg-green-50' ? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50' : 'border-red-200 bg-red-50'
}`}> }`}>
@ -664,8 +662,7 @@ export function UserManagement() {
variant={currentPage === pageNum ? "default" : "outline"} variant={currentPage === pageNum ? "default" : "outline"}
size="sm" size="sm"
onClick={() => handlePageChange(pageNum)} onClick={() => handlePageChange(pageNum)}
className={`w-9 h-9 p-0 ${ className={`w-9 h-9 p-0 ${currentPage === pageNum
currentPage === pageNum
? 'bg-re-green hover:bg-re-green/90' ? 'bg-re-green hover:bg-re-green/90'
: '' : ''
}`} }`}

View File

@ -0,0 +1,194 @@
/**
* 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

@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/components/ui/utils"; import { cn } from "@/components/ui/utils";
import { sanitizeHTML } from "@/utils/sanitizer";
interface FormattedDescriptionProps { interface FormattedDescriptionProps {
content: string; content: string;
@ -30,10 +31,11 @@ export function FormattedDescription({ content, className }: FormattedDescriptio
} }
// Wrap the table in a scrollable container // Wrap the table in a scrollable container
return `<div class="table-wrapper" style="overflow-x: auto; max-width: 100%; margin: 8px 0;">${match}</div>`; return `<div class="table-wrapper">${match}</div>`;
}); });
return processed; // Sanitize the content to prevent CSP violations (onclick, style tags, etc.)
return sanitizeHTML(processed);
}, [content]); }, [content]);
if (!content) return null; if (!content) return null;

View File

@ -1,7 +1,6 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
import { formatBreachTime } from '@/pages/Dashboard/utils/dashboardCalculations';
export interface CriticalAlertData { export interface CriticalAlertData {
requestId: string; requestId: string;
@ -13,8 +12,6 @@ export interface CriticalAlertData {
breachCount: number; breachCount: number;
currentLevel: number; currentLevel: number;
totalLevels: number; totalLevels: number;
isActionable?: boolean;
requestRole?: 'APPROVER' | 'INITIATOR' | 'PARTICIPANT';
} }
interface CriticalAlertCardProps { interface CriticalAlertCardProps {
@ -43,29 +40,23 @@ const calculateProgress = (alert: CriticalAlertData) => {
return Math.min(100, Math.max(0, Math.round(percentageUsed))); return Math.min(100, Math.max(0, Math.round(percentageUsed)));
}; };
const formatDisplayTime = (alert: CriticalAlertData) => { const formatRemainingTime = (alert: CriticalAlertData) => {
if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A'; if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A';
const hours = alert.totalTATHours; const hours = alert.totalTATHours;
const isOverdue = hours <= 0;
const absHours = Math.abs(hours);
const formattedTime = formatBreachTime(absHours); // If TAT is breached (negative or zero)
if (hours <= 0) {
if (formattedTime === 'Just breached') return 'Breached'; const overdue = Math.abs(hours);
if (overdue < 1) return `Breached`;
return isOverdue ? `${formattedTime} overdue` : `${formattedTime} left`; if (overdue < 24) return `${Math.round(overdue)}h overdue`;
}; return `${Math.round(overdue / 24)}d overdue`;
const getRoleBadge = (role?: string) => {
switch (role) {
case 'APPROVER':
return { label: 'Action Required', className: 'bg-red-100 text-red-700 border-red-200' };
case 'INITIATOR':
return { label: 'My Request', className: 'bg-orange-100 text-orange-700 border-orange-200' };
default:
return { label: 'Monitoring', className: 'bg-blue-100 text-blue-700 border-blue-200' };
} }
// If TAT is still remaining
if (hours < 1) return `${Math.round(hours * 60)}min left`;
if (hours < 24) return `${Math.round(hours)}h left`;
return `${Math.round(hours / 24)}d left`;
}; };
export function CriticalAlertCard({ export function CriticalAlertCard({
@ -74,15 +65,10 @@ export function CriticalAlertCard({
testId = 'critical-alert-card' testId = 'critical-alert-card'
}: CriticalAlertCardProps) { }: CriticalAlertCardProps) {
const progress = calculateProgress(alert); const progress = calculateProgress(alert);
const isActionable = alert.isActionable ?? true; // Default to true if not provided (admin view)
const roleInfo = getRoleBadge(alert.requestRole);
return ( return (
<div <div
className={`p-3 sm:p-4 rounded-lg sm:rounded-xl border hover:shadow-md transition-all duration-200 cursor-pointer ${isActionable className="p-3 sm:p-4 bg-red-50 rounded-lg sm:rounded-xl border border-red-100 hover:shadow-md transition-all duration-200 cursor-pointer"
? 'bg-red-50 border-red-100'
: 'bg-orange-50/50 border-orange-100'
}`}
onClick={() => onNavigate?.(alert.requestNumber)} onClick={() => onNavigate?.(alert.requestNumber)}
data-testid={`${testId}-${alert.requestId}`} data-testid={`${testId}-${alert.requestId}`}
> >
@ -97,22 +83,14 @@ export function CriticalAlertCard({
</p> </p>
{alert.priority === 'express' && ( {alert.priority === 'express' && (
<Star <Star
className={`h-3 w-3 flex-shrink-0 ${isActionable ? 'text-red-500' : 'text-orange-500'}`} className="h-3 w-3 text-red-500 flex-shrink-0"
data-testid={`${testId}-priority-icon`} data-testid={`${testId}-priority-icon`}
/> />
)} )}
{alert.requestRole && (
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 h-4 ${roleInfo.className}`}
>
{roleInfo.label}
</Badge>
)}
{alert.breachCount > 0 && ( {alert.breachCount > 0 && (
<Badge <Badge
variant="destructive" variant="destructive"
className="text-[10px] px-1.5 py-0 h-4" className="text-xs"
data-testid={`${testId}-breach-count`} data-testid={`${testId}-breach-count`}
> >
{alert.breachCount} {alert.breachCount}
@ -128,11 +106,10 @@ export function CriticalAlertCard({
</div> </div>
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs bg-white font-medium whitespace-nowrap ${isActionable ? 'border-red-200 text-red-700' : 'border-orange-200 text-orange-700' className="text-xs bg-white border-red-200 text-red-700 font-medium whitespace-nowrap"
}`}
data-testid={`${testId}-remaining-time`} data-testid={`${testId}-remaining-time`}
> >
{formatDisplayTime(alert)} {formatRemainingTime(alert)}
</Badge> </Badge>
</div> </div>
<div className="space-y-1 sm:space-y-2"> <div className="space-y-1 sm:space-y-2">
@ -147,7 +124,8 @@ export function CriticalAlertCard({
</div> </div>
<Progress <Progress
value={progress} value={progress}
className={`h-1.5 sm:h-2 ${progress >= 80 ? '[&>div]:bg-red-600' : className={`h-1.5 sm:h-2 ${
progress >= 80 ? '[&>div]:bg-red-600' :
progress >= 50 ? '[&>div]:bg-orange-500' : progress >= 50 ? '[&>div]:bg-orange-500' :
'[&>div]:bg-green-600' '[&>div]:bg-green-600'
}`} }`}

View File

@ -72,8 +72,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const items = [ const items = [
{ id: 'dashboard', label: 'Dashboard', icon: Home }, { id: 'dashboard', label: 'Dashboard', icon: Home },
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests) // Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
{ id: 'requests', label: 'All Requests', icon: List }, { id: 'requests', label: 'All Requests', icon: List, adminOnly: false }
{ id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true }, // { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
]; ];
// Add remaining menu items (exclude "My Requests" for dealers) // Add remaining menu items (exclude "My Requests" for dealers)
@ -275,7 +275,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div> </div>
{/* Quick Action in Sidebar - Right below menu items */} {/* Quick Action in Sidebar - Right below menu items */}
{/* {!isDealer && ( */} {!isDealer && (
<div className="mt-6 pt-6 border-t border-gray-800 px-3"> <div className="mt-6 pt-6 border-t border-gray-800 px-3">
<Button <Button
onClick={onNewRequest} onClick={onNewRequest}
@ -286,7 +286,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
Raise New Request Raise New Request
</Button> </Button>
</div> </div>
{/* )} */} )}
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -42,7 +42,6 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
spectators: [] as any[], spectators: [] as any[],
documents: [] as File[] documents: [] as File[]
}); });
const [isDragging, setIsDragging] = useState(false);
const totalSteps = 5; const totalSteps = 5;
@ -79,36 +78,9 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
updateFormData('spectators', formData.spectators.filter(s => s.id !== userId)); updateFormData('spectators', formData.spectators.filter(s => s.id !== userId));
}; };
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement> | React.DragEvent) => { const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
let files: File[] = []; const files = Array.from(event.target.files || []);
if ('target' in event && event.target instanceof HTMLInputElement && event.target.files) {
files = Array.from(event.target.files);
} else if ('dataTransfer' in event && event.dataTransfer.files) {
files = Array.from(event.dataTransfer.files);
}
if (files.length > 0) {
updateFormData('documents', [...formData.documents, ...files]); updateFormData('documents', [...formData.documents, ...files]);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
handleFileUpload(e);
}; };
const removeDocument = (index: number) => { const removeDocument = (index: number) => {
@ -403,16 +375,10 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
Attach supporting documents for your request. Maximum 10MB per file. Attach supporting documents for your request. Maximum 10MB per file.
</p> </p>
<div <div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-border' <Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className={`h-8 w-8 mx-auto mb-2 ${isDragging ? 'text-re-green' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
Drag and drop files here, or click to browse click to browse
</p> </p>
<input <input
type="file" type="file"

View File

@ -151,8 +151,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
whileTap={isDisabled ? {} : { scale: 0.98 }} whileTap={isDisabled ? {} : { scale: 0.98 }}
> >
<Card <Card
className={`h-full transition-all duration-300 border-2 ${ className={`h-full transition-all duration-300 border-2 ${isDisabled
isDisabled
? 'opacity-50 cursor-not-allowed border-gray-200' ? 'opacity-50 cursor-not-allowed border-gray-200'
: isSelected : isSelected
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200' ? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
@ -262,8 +261,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
onClick={handleContinue} onClick={handleContinue}
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled} disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
size="lg" size="lg"
className={`gap-2 px-8 ${ className={`gap-2 px-8 ${selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
? 'bg-blue-600 hover:bg-blue-700' ? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400 cursor-not-allowed' : 'bg-gray-400 cursor-not-allowed'
}`} }`}

View File

@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { sanitizeHTML } from '../../utils/sanitizer';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Avatar, AvatarFallback } from '../ui/avatar'; import { Avatar, AvatarFallback } from '../ui/avatar';
@ -166,7 +167,8 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
const formatMessage = (content: string) => { const formatMessage = (content: string) => {
// Simple mention highlighting // Simple mention highlighting
return content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>'); const formatted = content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
return sanitizeHTML(formatted);
}; };
return ( return (
@ -195,8 +197,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}> <div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
{!msg.isSystem && ( {!msg.isSystem && (
<Avatar className="h-8 w-8 flex-shrink-0"> <Avatar className="h-8 w-8 flex-shrink-0">
<AvatarFallback className={`text-white text-xs ${ <AvatarFallback className={`text-white text-xs ${msg.user.role === 'Initiator' ? 'bg-re-green' :
msg.user.role === 'Initiator' ? 'bg-re-green' :
msg.user.role === 'Current User' ? 'bg-blue-500' : msg.user.role === 'Current User' ? 'bg-blue-500' :
'bg-re-light-green' 'bg-re-light-green'
}`}> }`}>
@ -306,8 +307,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<div key={index} className="flex items-center gap-3"> <div key={index} className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback className={`text-white text-xs ${ <AvatarFallback className={`text-white text-xs ${participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
}`}> }`}>
{participant.avatar} {participant.avatar}
</AvatarFallback> </AvatarFallback>

View File

@ -0,0 +1,297 @@
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

@ -54,13 +54,13 @@ function ChartContainer({
<div <div
data-slot="chart" data-slot="chart"
data-chart={chartId} data-chart={chartId}
style={getChartStyle(config)}
className={cn( className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className, className,
)} )}
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>
{children} {children}
</RechartsPrimitive.ResponsiveContainer> </RechartsPrimitive.ResponsiveContainer>
@ -69,37 +69,39 @@ function ChartContainer({
); );
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const getChartStyle = (config: ChartConfig) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color, ([, config]) => config.theme || config.color,
); );
if (!colorConfig.length) { if (!colorConfig.length) {
return null; return {};
} }
return ( const styles: Record<string, string> = {};
<style
dangerouslySetInnerHTML={{ colorConfig.forEach(([key, itemConfig]) => {
__html: Object.entries(THEMES) // For simplicity, we'll use the default color or the light theme color
.map( // If you need per-theme variables, they should be handled via CSS classes or media queries
([theme, prefix]) => ` // but applying them here as inline styles is CSP-safe.
${prefix} [data-chart=${id}] { const color = itemConfig.color || itemConfig.theme?.light;
${colorConfig if (color) {
.map(([key, itemConfig]) => { styles[`--color-${key}`] = color;
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
} }
`,
) // Handle dark theme if present
.join("\n"), const darkColor = itemConfig.theme?.dark;
}} if (darkColor) {
/> styles[`--color-${key}-dark`] = darkColor;
); }
});
return styles as React.CSSProperties;
};
// Deprecated: Kept for backward compatibility if needed in other files.
const ChartStyle = () => {
return null;
}; };
const ChartTooltip = RechartsPrimitive.Tooltip; const ChartTooltip = RechartsPrimitive.Tooltip;

View File

@ -3,6 +3,7 @@ import { cn } from "./utils";
import { Button } from "./button"; import { Button } from "./button";
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react"; import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "./popover"; import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { sanitizeHTML } from "@/utils/sanitizer";
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> { interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
value: string; value: string;
@ -59,7 +60,8 @@ export function RichTextEditor({
// Only update if the value actually changed externally // Only update if the value actually changed externally
const currentValue = editorRef.current.innerHTML; const currentValue = editorRef.current.innerHTML;
if (currentValue !== value) { if (currentValue !== value) {
editorRef.current.innerHTML = value || ''; // Sanitize incoming content
editorRef.current.innerHTML = sanitizeHTML(value || '');
} }
} }
}, [value]); }, [value]);
@ -169,9 +171,6 @@ export function RichTextEditor({
// Wrap table in scrollable container for mobile // Wrap table in scrollable container for mobile
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper'; wrapper.className = 'table-wrapper';
wrapper.style.overflowX = 'auto';
wrapper.style.maxWidth = '100%';
wrapper.style.margin = '8px 0';
wrapper.appendChild(table); wrapper.appendChild(table);
fragment.appendChild(wrapper); fragment.appendChild(wrapper);
} }
@ -233,9 +232,9 @@ export function RichTextEditor({
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
// Trigger onChange // Trigger onChange with sanitized content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
}, [onChange, cleanWordHTML]); }, [onChange, cleanWordHTML]);
@ -380,7 +379,7 @@ export function RichTextEditor({
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Check active formats after a short delay // Check active formats after a short delay
@ -532,7 +531,7 @@ export function RichTextEditor({
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Close popover // Close popover
@ -636,7 +635,7 @@ export function RichTextEditor({
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Close popover // Close popover
@ -649,7 +648,7 @@ export function RichTextEditor({
// Handle input changes // Handle input changes
const handleInput = React.useCallback(() => { const handleInput = React.useCallback(() => {
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
checkActiveFormats(); checkActiveFormats();
}, [onChange, checkActiveFormats]); }, [onChange, checkActiveFormats]);
@ -685,7 +684,7 @@ export function RichTextEditor({
const handleBlur = React.useCallback(() => { const handleBlur = React.useCallback(() => {
setIsFocused(false); setIsFocused(false);
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
}, [onChange]); }, [onChange]);

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi'; import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
import { sanitizeHTML } from '@/utils/sanitizer';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@ -109,9 +110,7 @@ const getStatusText = (status: string) => {
const formatMessage = (content: string) => { const formatMessage = (content: string) => {
// Enhanced mention highlighting - Blue color with extra bold font for high visibility // Enhanced mention highlighting - Blue color with extra bold font for high visibility
// Matches: @username or @FirstName LastName (only one space allowed for first name + last name) const formattedContent = content
// Pattern: @word or @word word (stops after second word)
return content
.replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => { .replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => {
const afterPos = offset + match.length; const afterPos = offset + match.length;
const afterChar = string[afterPos]; const afterChar = string[afterPos];
@ -124,6 +123,8 @@ const formatMessage = (content: string) => {
return match; return match;
}) })
.replace(/\n/g, '<br />'); .replace(/\n/g, '<br />');
return sanitizeHTML(formattedContent);
}; };
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
@ -779,18 +780,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
})) : undefined })) : undefined
}; };
}) : []; }) : [];
setMessages(prev => { setMessages(mapped as any);
// Keep system messages (activities) from the previous state } catch {
const systemMessages = prev.filter(m => m.isSystem);
// Combine with the newly fetched work notes
const combined = [...mapped, ...systemMessages];
// Sort to maintain chronological order
return combined.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
) as any;
});
} catch (error) {
console.error('[WorkNoteChat] Failed to send message or fetch notes:', error);
setMessages(prev => [...prev, newMessage]); setMessages(prev => [...prev, newMessage]);
} }
} }

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi'; import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
import { sanitizeHTML } from '@/utils/sanitizer';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket'; import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { formatDateTime } from '@/utils/dateFormatter'; import { formatDateTime } from '@/utils/dateFormatter';
@ -58,9 +59,11 @@ interface WorkNoteChatSimpleProps {
} }
const formatMessage = (content: string) => { const formatMessage = (content: string) => {
return content const formattedContent = content
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>') .replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
.replace(/\n/g, '<br />'); .replace(/\n/g, '<br />');
return sanitizeHTML(formattedContent);
}; };
const FileIcon = ({ type }: { type: string }) => { const FileIcon = ({ type }: { type: string }) => {
@ -394,8 +397,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}> <div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
{!msg.isSystem && !isCurrentUser && ( {!msg.isSystem && !isCurrentUser && (
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm"> <Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
<AvatarFallback className={`text-white font-semibold text-sm ${ <AvatarFallback className={`text-white font-semibold text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Approver' ? 'bg-blue-600' : msg.user.role === 'Approver' ? 'bg-blue-600' :
'bg-slate-600' 'bg-slate-600'
}`}> }`}>
@ -528,8 +530,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<button <button
key={index} key={index}
onClick={() => addReaction(msg.id, reaction.emoji)} onClick={() => addReaction(msg.id, reaction.emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${ className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
reaction.users.includes('You')
? 'bg-blue-100 text-blue-800 border border-blue-200' ? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`} }`}

View File

@ -297,14 +297,12 @@ export function ApprovalWorkflowStep({
<div className="w-px h-6 bg-gray-300"></div> <div className="w-px h-6 bg-gray-300"></div>
</div> </div>
<div className={`p-4 rounded-lg border-2 transition-all ${ <div className={`p-4 rounded-lg border-2 transition-all ${approver.email
approver.email
? 'border-green-200 bg-green-50' ? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50' : 'border-gray-200 bg-gray-50'
}`}> }`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${ <div className={`w-10 h-10 rounded-full flex items-center justify-center ${approver.email
approver.email
? 'bg-green-600' ? 'bg-green-600'
: 'bg-gray-400' : 'bg-gray-400'
}`}> }`}>
@ -336,7 +334,7 @@ export function ApprovalWorkflowStep({
<Input <Input
id={`approver-${level}`} id={`approver-${level}`}
type="email" type="email"
placeholder="approver@royalenfield.com" placeholder={`approver@${import.meta.env.VITE_APP_DOMAIN}`}
value={approver.email || ''} value={approver.email || ''}
onChange={(e) => handleApproverEmailChange(index, e.target.value)} onChange={(e) => handleApproverEmailChange(index, e.target.value)}
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full" className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"

View File

@ -1,4 +1,3 @@
import { useState, ChangeEvent, DragEvent, RefObject } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -21,7 +20,7 @@ interface DocumentsStepProps {
onDocumentsToDeleteChange: (ids: string[]) => void; onDocumentsToDeleteChange: (ids: string[]) => void;
onPreviewDocument: (doc: any, isExisting: boolean) => void; onPreviewDocument: (doc: any, isExisting: boolean) => void;
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void; onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
fileInputRef: RefObject<HTMLInputElement>; fileInputRef: React.RefObject<HTMLInputElement>;
} }
/** /**
@ -48,9 +47,8 @@ export function DocumentsStep({
onDocumentErrors, onDocumentErrors,
fileInputRef fileInputRef
}: DocumentsStepProps) { }: DocumentsStepProps) {
const [isDragging, setIsDragging] = useState(false); const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const processFiles = (files: File[]) => {
if (files.length === 0) return; if (files.length === 0) return;
// Validate files // Validate files
@ -92,11 +90,6 @@ export function DocumentsStep({
if (validationErrors.length > 0 && onDocumentErrors) { if (validationErrors.length > 0 && onDocumentErrors) {
onDocumentErrors(validationErrors); onDocumentErrors(validationErrors);
} }
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
processFiles(files);
// Reset file input // Reset file input
if (event.target) { if (event.target) {
@ -104,27 +97,6 @@ export function DocumentsStep({
} }
}; };
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
processFiles(files);
};
const handleRemove = (index: number) => { const handleRemove = (index: number) => {
const newDocs = documents.filter((_, i) => i !== index); const newDocs = documents.filter((_, i) => i !== index);
onDocumentsChange(newDocs); onDocumentsChange(newDocs);
@ -184,18 +156,11 @@ export function DocumentsStep({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div <div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors" data-testid="documents-upload-area">
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-gray-300 hover:border-gray-400' <Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
data-testid="documents-upload-area"
>
<Upload className={`h-12 w-12 mx-auto mb-4 ${isDragging ? 'text-re-green' : 'text-gray-400'}`} />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
Drag and drop files here, or click to browse click to browse
</p> </p>
<input <input
type="file" type="file"

View File

@ -52,18 +52,18 @@ export function TemplateSelectionStep({
const displayTemplates = viewMode === 'main' const displayTemplates = viewMode === 'main'
? [ ? [
...templates, ...templates,
{ // {
id: 'admin-templates-category', // id: 'admin-templates-category',
name: 'Admin Templates', // name: 'Admin Templates',
description: 'Browse standardized request workflows created by your organization administrators', // description: 'Browse standardized request workflows created by your organization administrators',
category: 'Organization', // category: 'Organization',
icon: FolderOpen, // icon: FolderOpen,
estimatedTime: 'Variable', // estimatedTime: 'Variable',
commonApprovers: [], // commonApprovers: [],
suggestedSLA: 0, // suggestedSLA: 0,
priority: 'medium', // priority: 'medium',
fields: {} // fields: {}
} as any // } as any
] ]
: adminTemplates; : adminTemplates;
@ -108,7 +108,7 @@ export function TemplateSelectionStep({
</div> </div>
) : ( ) : (
displayTemplates.map((template) => { displayTemplates.map((template) => {
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder const isComingSoon = false;
const isDisabled = isComingSoon; const isDisabled = isComingSoon;
const isCategoryCard = template.id === 'admin-templates-category'; const isCategoryCard = template.id === 'admin-templates-category';
// const isCustomCard = template.id === 'custom'; // const isCustomCard = template.id === 'custom';

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 // PRIORITY 3: Skip auth check if on callback page - let callback handler process first
// This is critical for production mode where we need to exchange code for tokens // This is essential for production mode where we need to exchange code for tokens
// before we can verify session with server // before we can verify session with server
if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') { if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
// Don't check auth status here - let the callback handler do its job // Don't check auth status here - let the callback handler do its job
@ -149,14 +149,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// In production: Always verify with server (cookies are sent automatically) // In production: Always verify with server (cookies are sent automatically)
// In development: Check local auth data first // In development: Check local auth data first
if (isProductionMode) { if (isProductionMode) {
// Production: Verify session with server via httpOnly cookie // Prod: Verify session with server via httpOnly cookie
if (!isLoggingOut) { if (!isLoggingOut) {
checkAuthStatus(); checkAuthStatus();
} else { } else {
setIsLoading(false); setIsLoading(false);
} }
} else { } else {
// Development: If no auth data exists, user is not authenticated // Dev: If no auth data exists, user is not authenticated
if (!hasAuthData) { if (!hasAuthData) {
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
@ -323,7 +323,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
try { try {
setIsLoading(true); setIsLoading(true);
// PRODUCTION MODE: Verify session via httpOnly cookie // Prod MODE: Verify session via httpOnly cookie
// The cookie is sent automatically with the request (withCredentials: true) // The cookie is sent automatically with the request (withCredentials: true)
if (isProductionMode) { if (isProductionMode) {
const storedUser = TokenManager.getUserData(); const storedUser = TokenManager.getUserData();
@ -369,7 +369,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return; return;
} }
// DEVELOPMENT MODE: Check local token // Dev MODE: Check local token
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
const storedUser = TokenManager.getUserData(); const storedUser = TokenManager.getUserData();
@ -454,7 +454,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
try { try {
setError(null); setError(null);
// Redirect to Okta login // Redirect to Okta login
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || '{{IDP_DOMAIN}}';
const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8'; const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8';
const redirectUri = `${window.location.origin}/login/callback`; const redirectUri = `${window.location.origin}/login/callback`;
const responseType = 'code'; const responseType = 'code';
@ -490,7 +490,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const logout = async () => { const logout = async () => {
try { try {
// CRITICAL: Get id_token from TokenManager before clearing anything //: Get id_token from TokenManager before clearing anything
// Needed for both Okta and Tanflow logout endpoints // Needed for both Okta and Tanflow logout endpoints
const idToken = TokenManager.getIdToken(); const idToken = TokenManager.getIdToken();
@ -609,7 +609,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} }
} }
// Development mode: tokens in localStorage // Dev mode: tokens in localStorage
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) { if (token && !isTokenExpired(token)) {
return token; return token;
@ -672,7 +672,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
export function _Auth0AuthProvider({ children }: { children: ReactNode }) { export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
return ( return (
<Auth0Provider <Auth0Provider
domain="https://dev-830839.oktapreview.com/oauth2/default/v1" domain="{{IDP_DOMAIN}}/oauth2/default/v1"
clientId="0oa2j8slwj5S4bG5k0h8" clientId="0oa2j8slwj5S4bG5k0h8"
authorizationParams={{ authorizationParams={{
redirect_uri: window.location.origin + '/login/callback', redirect_uri: window.location.origin + '/login/callback',

View File

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

View File

@ -31,13 +31,13 @@ export function StandardRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
// templateTypeFilter, templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
onSearchChange, onSearchChange,
onStatusFilterChange, onStatusFilterChange,
onPriorityFilterChange, onPriorityFilterChange,
// onTemplateTypeFilterChange, onTemplateTypeFilterChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
@ -120,7 +120,7 @@ export function StandardRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}> <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -129,7 +129,7 @@ export function StandardRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> */} </Select>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}> <Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>

View File

@ -22,7 +22,6 @@ import { CustomDatePicker } from '@/components/ui/date-picker';
interface StandardUserAllRequestsFiltersProps { interface StandardUserAllRequestsFiltersProps {
// Filters // Filters
searchTerm: string; searchTerm: string;
lifecycleFilter: string;
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
@ -65,7 +64,6 @@ interface StandardUserAllRequestsFiltersProps {
// Actions // Actions
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onLifecycleChange: (value: string) => void;
onStatusChange: (value: string) => void; onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void; onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void; onTemplateTypeChange: (value: string) => void;
@ -87,10 +85,9 @@ interface StandardUserAllRequestsFiltersProps {
export function StandardUserAllRequestsFilters({ export function StandardUserAllRequestsFilters({
searchTerm, searchTerm,
lifecycleFilter,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
// templateTypeFilter, templateTypeFilter,
departmentFilter, departmentFilter,
slaComplianceFilter, slaComplianceFilter,
initiatorFilter: _initiatorFilter, initiatorFilter: _initiatorFilter,
@ -105,10 +102,9 @@ export function StandardUserAllRequestsFilters({
initiatorSearch, initiatorSearch,
approverSearch, approverSearch,
onSearchChange, onSearchChange,
onLifecycleChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
// onTemplateTypeChange, onTemplateTypeChange,
onDepartmentChange, onDepartmentChange,
onSlaComplianceChange, onSlaComplianceChange,
onInitiatorChange: _onInitiatorChange, onInitiatorChange: _onInitiatorChange,
@ -159,17 +155,6 @@ export function StandardUserAllRequestsFilters({
/> />
</div> </div>
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
<SelectValue placeholder="All Requests" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Requests</SelectItem>
<SelectItem value="open">Open Requests</SelectItem>
<SelectItem value="closed">Closed Requests</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}> <Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="status-filter"> <SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" /> <SelectValue placeholder="All Status" />
@ -195,7 +180,7 @@ export function StandardUserAllRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-10" data-testid="template-type-filter"> <SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -204,7 +189,7 @@ export function StandardUserAllRequestsFilters({
<SelectItem value="CUSTOM">Custom</SelectItem> <SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> */} </Select>
<Select <Select
value={departmentFilter} value={departmentFilter}
@ -255,7 +240,7 @@ export function StandardUserAllRequestsFilters({
) : ( ) : (
<> <>
<Input <Input
placeholder="Use @ to search initiator..." placeholder="Search initiator..."
value={initiatorSearch.searchQuery} value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)} onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => { onFocus={() => {
@ -325,7 +310,7 @@ export function StandardUserAllRequestsFilters({
) : ( ) : (
<> <>
<Input <Input
placeholder="Use @ to search approver..." placeholder="Search approver..."
value={approverSearch.searchQuery} value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)} onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => { onFocus={() => {

View File

@ -280,9 +280,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
setShowShareSummaryModal(true); setShowShareSummaryModal(true);
}; };
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase(); const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
const isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed'; const isClosed = request?.status === 'closed';
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
// Fetch summary details if request is closed // Fetch summary details if request is closed
useEffect(() => { useEffect(() => {
@ -420,7 +419,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
refreshing={refreshing} refreshing={refreshing}
onBack={onBack || (() => window.history.back())} onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onShareSummary={summaryId ? handleShareSummary : undefined} onShareSummary={handleShareSummary}
isInitiator={isInitiator} isInitiator={isInitiator}
// Custom module: Business logic for preparing SLA data // Custom module: Business logic for preparing SLA data
slaData={request?.summary?.sla || request?.sla || null} slaData={request?.summary?.sla || request?.sla || null}
@ -517,7 +516,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
generationAttempts={generationAttempts} generationAttempts={generationAttempts}
generationFailed={generationFailed} generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached} maxAttemptsReached={maxAttemptsReached}
isClosed={isClosed}
/> />
</TabsContent> </TabsContent>

View File

@ -172,7 +172,7 @@ export function DealerUserAllRequestsFilters({
) : ( ) : (
<> <>
<Input <Input
placeholder="Use @ to search initiator..." placeholder="Search initiator..."
value={initiatorSearch.searchQuery} value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)} onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => { onFocus={() => {
@ -242,7 +242,7 @@ export function DealerUserAllRequestsFilters({
) : ( ) : (
<> <>
<Input <Input
placeholder="Use @ to search approver..." placeholder="Search approver..."
value={approverSearch.searchQuery} value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)} onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => { onFocus={() => {

View File

@ -141,7 +141,7 @@ export function ClaimApproverSelectionStep({
// Create new approver only if it doesn't exist // Create new approver only if it doesn't exist
if (step.isAuto) { if (step.isAuto) {
// System steps // System steps
const systemEmail = step.level === 8 ? 'finance@royalenfield.com' : 'system@royalenfield.com'; const systemEmail = step.level === 8 ? `finance@${import.meta.env.VITE_APP_DOMAIN}` : `system@${import.meta.env.VITE_APP_DOMAIN}`;
const systemName = step.level === 8 ? 'System/Finance' : 'System'; const systemName = step.level === 8 ? 'System/Finance' : 'System';
newApprovers.push({ newApprovers.push({
email: systemEmail, email: systemEmail,
@ -921,16 +921,14 @@ export function ClaimApproverSelectionStep({
); );
})} })}
<div className={`p-3 rounded-lg border-2 transition-all ${ <div className={`p-3 rounded-lg border-2 transition-all ${approver.email && approver.userId
approver.email && approver.userId
? 'border-green-200 bg-green-50' ? 'border-green-200 bg-green-50'
: isPreFilled : isPreFilled
? 'border-blue-200 bg-blue-50' ? 'border-blue-200 bg-blue-50'
: 'border-gray-200 bg-gray-50' : 'border-gray-200 bg-gray-50'
}`}> }`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${ <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${approver.email && approver.userId
approver.email && approver.userId
? 'bg-green-600' ? 'bg-green-600'
: isPreFilled : isPreFilled
? 'bg-blue-600' ? 'bg-blue-600'
@ -952,14 +950,20 @@ export function ClaimApproverSelectionStep({
</div> </div>
<p className="text-xs text-gray-600 mb-2">{step.description}</p> <p className="text-xs text-gray-600 mb-2">{step.description}</p>
{isEditable && ( {isEditable && (() => {
const isVerified = !!(approver.email && approver.userId);
const isEmpty = !approver.email && !isPreFilled;
return (
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<Label htmlFor={`approver-${step.level}`} className="text-xs font-medium"> <Label htmlFor={`approver-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
Email Address {!isPreFilled && '*'} }`}>
Approver Email {!isPreFilled && '*'}
{isEmpty && <span className="ml-2 text-[10px] font-semibold italic text-blue-600">(Required)</span>}
</Label> </Label>
{approver.email && approver.userId && ( {isVerified && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300"> <Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" /> <CheckCircle className="w-3 h-3 mr-1" />
Verified Verified
@ -970,7 +974,7 @@ export function ClaimApproverSelectionStep({
<Input <Input
id={`approver-${step.level}`} id={`approver-${step.level}`}
type="text" type="text"
placeholder={isPreFilled ? approver.email : "@approver@royalenfield.com"} placeholder={isPreFilled ? approver.email : "@username or email..."}
value={approver.email || ''} value={approver.email || ''}
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
@ -979,7 +983,12 @@ export function ClaimApproverSelectionStep({
} }
}} }}
disabled={isPreFilled || step.isAuto} disabled={isPreFilled || step.isAuto}
className="h-9 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full text-sm" 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 */} {/* Search suggestions dropdown */}
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && ( {!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
@ -1014,7 +1023,8 @@ export function ClaimApproverSelectionStep({
</div> </div>
<div> <div>
<Label htmlFor={`tat-${step.level}`} className="text-xs font-medium"> <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) * TAT (Turn Around Time) *
</Label> </Label>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
@ -1027,14 +1037,24 @@ export function ClaimApproverSelectionStep({
value={approver.tat || ''} value={approver.tat || ''}
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')} onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
disabled={step.isAuto} disabled={step.isAuto}
className="h-9 border-2 border-gray-300 focus:border-blue-500 flex-1 text-sm" 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 <Select
value={approver.tatType || 'hours'} value={approver.tatType || 'hours'}
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')} onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
disabled={step.isAuto} disabled={step.isAuto}
> >
<SelectTrigger className="w-20 h-9 border-2 border-gray-300 text-sm"> <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1045,7 +1065,8 @@ export function ClaimApproverSelectionStep({
</div> </div>
</div> </div>
</div> </div>
)} );
})()}
</div> </div>
</div> </div>
</div> </div>

View File

@ -29,7 +29,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi'; import { verifyDealerLogin, searchExternalDealerByCode, type DealerInfo } from '@/services/dealerApi';
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep'; import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi';
@ -194,10 +194,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Debounce search // Debounce search
dealerSearchTimer.current = setTimeout(async () => { dealerSearchTimer.current = setTimeout(async () => {
try { try {
const results = await fetchDealersFromAPI(value, 10); // Limit to 10 results const result = await searchExternalDealerByCode(value);
setDealerSearchResults(results); 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([]);
}
} catch (error) { } catch (error) {
console.error('Error searching dealers:', error); console.error('Error searching external dealer:', error);
setDealerSearchResults([]); setDealerSearchResults([]);
} finally { } finally {
setDealerSearchLoading(false); setDealerSearchLoading(false);
@ -882,8 +898,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
} }
return ( return (
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${ <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'
approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
}`}> }`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
@ -1050,8 +1065,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{STEP_NAMES.map((_name, index) => ( {STEP_NAMES.map((_name, index) => (
<span <span
key={index} key={index}
className={`text-xs sm:text-sm ${ className={`text-xs sm:text-sm ${index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`} }`}
> >
{index + 1} {index + 1}
@ -1085,8 +1099,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{currentStep < totalSteps ? ( {currentStep < totalSteps ? (
<Button <Button
onClick={nextStep} onClick={nextStep}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${ className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${!isStepValid()
!isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60' ? 'opacity-50 cursor-pointer hover:opacity-60'
: '' : ''
}`} }`}

View File

@ -5,13 +5,13 @@
* Located in: src/dealer-claim/components/request-detail/ * Located in: src/dealer-claim/components/request-detail/
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react'; import { DollarSign, Download, CircleCheckBig, Target, CircleAlert } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi'; import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
@ -30,95 +30,87 @@ interface IOBlockedDetails {
blockedDate: string; blockedDate: string;
blockedBy: string; // User who blocked blockedBy: string; // User who blocked
sapDocumentNumber: string; sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed'; status: 'blocked' | 'released' | 'failed' | 'pending';
} }
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) { export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const { user } = useAuth(); const { user } = useAuth();
const requestId = apiRequest?.requestId || request?.requestId; const requestId = apiRequest?.requestId || request?.requestId;
// Load existing IO data from apiRequest or request
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
// Get organizer user object from association (organizer) or fallback to organizedBy UUID // Get organizer user object from association (organizer) or fallback to organizedBy UUID
const organizer = internalOrder?.organizer || null;
// Get estimated budget from proposal details
const proposalDetails = apiRequest?.proposalDetails || {}; const proposalDetails = apiRequest?.proposalDetails || {};
const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0); const claimDetails = apiRequest?.claimDetails || apiRequest || {};
const [ioNumber, setIoNumber] = useState(existingIONumber); // 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 [fetchingAmount, setFetchingAmount] = useState(false); const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null); const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>(''); const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null); const [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
const [blockingBudget, setBlockingBudget] = useState(false); const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO block details from apiRequest // Load existing IO blocks
useEffect(() => { useEffect(() => {
if (internalOrder && existingIONumber) { if (internalOrdersList.length > 0) {
// IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking const formattedIOs = internalOrdersList.map((io: any) => {
// We should NOT add blockedAmount to it - that would cause double deduction const org = io.organizer || null;
// Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block) const blockedByName = org?.displayName ||
const availableBeforeBlock = Number(existingAvailableBalance) || 0; org?.display_name ||
org?.name ||
// Get blocked by user name from organizer association (who blocked the amount) (org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) ||
// When amount is blocked, organizedBy stores the user who blocked it org?.email ||
const blockedByName = organizer?.displayName ||
organizer?.display_name ||
organizer?.name ||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
organizer?.email ||
'Unknown User'; 'Unknown User';
return {
// Set IO number from existing data ioNumber: io.ioNumber || io.io_number,
setIoNumber(existingIONumber); blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0),
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0),
// Only set blocked details if amount is blocked remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0),
if (existingBlockedAmount > 0) { blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
const blockedAmt = Number(existingBlockedAmount) || 0;
const backendRemaining = Number(existingRemainingBalance) || 0;
// Calculate expected remaining balance for validation/debugging
// Formula: remaining = availableBeforeBlock - blockedAmount
const expectedRemaining = availableBeforeBlock - blockedAmt;
// Loading existing IO block
// Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(backendRemaining - expectedRemaining) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
availableBalance: availableBeforeBlock,
blockedAmount: blockedAmt,
expectedRemaining,
backendRemaining,
difference: backendRemaining - expectedRemaining,
});
}
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: blockedAmt,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: backendRemaining, // Use backend calculated value
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName, blockedBy: blockedByName,
sapDocumentNumber: sapDocNumber, sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '',
status: (internalOrder.status === 'BLOCKED' ? 'blocked' : status: (io.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed', io.status === 'RELEASED' ? 'released' :
io.status === 'PENDING' ? 'pending' : 'blocked') as any,
};
}); });
setBlockedIOs(formattedIOs);
// Set fetched amount if available balance exists // If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience
if (availableBeforeBlock > 0) { if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) {
setFetchedAmount(availableBeforeBlock); setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
} }
} }
} }, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]);
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/** /**
* Fetch available budget from SAP * Fetch available budget from SAP
@ -143,12 +135,22 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
if (ioData.isValid && ioData.availableBalance > 0) { if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance); setFetchedAmount(ioData.availableBalance);
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
if (estimatedBudget > 0) { // Calculate total already blocked amount
setAmountToBlock(String(estimatedBudget)); 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)));
} else { } else {
setAmountToBlock(String(ioData.availableBalance)); setAmountToBlock(String(ioData.availableBalance.toFixed(2)));
} }
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
} else { } else {
toast.error('Invalid IO number or no available balance found'); toast.error('Invalid IO number or no available balance found');
@ -199,11 +201,18 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
return; return;
} }
// Validate that amount to block must exactly match estimated budget
// Calculate total already blocked
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
const totalPlanned = totalAlreadyBlocked + blockAmount;
// Validate that total planned must exactly match estimated budget
if (estimatedBudget > 0) { if (estimatedBudget > 0) {
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2)); const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) { const roundedTotalPlanned = parseFloat(totalPlanned.toFixed(2));
toast.error(`Amount to block must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`);
if (Math.abs(roundedTotalPlanned - roundedEstimatedBudget) > 0.01) {
toast.error(`Total blocked amount (₹${roundedTotalPlanned.toLocaleString('en-IN')}) must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN')})`);
return; return;
} }
} }
@ -279,8 +288,9 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
status: 'blocked', status: 'blocked',
}; };
setBlockedDetails(blocked); setBlockedIOs(prev => [...prev, blocked]);
setAmountToBlock(''); // Clear the input setAmountToBlock(''); // Clear the input
setFetchedAmount(null); // Reset fetched state
toast.success('IO budget blocked successfully in SAP'); toast.success('IO budget blocked successfully in SAP');
// Refresh request details // Refresh request details
@ -321,12 +331,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
placeholder="Enter IO number (e.g., IO-2024-12345)" placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber} value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)} onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || !!blockedDetails} disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
className="flex-1" className="flex-1"
/> />
<Button <Button
onClick={handleFetchAmount} onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails} disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
className="bg-[#2d4a3e] hover:bg-[#1f3329]" className="bg-[#2d4a3e] hover:bg-[#1f3329]"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
@ -336,7 +346,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</div> </div>
{/* Instructions when IO number is entered but not fetched */} {/* Instructions when IO number is entered but not fetched */}
{!fetchedAmount && !blockedDetails && ioNumber.trim() && ( {!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800"> <p className="text-sm text-blue-800">
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP. <strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
@ -345,7 +355,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
)} )}
{/* Fetched Amount Display */} {/* Fetched Amount Display */}
{fetchedAmount !== null && !blockedDetails && ( {fetchedAmount !== null && (blockedIOs.length === 0 || isAdditionalBlockingNeeded) && (
<> <>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4"> <div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -396,7 +406,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
!amountToBlock || !amountToBlock ||
parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount || parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01) (estimatedBudget > 0 && Math.abs((blockedIOs.reduce((s, i) => s + i.blockedAmount, 0) + parseFloat(amountToBlock)) - estimatedBudget) > 0.01)
} }
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]" className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
> >
@ -420,71 +430,57 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{blockedDetails ? ( {blockedIOs.length > 0 ? (
<div className="space-y-4"> <div className="space-y-6">
{/* Success Banner */} {isAdditionalBlockingNeeded && (
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4"> <div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" /> <CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="font-semibold text-green-900">IO Blocked Successfully</p> <p className="font-semibold text-amber-900">Additional Budget Blocking Required</p>
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</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> </div>
</div> </div>
</div> </div>
)}
{/* Blocked Details */} {blockedIOs.slice().reverse().map((io, idx) => (
<div className="border rounded-lg divide-y"> <div key={idx} className="border rounded-lg overflow-hidden">
<div className="p-4"> <div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p> <span className="font-semibold text-sm">IO: {io.ioNumber}</span>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p> <Badge className={
</div> io.status === 'blocked' ? 'bg-green-100 text-green-800' :
<div className="p-4"> io.status === 'pending' ? 'bg-amber-100 text-amber-800' :
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p> 'bg-blue-100 text-blue-800'
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p> }>
</div> {io.status === 'blocked' ? 'Blocked' :
<div className="p-4 bg-green-50"> io.status === 'pending' ? 'Provisioned' : 'Released'}
<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> </Badge>
</div> </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>
</div>
))}
<div className="mt-4 p-4 bg-[#2d4a3e] text-white rounded-lg flex justify-between items-center">
<span className="font-bold">Total Blocked:</span>
<span className="text-xl font-bold">{blockedIOs.reduce((s, i) => s + i.blockedAmount, 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -44,7 +44,6 @@ interface ClaimManagementOverviewTabProps {
generationAttempts?: number; generationAttempts?: number;
generationFailed?: boolean; generationFailed?: boolean;
maxAttemptsReached?: boolean; maxAttemptsReached?: boolean;
isClosed?: boolean;
} }
export function ClaimManagementOverviewTab({ export function ClaimManagementOverviewTab({
@ -65,7 +64,6 @@ export function ClaimManagementOverviewTab({
generationAttempts = 0, generationAttempts = 0,
generationFailed = false, generationFailed = false,
maxAttemptsReached = false, maxAttemptsReached = false,
isClosed = false,
}: ClaimManagementOverviewTabProps) { }: ClaimManagementOverviewTabProps) {
// Check if this is a claim management request // Check if this is a claim management request
if (!isClaimManagementRequest(apiRequest)) { if (!isClaimManagementRequest(apiRequest)) {
@ -138,7 +136,7 @@ export function ClaimManagementOverviewTab({
<RequestInitiatorCard initiatorInfo={initiatorInfo} /> <RequestInitiatorCard initiatorInfo={initiatorInfo} />
{/* Closed Request Conclusion Remark Display */} {/* Closed Request Conclusion Remark Display */}
{isClosed && apiRequest?.conclusionRemark && ( {apiRequest?.status === 'closed' && apiRequest?.conclusionRemark && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@ -168,15 +166,18 @@ export function ClaimManagementOverviewTab({
{/* Conclusion Remark Section - Closure Setup */} {/* Conclusion Remark Section - Closure Setup */}
{needsClosure && ( {needsClosure && (
<Card data-testid="conclusion-remark-card"> <Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${(apiRequest?.status || '').toLowerCase() === 'rejected' <CardHeader className={`bg-gradient-to-r border-b ${
(apiRequest?.status || '').toLowerCase() === 'rejected'
? 'from-red-50 to-rose-50 border-red-200' ? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 border-green-200' : 'from-green-50 to-emerald-50 border-green-200'
}`}> }`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700' <CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}> }`}>
<CheckCircle className={`w-5 h-5 ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600' <CheckCircle className={`w-5 h-5 ${
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} /> }`} />
Conclusion Remark - Final Step Conclusion Remark - Final Step
</CardTitle> </CardTitle>

View File

@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw, Eye } from 'lucide-react'; import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw, Eye, FileSpreadsheet, X, Loader2 } from 'lucide-react';
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { formatHoursMinutes } from '@/utils/slaTracker'; import { formatHoursMinutes } from '@/utils/slaTracker';
import { import {
@ -29,6 +29,7 @@ import { toast } from 'sonner';
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice, sendCreditNoteToDealer } from '@/services/dealerClaimApi'; import { submitProposal, updateIODetails, submitCompletion, updateEInvoice, sendCreditNoteToDealer } from '@/services/dealerClaimApi';
import { getWorkflowDetails, approveLevel, rejectLevel, handleInitiatorAction, getWorkflowHistory } from '@/services/workflowApi'; import { getWorkflowDetails, approveLevel, rejectLevel, handleInitiatorAction, getWorkflowHistory } from '@/services/workflowApi';
import { uploadDocument } from '@/services/documentApi'; import { uploadDocument } from '@/services/documentApi';
import { TokenManager } from '@/utils/tokenManager';
interface DealerClaimWorkflowTabProps { interface DealerClaimWorkflowTabProps {
request: any; request: any;
@ -48,7 +49,7 @@ interface WorkflowStep {
approver: string; approver: string;
description: string; description: string;
tatHours: number; tatHours: number;
status: 'pending' | 'approved' | 'waiting' | 'rejected' | 'in_progress'; status: 'pending' | 'approved' | 'waiting' | 'rejected' | 'in_progress' | 'skipped';
comment?: string; comment?: string;
approvedAt?: string; approvedAt?: string;
elapsedHours?: number; elapsedHours?: number;
@ -69,6 +70,7 @@ interface WorkflowStep {
}; };
einvoiceUrl?: string; einvoiceUrl?: string;
emailTemplateUrl?: string; emailTemplateUrl?: string;
levelName?: string;
versionHistory?: { versionHistory?: {
current: any; current: any;
previous: any; previous: any;
@ -107,8 +109,8 @@ const getStepIcon = (status: string) => {
switch (status) { switch (status) {
case 'approved': case 'approved':
return <CircleCheckBig className="w-5 h-5 text-green-600" />; return <CircleCheckBig className="w-5 h-5 text-green-600" />;
case 'in_progress': case 'skipped':
return <RotateCw className="w-5 h-5 text-purple-600 animate-spin-slow" />; return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'pending': case 'pending':
return <Clock className="w-5 h-5 text-blue-600" />; return <Clock className="w-5 h-5 text-blue-600" />;
case 'rejected': case 'rejected':
@ -125,8 +127,8 @@ const getStepBadgeVariant = (status: string) => {
switch (status) { switch (status) {
case 'approved': case 'approved':
return 'bg-green-100 text-green-800 border-green-200'; return 'bg-green-100 text-green-800 border-green-200';
case 'in_progress': case 'skipped':
return 'bg-blue-100 text-blue-800 border-blue-200'; return 'bg-green-50 text-green-700 border-green-200';
case 'pending': case 'pending':
return 'bg-purple-100 text-purple-800 border-purple-200'; return 'bg-purple-100 text-purple-800 border-purple-200';
case 'rejected': case 'rejected':
@ -143,7 +145,7 @@ const getStepCardStyle = (status: string, isActive: boolean) => {
if (isActive && (status === 'pending' || status === 'in_progress')) { if (isActive && (status === 'pending' || status === 'in_progress')) {
return 'border-purple-500 bg-purple-50 shadow-md'; return 'border-purple-500 bg-purple-50 shadow-md';
} }
if (status === 'approved') { if (status === 'approved' || status === 'skipped') {
return 'border-green-500 bg-green-50'; return 'border-green-500 bg-green-50';
} }
if (status === 'rejected') { if (status === 'rejected') {
@ -159,8 +161,8 @@ const getStepIconBg = (status: string) => {
switch (status) { switch (status) {
case 'approved': case 'approved':
return 'bg-green-100'; return 'bg-green-100';
case 'in_progress': case 'skipped':
return 'bg-blue-100'; return 'bg-green-100';
case 'pending': case 'pending':
return 'bg-purple-100'; return 'bg-purple-100';
case 'rejected': case 'rejected':
@ -193,6 +195,9 @@ export function DealerClaimWorkflowTab({
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set()); const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set());
const [viewSnapshot, setViewSnapshot] = useState<{ data: any, type: 'PROPOSAL' | 'COMPLETION', title?: string } | null>(null); const [viewSnapshot, setViewSnapshot] = useState<{ data: any, type: 'PROPOSAL' | 'COMPLETION', title?: string } | null>(null);
const [invoicePdfUrl, setInvoicePdfUrl] = useState<string | null>(null);
const [showInvoicePdfModal, setShowInvoicePdfModal] = useState(false);
const [invoicePdfLoading, setInvoicePdfLoading] = useState(false);
// Load approval flows from real API // Load approval flows from real API
const [approvalFlow, setApprovalFlow] = useState<any[]>([]); const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
@ -335,19 +340,31 @@ export function DealerClaimWorkflowTab({
// Step title and description mapping based on actual step number (not array index) // Step title and description mapping based on actual step number (not array index)
// This handles cases where approvers are added between steps // This handles cases where approvers are added between steps
const getStepTitle = (stepNumber: number, levelName?: string, approverName?: string): string => { const getStepTitle = (stepNumber: number, levelName?: string, approverName?: string): string => {
// Check if this is a legacy workflow (8 steps) or new workflow (5 steps)
// Legacy flows have system steps (Activity, E-Invoice, Credit Note) as approval levels
const isLegacyFlow = (request?.totalLevels || 0) > 5 || (request?.approvalLevels?.length || 0) > 5;
// Use levelName from backend if available (most accurate) // Use levelName from backend if available (most accurate)
// Check if it's an "Additional Approver" - this indicates a dynamically added approver // Check if it's an "Additional Approver" - this indicates a dynamically added approver
if (levelName && levelName.trim()) { if (levelName && levelName.trim()) {
const levelNameLower = levelName.toLowerCase();
// If it starts with "Additional Approver", use it as-is (it's already formatted) // If it starts with "Additional Approver", use it as-is (it's already formatted)
if (levelName.toLowerCase().includes('additional approver')) { if (levelNameLower.includes('additional approver')) {
return levelName;
}
// Otherwise use the levelName from backend (preserved from original step)
return levelName; return levelName;
} }
// Fallback to mapping based on step number // If levelName is NOT generic "Step X", return it
const stepTitleMap: Record<number, string> = { // This fixes the issue where backend sends "Step 1" instead of "Dealer Proposal Submission"
if (!/^step\s+\d+$/i.test(levelName)) {
return levelName;
}
}
// Fallback to mapping based on step number and flow version
const stepTitleMap: Record<number, string> = isLegacyFlow
? {
// Legacy 8-step flow
1: 'Dealer - Proposal Submission', 1: 'Dealer - Proposal Submission',
2: 'Requestor Evaluation & Confirmation', 2: 'Requestor Evaluation & Confirmation',
3: 'Department Lead Approval', 3: 'Department Lead Approval',
@ -356,6 +373,16 @@ export function DealerClaimWorkflowTab({
6: 'Requestor - Claim Approval', 6: 'Requestor - Claim Approval',
7: 'E-Invoice Generation', 7: 'E-Invoice Generation',
8: 'Credit Note from SAP', 8: 'Credit Note from SAP',
}
: {
// New 5-step flow
1: 'Dealer - Proposal Submission',
2: 'Requestor Evaluation & Confirmation',
3: 'Department Lead Approval',
4: 'Dealer - Completion Documents',
5: 'Requestor - Claim Approval',
6: 'E-Invoice Generation',
7: 'Credit Note from SAP',
}; };
// If step number exists in map, use it // If step number exists in map, use it
@ -383,6 +410,9 @@ export function DealerClaimWorkflowTab({
return `Additional approver will review and approve this request.`; return `Additional approver will review and approve this request.`;
} }
// Check if this is a legacy workflow (8 steps) or new workflow (5 steps)
const isLegacyFlow = (request?.totalLevels || 0) > 5 || (request?.approvalLevels?.length || 0) > 5;
// Use levelName to determine description (handles shifted steps correctly) // Use levelName to determine description (handles shifted steps correctly)
// This ensures descriptions shift with their steps when approvers are added // This ensures descriptions shift with their steps when approvers are added
if (levelName && levelName.trim()) { if (levelName && levelName.trim()) {
@ -398,6 +428,7 @@ export function DealerClaimWorkflowTab({
if (levelNameLower.includes('department lead')) { if (levelNameLower.includes('department lead')) {
return 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)'; return 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)';
} }
// Re-added for legacy support
if (levelNameLower.includes('activity creation')) { if (levelNameLower.includes('activity creation')) {
return 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.'; return 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.';
} }
@ -407,24 +438,36 @@ export function DealerClaimWorkflowTab({
if (levelNameLower.includes('requestor') && (levelNameLower.includes('claim') || levelNameLower.includes('approval'))) { if (levelNameLower.includes('requestor') && (levelNameLower.includes('claim') || levelNameLower.includes('approval'))) {
return 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.'; return 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.';
} }
if (levelNameLower.includes('e-invoice') || levelNameLower.includes('invoice generation')) { if (levelNameLower.includes('e-invoice') || levelNameLower.includes('invoice generation') || levelNameLower.includes('dms')) {
return 'E-invoice will be generated through DMS.'; return 'E-Invoice will be generated upon settlement initiation.';
} }
if (levelNameLower.includes('credit note') || levelNameLower.includes('sap')) { if (levelNameLower.includes('credit note') || levelNameLower.includes('sap')) {
return 'Got credit note from SAP. Review and send to dealer to complete the claim management process.'; return 'Got credit note from SAP. Review and send to dealer to complete the claim management process.';
} }
} }
// Fallback to step number mapping (for backwards compatibility) // Fallback to step number mapping depending on flow version
const stepDescriptionMap: Record<number, string> = { const stepDescriptionMap: Record<number, string> = isLegacyFlow
? {
// Legacy 8-step flow
1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests', 1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)', 2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)', 3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
4: 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.', 4: 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
5: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description', 5: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
6: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.', 6: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
7: 'E-invoice will be generated through DMS.', 7: 'E-Invoice will be generated upon settlement initiation.',
8: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.', 8: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
}
: {
// New 5-step flow
1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
4: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
5: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
6: 'E-Invoice will be generated upon settlement initiation.',
7: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
}; };
if (stepDescriptionMap[stepNumber]) { if (stepDescriptionMap[stepNumber]) {
@ -633,6 +676,8 @@ export function DealerClaimWorkflowTab({
const approvalStatus = approval.status.toLowerCase(); const approvalStatus = approval.status.toLowerCase();
if (approvalStatus === 'approved') { if (approvalStatus === 'approved') {
normalizedStatus = 'approved'; normalizedStatus = 'approved';
} else if (approvalStatus === 'skipped') {
normalizedStatus = 'skipped';
} else if (approvalStatus === 'rejected') { } else if (approvalStatus === 'rejected') {
normalizedStatus = 'rejected'; normalizedStatus = 'rejected';
} else { } else {
@ -731,7 +776,7 @@ export function DealerClaimWorkflowTab({
// Note: Status normalization already handled in workflowSteps mapping above // Note: Status normalization already handled in workflowSteps mapping above
// backendCurrentLevel is already calculated above before the map function // backendCurrentLevel is already calculated above before the map function
// CRITICAL: If request is rejected or closed, no step should be active //: If request is rejected or closed, no step should be active
let activeStep = null; let activeStep = null;
let currentStep = 1; let currentStep = 1;
@ -851,13 +896,29 @@ export function DealerClaimWorkflowTab({
await uploadDocument(file, requestId, 'SUPPORTING'); await uploadDocument(file, requestId, 'SUPPORTING');
} }
// Submit proposal using dealer claim API // Submit proposal using dealer claim API (calculate total from inclusive item totals)
const totalBudget = data.costBreakup.reduce((sum, item) => sum + item.amount, 0); const totalBudget = data.costBreakup.reduce((sum, item: any) => sum + (item.totalAmt || item.amount || 0), 0);
await submitProposal(requestId, { await submitProposal(requestId, {
proposalDocument: data.proposalDocument || undefined, proposalDocument: data.proposalDocument || undefined,
costBreakup: data.costBreakup.map(item => ({ costBreakup: data.costBreakup.map((item: any) => ({
description: item.description, description: item.description,
amount: item.amount, amount: item.amount,
gstRate: item.gstRate,
gstAmt: item.gstAmt,
cgstRate: item.cgstRate,
cgstAmt: item.cgstAmt,
sgstRate: item.sgstRate,
sgstAmt: item.sgstAmt,
igstRate: item.igstRate,
igstAmt: item.igstAmt,
utgstRate: item.utgstRate,
utgstAmt: item.utgstAmt,
cessRate: item.cessRate,
cessAmt: item.cessAmt,
totalAmt: item.totalAmt,
quantity: item.quantity,
hsnCode: item.hsnCode,
isService: item.isService
})), })),
totalEstimatedBudget: totalBudget, totalEstimatedBudget: totalBudget,
expectedCompletionDate: data.expectedCompletionDate, expectedCompletionDate: data.expectedCompletionDate,
@ -1041,6 +1102,45 @@ export function DealerClaimWorkflowTab({
} }
}; };
// Handle re-quotation request from Claim Approval step (Step 6)
const handleClaimReQuotation = async (comments: string) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Get workflow details to find the Requestor Claim Approval levelId dynamically
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
// Find the Requestor Claim Approval step
const claimApprovalLevel = approvals.find((level: any) => {
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('requestor claim') || levelName.includes('requestor - claim');
});
if (!claimApprovalLevel?.levelId && !claimApprovalLevel?.level_id) {
throw new Error('Claim approval level not found');
}
const levelId = claimApprovalLevel.levelId || claimApprovalLevel.level_id;
// Reject the claim approval step with 'Revised Quotation Requested'
// This will trigger the backend to return the workflow to the Dealer Proposal step
await rejectLevel(requestId, levelId, 'Revised Quotation Requested', comments);
toast.success('Re-quotation requested. Request returned to dealer.');
handleRefresh();
} catch (error: any) {
console.error('Failed to request re-quotation:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to request re-quotation. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Handle IO approval (Department Lead step - found dynamically) // Handle IO approval (Department Lead step - found dynamically)
const handleIOApproval = async (data: { const handleIOApproval = async (data: {
ioNumber: string; ioNumber: string;
@ -1112,10 +1212,26 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Transform expense items to match API format // Transform expense items to match API format (include GST fields)
const closedExpenses = data.closedExpenses.map(item => ({ const closedExpenses = data.closedExpenses.map((item: any) => ({
description: item.description, description: item.description,
amount: item.amount, amount: item.amount,
gstRate: item.gstRate,
gstAmt: item.gstAmt,
cgstRate: item.cgstRate,
cgstAmt: item.cgstAmt,
sgstRate: item.sgstRate,
sgstAmt: item.sgstAmt,
igstRate: item.igstRate,
igstAmt: item.igstAmt,
utgstRate: item.utgstRate,
utgstAmt: item.utgstAmt,
cessRate: item.cessRate,
cessAmt: item.cessAmt,
totalAmt: item.totalAmt,
quantity: item.quantity,
hsnCode: item.hsnCode,
isService: item.isService
})); }));
// Submit completion documents using dealer claim API // Submit completion documents using dealer claim API
@ -1151,7 +1267,7 @@ export function DealerClaimWorkflowTab({
} }
}; };
// Handle DMS push (Step 6) // Handle E-Invoice generation (Step 6)
const handleDMSPush = async (_comments: string) => { const handleDMSPush = async (_comments: string) => {
try { try {
if (!request?.id && !request?.requestId) { if (!request?.id && !request?.requestId) {
@ -1168,11 +1284,18 @@ export function DealerClaimWorkflowTab({
}); });
// Activity is logged by backend service - no need to create work note // Activity is logged by backend service - no need to create work note
toast.success('Pushed to DMS successfully. E-invoice will be generated automatically.'); toast.success('E-Invoice generation initiated successfully.');
handleRefresh(); handleRefresh();
} catch (error: any) { } catch (error: any) {
console.error('[DealerClaimWorkflowTab] Error pushing to DMS:', error); console.error('[DealerClaimWorkflowTab] Error generating e-invoice:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to push to DMS. Please try again.'; // Backend now translates PWC error codes to user-friendly messages in 'message' field
// Prefer 'message' (user-friendly) over 'error' (raw technical details)
const responseMessage = error?.response?.data?.message;
let errorMessage = responseMessage || error?.message || 'E-Invoice generation failed. Please try again.';
// Truncate very long error messages for toast display (keep first 300 chars)
if (errorMessage.length > 300) {
errorMessage = errorMessage.substring(0, 300) + '...';
}
toast.error(errorMessage); toast.error(errorMessage);
throw error; throw error;
} }
@ -1387,10 +1510,115 @@ export function DealerClaimWorkflowTab({
loadCompletionDocuments(); loadCompletionDocuments();
}, [request]); }, [request]);
const handleDownloadCSV = async () => {
try {
const requestId = request.id || request.requestId;
if (!requestId) {
toast.error('Request ID not found');
return;
}
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
const response = await fetch(`${baseUrl}/dealer-claims/${requestId}/e-invoice/csv`, {
headers: {
'Authorization': `Bearer ${TokenManager.getAccessToken()}`
}
});
if (!response.ok) {
throw new Error('Failed to download CSV');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Invoice_${request.requestNumber || 'Export'}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('CSV downloaded successfully');
} catch (error) {
console.error('Error downloading CSV:', error);
toast.error('Failed to download CSV');
}
};
const handlePreviewInvoice = async () => {
try {
const requestId = request.id || request.requestId;
if (!requestId) {
toast.error('Request ID not found');
return;
}
// Check if invoice exists
if (!request.invoice && !request.irn) {
toast.error('Invoice not generated yet');
return;
}
setInvoicePdfLoading(true);
setShowInvoicePdfModal(true);
// Fetch PDF securely via Authorization header (not in URL query)
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
const response = await fetch(`${baseUrl}/dealer-claims/${requestId}/e-invoice/pdf`, {
headers: {
'Authorization': `Bearer ${TokenManager.getAccessToken()}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch invoice PDF');
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
// Revoke previous blob URL to prevent memory leaks
if (invoicePdfUrl) {
window.URL.revokeObjectURL(invoicePdfUrl);
}
setInvoicePdfUrl(blobUrl);
} catch (error) {
console.error('Failed to preview invoice:', error);
toast.error('Failed to load invoice preview');
setShowInvoicePdfModal(false);
} finally {
setInvoicePdfLoading(false);
}
};
const handleCloseInvoicePdf = () => {
setShowInvoicePdfModal(false);
if (invoicePdfUrl) {
window.URL.revokeObjectURL(invoicePdfUrl);
setInvoicePdfUrl(null);
}
};
const handleDownloadInvoicePdf = () => {
if (invoicePdfUrl) {
const a = document.createElement('a');
a.href = invoicePdfUrl;
a.download = `Invoice_${request.requestNumber || 'Download'}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast.success('Invoice PDF downloaded');
}
};
// Get dealer and activity info // Get dealer and activity info
const dealerName = request?.claimDetails?.dealerName || const dealerName = request?.claimDetails?.dealerName ||
request?.dealerInfo?.name || request?.dealerInfo?.name ||
'Dealer'; 'Dealer';
const dealerGSTIN = request?.claimDetails?.dealerGstin ||
request?.dealerInfo?.gstin ||
request?.dealerInfo?.dealerGSTIN;
const activityName = request?.claimDetails?.activityName || const activityName = request?.claimDetails?.activityName ||
request?.activityInfo?.activityName || request?.activityInfo?.activityName ||
request?.title || request?.title ||
@ -1455,7 +1683,7 @@ export function DealerClaimWorkflowTab({
// - AND it matches the current step // - AND it matches the current step
// - AND is pending/in_progress // - AND is pending/in_progress
const isActive = isRequestActive && isPendingOrInProgress && matchesCurrentStep; const isActive = isRequestActive && isPendingOrInProgress && matchesCurrentStep;
const isCompleted = step.status === 'approved'; const isCompleted = step.status === 'approved' || step.status === 'skipped';
// Find approval data for this step to get SLA information // Find approval data for this step to get SLA information
// First find the corresponding level in approvalFlow to get levelId // First find the corresponding level in approvalFlow to get levelId
@ -1519,6 +1747,40 @@ export function DealerClaimWorkflowTab({
<Download className="w-3.5 h-3.5 text-green-600" /> <Download className="w-3.5 h-3.5 text-green-600" />
</Button> </Button>
)} )}
{/* Invoice Preview Button (Requestor Claim Approval) */}
{(() => {
const isRequestorClaimStep = (step.levelName || step.title || '').toLowerCase().includes('requestor claim') ||
(step.levelName || step.title || '').toLowerCase().includes('requestor - claim');
const hasInvoice = request?.invoice || (request?.irn && step.status === 'approved');
return isRequestorClaimStep && hasInvoice && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-amber-100"
title="Preview Invoice"
onClick={handlePreviewInvoice}
>
<Receipt className="w-3.5 h-3.5 text-amber-600" />
</Button>
);
})()}
{/* CSV Export Button (Requestor Claim Approval) */}
{(() => {
const isRequestorClaimStep = (step.levelName || step.title || '').toLowerCase().includes('requestor claim') ||
(step.levelName || step.title || '').toLowerCase().includes('requestor - claim');
const hasInvoice = request?.invoice || (request?.irn && step.status === 'approved');
return isRequestorClaimStep && hasInvoice && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-emerald-100 ml-1"
title="Export CSV"
onClick={handleDownloadCSV}
>
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-600" />
</Button>
);
})()}
</div> </div>
<p className="text-sm text-gray-600">{step.approver}</p> <p className="text-sm text-gray-600">{step.approver}</p>
<p className="text-sm text-gray-500 mt-2 italic">{step.description}</p> <p className="text-sm text-gray-500 mt-2 italic">{step.description}</p>
@ -1862,25 +2124,25 @@ export function DealerClaimWorkflowTab({
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" /> <Activity className="w-4 h-4 text-purple-600" />
<p className="text-xs font-semibold text-purple-900 uppercase tracking-wide"> <p className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
DMS Processing Details E-Invoice & Settlement Details
</p> </p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-gray-600">DMS Number:</span> <span className="text-xs text-gray-600">Settlement ID:</span>
<span className="text-sm font-semibold text-gray-900"> <span className="text-sm font-semibold text-gray-900">
{step.dmsDetails.dmsNumber} {step.dmsDetails.dmsNumber}
</span> </span>
</div> </div>
{step.dmsDetails.dmsRemarks && ( {step.dmsDetails.dmsRemarks && (
<div className="pt-1.5 border-t border-purple-100"> <div className="pt-1.5 border-t border-purple-100">
<p className="text-xs text-gray-600 mb-1">DMS Remarks:</p> <p className="text-xs text-gray-600 mb-1">Settlement Remarks:</p>
<p className="text-sm text-gray-900">{step.dmsDetails.dmsRemarks}</p> <p className="text-sm text-gray-900">{step.dmsDetails.dmsRemarks}</p>
</div> </div>
)} )}
{step.dmsDetails.pushedAt && ( {step.dmsDetails.pushedAt && (
<div className="pt-1.5 border-t border-purple-100 text-xs text-gray-500"> <div className="pt-1.5 border-t border-purple-100 text-xs text-gray-500">
Pushed by {step.dmsDetails.pushedBy} on{' '} Initiated by {step.dmsDetails.pushedBy} on{' '}
{formatDateSafe(step.dmsDetails.pushedAt)} {formatDateSafe(step.dmsDetails.pushedAt)}
</div> </div>
)} )}
@ -1908,7 +2170,16 @@ export function DealerClaimWorkflowTab({
})() && ( })() && (
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
{/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */} {/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */}
{step.step === 1 && (isDealer || isStep1Approver) && ( {(() => {
// Check if this is Step 1 (Dealer Proposal Submission)
// Use levelName match or fallback to step 1
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
const isProposalStep = step.step === 1 ||
levelName.includes('proposal') ||
levelName.includes('submission');
return isProposalStep && (isDealer || isStep1Approver);
})() && (
<Button <Button
className="bg-purple-600 hover:bg-purple-700" className="bg-purple-600 hover:bg-purple-700"
onClick={() => { onClick={() => {
@ -1924,7 +2195,16 @@ export function DealerClaimWorkflowTab({
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */} {/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */} {/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */} {/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
{step.step === initiatorStepNumber && (isInitiator || isStep2Approver) && ( {/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
{(() => {
// Check if this is the Requestor Evaluation step
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
const isEvaluationStep = levelName.includes('requestor evaluation') ||
levelName.includes('confirmation') ||
step.step === initiatorStepNumber; // Fallback
return isEvaluationStep && (isInitiator || isStep2Approver);
})() && (
<Button <Button
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
onClick={() => { onClick={() => {
@ -2086,20 +2366,26 @@ export function DealerClaimWorkflowTab({
}} }}
> >
<Activity className="w-4 h-4 mr-2" /> <Activity className="w-4 h-4 mr-2" />
Push to DMS Generate E-Invoice & Sync
</Button> </Button>
); );
})()} })()}
{/* Step 8: View & Send Credit Note - Only for finance approver or step 8 approver */} {/* Step 8: View & Send Credit Note - Only for finance approver or step 8 approver */}
{step.step === 8 && (() => { {(() => {
const step8Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 8); const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
const step8ApproverEmail = (step8Level?.approverEmail || '').toLowerCase(); // Check for "Credit Note" or "SAP" in level name, or fallback to step 8 if it's the last step
const isStep8Approver = step8ApproverEmail && userEmail === step8ApproverEmail; const isCreditNoteStep = levelName.includes('credit note') ||
levelName.includes('sap') ||
(step.step === 8 && !levelName.includes('additional'));
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
const isStepApprover = stepApproverEmail && userEmail === stepApproverEmail;
// Also check if user has finance role // Also check if user has finance role
const userRole = (user as any)?.role?.toUpperCase() || ''; const userRole = (user as any)?.role?.toUpperCase() || '';
const isFinanceUser = userRole === 'FINANCE' || userRole === 'ADMIN'; const isFinanceUser = userRole === 'FINANCE' || userRole === 'ADMIN';
return isStep8Approver || isFinanceUser;
return isCreditNoteStep && (isStepApprover || isFinanceUser);
})() && ( })() && (
<Button <Button
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700"
@ -2119,35 +2405,21 @@ export function DealerClaimWorkflowTab({
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase(); const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
const isAdditionalApprover = levelName.includes('additional approver'); const isAdditionalApprover = levelName.includes('additional approver');
// Check if this step doesn't have any of the specific workflow action buttons above
// Check if this step doesn't have any of the specific workflow action buttons above // Check if this step doesn't have any of the specific workflow action buttons above
const hasSpecificWorkflowAction = const hasSpecificWorkflowAction =
step.step === 1 || // Proposal
step.step === initiatorStepNumber || (step.step === 1 || levelName.includes('proposal') || levelName.includes('submission')) ||
(() => { // Evaluation
const deptLeadStepLevel = approvalFlow.find((l: any) => { (levelName.includes('requestor evaluation') || levelName.includes('confirmation')) ||
const ln = (l.levelName || '').toLowerCase(); // Dept Lead
return ln.includes('department lead'); levelName.includes('department lead') ||
}); // Dealer Completion
return deptLeadStepLevel && (levelName.includes('dealer completion') || levelName.includes('completion documents')) ||
(step.step === (deptLeadStepLevel.step || deptLeadStepLevel.levelNumber || deptLeadStepLevel.level_number)); // Requestor Claim
})() || (levelName.includes('requestor claim') || levelName.includes('requestor - claim')) ||
(() => { // Credit Note
const stepLevel = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step); (levelName.includes('credit note') || levelName.includes('sap'));
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
const isDealerForThisStep = isDealer && stepApproverEmail === dealerEmail;
const ln = (stepLevel?.levelName || step.title || '').toLowerCase();
const isDealerCompletionStep = ln.includes('dealer completion') || ln.includes('completion documents');
return isDealerForThisStep && isDealerCompletionStep;
})() ||
(() => {
const requestorClaimStepLevel = approvalFlow.find((l: any) => {
const ln = (l.levelName || '').toLowerCase();
return ln.includes('requestor claim') || ln.includes('requestor - claim');
});
return requestorClaimStepLevel &&
(step.step === (requestorClaimStepLevel.step || requestorClaimStepLevel.levelNumber || requestorClaimStepLevel.level_number));
})() ||
step.step === 8;
// Show "Review Request" button for additional approvers or steps without specific workflow actions // Show "Review Request" button for additional approvers or steps without specific workflow actions
// Similar to the requestor approval step // Similar to the requestor approval step
@ -2256,10 +2528,14 @@ export function DealerClaimWorkflowTab({
onClose={() => setShowProposalModal(false)} onClose={() => setShowProposalModal(false)}
onSubmit={handleProposalSubmit} onSubmit={handleProposalSubmit}
dealerName={dealerName} dealerName={dealerName}
dealerGSTIN={dealerGSTIN}
activityName={activityName} activityName={activityName}
defaultGstRate={request?.claimDetails?.defaultGstRate}
requestId={request?.id || request?.requestId} requestId={request?.id || request?.requestId}
previousProposalData={versionHistory?.find(v => v.snapshotType === 'PROPOSAL')?.snapshotData} previousProposalData={versionHistory?.find(v => v.snapshotType === 'PROPOSAL')?.snapshotData}
documentPolicy={documentPolicy} documentPolicy={documentPolicy}
taxationType={request?.claimDetails?.taxationType}
totalBlockedAmount={(request?.internalOrders || []).reduce((sum: number, io: any) => sum + (Number(io.ioBlockedAmount || io.io_blocked_amount || io.blockedAmount || 0)), 0)}
/> />
{/* Initiator Proposal Approval Modal */} {/* Initiator Proposal Approval Modal */}
@ -2283,6 +2559,7 @@ export function DealerClaimWorkflowTab({
// proposalSnapshots[1] is the previous proposal (last iteration - 1) // proposalSnapshots[1] is the previous proposal (last iteration - 1)
return proposalSnapshots.length > 1 ? proposalSnapshots[1].snapshotData : null; return proposalSnapshots.length > 1 ? proposalSnapshots[1].snapshotData : null;
})()} })()}
taxationType={request?.claimDetails?.taxationType}
/> />
{/* Dept Lead IO Approval Modal */} {/* Dept Lead IO Approval Modal */}
@ -2296,6 +2573,7 @@ export function DealerClaimWorkflowTab({
preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined} preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined}
preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined} preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined}
preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined} preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined}
taxationType={request?.claimDetails?.taxationType}
/> />
{/* Dealer Completion Documents Modal */} {/* Dealer Completion Documents Modal */}
@ -2304,9 +2582,12 @@ export function DealerClaimWorkflowTab({
onClose={() => setShowCompletionModal(false)} onClose={() => setShowCompletionModal(false)}
onSubmit={handleCompletionSubmit} onSubmit={handleCompletionSubmit}
dealerName={dealerName} dealerName={dealerName}
dealerGSTIN={dealerGSTIN}
activityName={activityName} activityName={activityName}
defaultGstRate={request?.claimDetails?.defaultGstRate}
requestId={request?.id || request?.requestId} requestId={request?.id || request?.requestId}
documentPolicy={documentPolicy} documentPolicy={documentPolicy}
taxationType={request?.claimDetails?.taxationType}
/> />
{/* DMS Push Modal */} {/* DMS Push Modal */}
@ -2330,14 +2611,16 @@ export function DealerClaimWorkflowTab({
completionDocuments={completionDocumentsData} completionDocuments={completionDocumentsData}
requestTitle={request?.title} requestTitle={request?.title}
requestNumber={request?.requestNumber || request?.request_number || request?.id} requestNumber={request?.requestNumber || request?.request_number || request?.id}
taxationType={request?.claimDetails?.taxationType}
onReQuotation={handleClaimReQuotation}
/> />
{/* Credit Note from SAP Modal (Step 8) */} {/* Credit Note from SAP Modal (Step 8) */}
<CreditNoteSAPModal <CreditNoteSAPModal
isOpen={showCreditNoteModal} isOpen={showCreditNoteModal}
onClose={() => setShowCreditNoteModal(false)} onClose={() => setShowCreditNoteModal(false)}
taxationType={request?.claimDetails?.taxationType}
onDownload={async () => { onDownload={async () => {
// TODO: Implement download functionality
toast.info('Download functionality will be implemented'); toast.info('Download functionality will be implemented');
}} }}
onSendToDealer={async () => { onSendToDealer={async () => {
@ -2408,7 +2691,7 @@ export function DealerClaimWorkflowTab({
stepNumber={selectedStepForEmail?.stepNumber || 4} stepNumber={selectedStepForEmail?.stepNumber || 4}
stepName={selectedStepForEmail?.stepName || 'Activity Creation'} stepName={selectedStepForEmail?.stepName || 'Activity Creation'}
requestNumber={request?.requestNumber || request?.id || request?.request_number} requestNumber={request?.requestNumber || request?.id || request?.request_number}
recipientEmail="system@royalenfield.com" recipientEmail={`system@${import.meta.env.VITE_EMAIL_DOMAIN}`}
/> />
{/* Additional Approver Review Modal */} {/* Additional Approver Review Modal */}
@ -2642,6 +2925,65 @@ export function DealerClaimWorkflowTab({
type={viewSnapshot?.type || 'PROPOSAL'} type={viewSnapshot?.type || 'PROPOSAL'}
title={viewSnapshot?.title} title={viewSnapshot?.title}
/> />
{/* Invoice PDF Viewer Modal */}
{showInvoicePdfModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={handleCloseInvoicePdf} />
{/* Modal */}
<div className="relative w-[95vw] max-w-5xl h-[90vh] bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b bg-gray-50">
<div className="flex items-center gap-2">
<Receipt className="w-5 h-5 text-amber-600" />
<h3 className="font-semibold text-gray-900">Invoice Preview</h3>
<Badge className="bg-amber-100 text-amber-800 text-xs">{request.requestNumber}</Badge>
</div>
<div className="flex items-center gap-2">
{invoicePdfUrl && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadInvoicePdf}
className="gap-1.5 text-xs"
>
<Download className="w-3.5 h-3.5" />
Download
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={handleCloseInvoicePdf}
className="h-8 w-8 hover:bg-gray-200"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-hidden">
{invoicePdfLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
<p className="text-sm text-gray-500">Loading invoice...</p>
</div>
) : invoicePdfUrl ? (
<iframe
src={invoicePdfUrl}
className="w-full h-full border-0"
title="Invoice PDF Preview"
/>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-gray-500">Failed to load invoice</p>
</div>
)}
</div>
</div>
</div>
)}
</> </>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

@ -27,6 +27,7 @@ import {
Download, Download,
Eye, Eye,
Loader2, Loader2,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi'; import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
@ -80,23 +81,31 @@ interface DMSPushModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onPush: (comments: string) => Promise<void>; onPush: (comments: string) => Promise<void>;
onReQuotation?: (comments: string) => Promise<void>;
completionDetails?: CompletionDetails | null; completionDetails?: CompletionDetails | null;
ioDetails?: IODetails | null; ioDetails?: IODetails | null;
completionDocuments?: CompletionDocuments | null; completionDocuments?: CompletionDocuments | null;
requestTitle?: string; requestTitle?: string;
requestNumber?: string; requestNumber?: string;
taxationType?: string | null;
} }
export function DMSPushModal({ export function DMSPushModal({
isOpen, isOpen,
onClose, onClose,
onPush, onPush,
onReQuotation,
completionDetails, completionDetails,
ioDetails, ioDetails,
completionDocuments, completionDocuments,
requestTitle, requestTitle,
requestNumber, requestNumber,
taxationType,
}: DMSPushModalProps) { }: DMSPushModalProps) {
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [previewDocument, setPreviewDocument] = useState<{ const [previewDocument, setPreviewDocument] = useState<{
@ -118,7 +127,9 @@ export function DMSPushModal({
if (completionDetails?.closedExpenses && Array.isArray(completionDetails.closedExpenses)) { if (completionDetails?.closedExpenses && Array.isArray(completionDetails.closedExpenses)) {
return completionDetails.closedExpenses.reduce((sum, item) => { return completionDetails.closedExpenses.reduce((sum, item) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0; const amount = typeof item === 'object' ? (item.amount || 0) : 0;
return sum + (Number(amount) || 0); const gst = typeof item === 'object' ? ((item as any).gstAmt || 0) : 0;
const total = (item as any).totalAmt || (amount + gst);
return sum + (Number(total) || 0);
}, 0); }, 0);
} }
return 0; return 0;
@ -228,7 +239,7 @@ export function DMSPushModal({
const handleSubmit = async () => { const handleSubmit = async () => {
if (!comments.trim()) { if (!comments.trim()) {
toast.error('Please provide comments before pushing to DMS'); toast.error('Please provide comments before proceeding');
return; return;
} }
@ -238,8 +249,8 @@ export function DMSPushModal({
handleReset(); handleReset();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Failed to push to DMS:', error); console.error('Failed to generate e-invoice:', error);
toast.error('Failed to push to DMS. Please try again.'); toast.error('Failed to generate e-invoice. Please try again.');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -249,6 +260,30 @@ export function DMSPushModal({
setComments(''); setComments('');
}; };
const handleReQuotation = async () => {
if (!comments.trim()) {
toast.error('Please provide comments (reason) for re-quotation request');
return;
}
if (!onReQuotation) {
toast.error('Re-quotation handler not provided');
return;
}
try {
setSubmitting(true);
await onReQuotation(comments.trim());
handleReset();
onClose();
} catch (error) {
console.error('Failed to request re-quotation:', error);
// Error is handled in the parent handler
} finally {
setSubmitting(false);
}
};
const handleClose = () => { const handleClose = () => {
if (!submitting) { if (!submitting) {
handleReset(); handleReset();
@ -257,19 +292,25 @@ export function DMSPushModal({
}; };
return ( return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dms-push-modal overflow-hidden flex flex-col"> <DialogContent className="settlement-push-modal overflow-hidden flex flex-col w-full max-w-none">
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0"> <DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
<div className="flex items-center gap-2 sm:gap-3 mb-2"> <div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="p-1.5 sm:p-2 rounded-lg bg-indigo-100"> <div className="p-1.5 sm:p-2 rounded-lg bg-indigo-100">
<Activity className="w-4 h-4 sm:w-5 sm:h-5 sm:w-6 sm:h-6 text-indigo-600" /> <Activity className="w-4 h-4 sm:w-5 sm:h-5 sm:w-6 sm:h-6 text-indigo-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<DialogTitle className="font-semibold text-lg sm:text-xl"> <DialogTitle className="font-semibold text-lg sm:text-xl flex items-center gap-2 flex-wrap">
Push to DMS - Verification E-Invoice Generation & Sync
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm mt-1"> <DialogDescription className="text-xs sm:text-sm mt-1">
Review completion details and expenses before pushing to DMS for e-invoice generation Review completion details and expenses before generating e-invoice and initiating SAP settlement
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@ -294,7 +335,7 @@ export function DMSPushModal({
<span className="font-medium text-xs sm:text-sm text-gray-600">Action:</span> <span className="font-medium text-xs sm:text-sm text-gray-600">Action:</span>
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs"> <Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs">
<Activity className="w-3 h-3 mr-1" /> <Activity className="w-3 h-3 mr-1" />
PUSH TO DMS SYNC TO SAP
</Badge> </Badge>
</div> </div>
</div> </div>
@ -386,38 +427,83 @@ export function DMSPushModal({
{/* Expense Breakdown Card */} {/* Expense Breakdown Card */}
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && ( {completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
<Card> <Card className="md:col-span-2 lg:col-span-3">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg"> <CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<DollarSign className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" /> <DollarSign className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
Expense Breakdown Expense Breakdown
</CardTitle> </CardTitle>
<CardDescription className="text-xs sm:text-sm"> <CardDescription className="text-xs sm:text-sm">
Review closed expenses before pushing to DMS Review closed expenses breakdown (Base + GST)
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-1.5 sm:space-y-2 max-h-[200px] overflow-y-auto"> {/* Table Header */}
{completionDetails.closedExpenses.map((expense, index) => ( <div className="grid grid-cols-12 gap-2 mb-2 px-3 text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:grid">
<div className={`${isNonGst ? 'col-span-8' : 'col-span-4'}`}>Description</div>
<div className="col-span-2 text-right">Base</div>
{!isNonGst && (
<>
<div className="col-span-2 text-right">GST Rate</div>
<div className="col-span-2 text-right">GST Amt</div>
</>
)}
<div className="col-span-2 text-right">Total</div>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{completionDetails.closedExpenses.map((expense, index) => {
const amount = typeof expense === 'object' ? (expense.amount || 0) : 0; // Base Amount
const gstRate = typeof expense === 'object' ? ((expense as any).gstRate || 0) : 0;
const gstAmt = typeof expense === 'object' ? ((expense as any).gstAmt || 0) : 0; // GST Amount
const total = typeof expense === 'object' ? ((expense as any).totalAmt || (amount + gstAmt)) : 0; // Total Amount
return (
<div <div
key={index} key={index}
className="flex items-center justify-between py-1.5 sm:py-2 px-2 sm:px-3 bg-gray-50 rounded border" className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-center py-2 px-3 bg-gray-50 rounded border text-xs sm:text-sm"
> >
<div className="flex-1 min-w-0 pr-2"> {/* Mobile View: Stacked */}
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate"> <div className="sm:hidden flex justify-between w-full mb-1">
<span className="font-semibold text-gray-900">{expense.description || `Expense ${index + 1}`}</span>
<span className="font-bold text-gray-900">{formatCurrency(total)}</span>
</div>
<div className="sm:hidden flex justify-between w-full text-xs text-gray-500">
<span>Base: {formatCurrency(amount)}</span>
{!isNonGst && (
<span>GST: {gstRate}% ({formatCurrency(gstAmt)})</span>
)}
</div>
{/* Desktop View: Grid */}
<div className={`hidden sm:block ${isNonGst ? 'col-span-8' : 'col-span-4'} min-w-0`}>
<p className="font-medium text-gray-900 truncate" title={expense.description}>
{expense.description || `Expense ${index + 1}`} {expense.description || `Expense ${index + 1}`}
</p> </p>
</div> </div>
<div className="ml-2 flex-shrink-0"> <div className="hidden sm:block col-span-2 text-right text-gray-600">
<p className="text-xs sm:text-sm font-semibold text-gray-900"> {formatCurrency(amount)}
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)} </div>
</p> {!isNonGst && (
<>
<div className="hidden sm:block col-span-2 text-right text-gray-600">
{gstRate}%
</div>
<div className="hidden sm:block col-span-2 text-right text-gray-600">
{formatCurrency(gstAmt)}
</div>
</>
)}
<div className="hidden sm:block col-span-2 text-right font-semibold text-gray-900">
{formatCurrency(total)}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
<div className="flex items-center justify-between py-2 sm:py-3 px-2 sm:px-3 bg-blue-50 rounded border-2 border-blue-200 mt-2 sm:mt-3">
<span className="text-xs sm:text-sm font-semibold text-gray-900">Total:</span> <div className="flex items-center justify-between py-2 sm:py-3 px-3 sm:px-4 bg-blue-50 rounded border-2 border-blue-200 mt-3 sm:mt-4">
<span className="text-xs sm:text-sm font-semibold text-gray-900">Total Closed Expenses:</span>
<span className="text-sm sm:text-base font-bold text-blue-700"> <span className="text-sm sm:text-base font-bold text-blue-700">
{formatCurrency(totalClosedExpenses)} {formatCurrency(totalClosedExpenses)}
</span> </span>
@ -700,10 +786,10 @@ export function DMSPushModal({
<TriangleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 flex-shrink-0 mt-0.5" /> <TriangleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="text-xs sm:text-sm font-semibold text-yellow-900"> <p className="text-xs sm:text-sm font-semibold text-yellow-900">
Please verify all details before pushing to DMS Please verify all details before generation
</p> </p>
<p className="text-xs text-yellow-700 mt-1"> <p className="text-xs text-yellow-700 mt-1">
Once pushed, the system will automatically generate an e-invoice and log it as an activity. Once submitted, the system will generate an e-invoice and initiate the SAP settlement process.
</p> </p>
</div> </div>
</div> </div>
@ -716,7 +802,7 @@ export function DMSPushModal({
</Label> </Label>
<Textarea <Textarea
id="comment" id="comment"
placeholder="Enter your comments about pushing to DMS (e.g., verified expenses, ready for invoice generation)..." placeholder="Enter your comments about e-invoice generation (e.g., verified expenses, ready for settlement)..."
value={comments} value={comments}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
@ -746,23 +832,40 @@ export function DMSPushModal({
> >
Cancel Cancel
</Button> </Button>
{onReQuotation && (
<Button
variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50"
onClick={handleReQuotation}
disabled={!comments.trim() || submitting}
>
{submitting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Request Re-quotation
</Button>
)}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!comments.trim() || submitting} disabled={!comments.trim() || submitting}
className="bg-indigo-600 hover:bg-indigo-700 text-white" className="bg-indigo-600 hover:bg-indigo-700 text-white"
> >
{submitting ? ( {submitting ? (
'Pushing to DMS...' 'Processing...'
) : ( ) : (
<> <>
<Activity className="w-4 h-4 mr-2" /> <Activity className="w-4 h-4 mr-2" />
Push to DMS Generate & Sync
</> </>
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog>
{/* File Preview Modal - Matching DocumentsTab style */} {/* File Preview Modal - Matching DocumentsTab style */}
{previewDocument && ( {previewDocument && (
<Dialog <Dialog
@ -866,7 +969,7 @@ export function DMSPushModal({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
</Dialog> </>
); );
} }

View File

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

View File

@ -19,15 +19,34 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { CustomDatePicker } from '@/components/ui/date-picker'; import { CustomDatePicker } from '@/components/ui/date-picker';
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download } from 'lucide-react'; import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download, IndianRupee } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { handleSecurityError } from '@/utils/securityToast';
import '@/components/common/FilePreview/FilePreview.css'; import '@/components/common/FilePreview/FilePreview.css';
import './DealerCompletionDocumentsModal.css'; import './DealerCompletionDocumentsModal.css';
import { validateHSNSAC } from '@/utils/validationUtils';
import { getStateCodeFromGSTIN, getActiveTaxComponents } from '@/utils/gstUtils';
interface ExpenseItem { interface ExpenseItem {
id: string; id: string;
description: string; description: string;
amount: number; amount: number;
gstRate: number;
gstAmt: number;
quantity: number;
hsnCode: string;
cgstRate: number;
cgstAmt: number;
sgstRate: number;
sgstAmt: number;
igstRate: number;
igstAmt: number;
utgstRate: number;
utgstAmt: number;
cessRate: number;
cessAmt: number;
totalAmt: number;
isService: boolean;
} }
interface DealerCompletionDocumentsModalProps { interface DealerCompletionDocumentsModalProps {
@ -45,12 +64,15 @@ interface DealerCompletionDocumentsModalProps {
completionDescription: string; completionDescription: string;
}) => Promise<void>; }) => Promise<void>;
dealerName?: string; dealerName?: string;
dealerGSTIN?: string;
activityName?: string; activityName?: string;
requestId?: string; requestId?: string;
defaultGstRate?: number;
documentPolicy: { documentPolicy: {
maxFileSizeMB: number; maxFileSizeMB: number;
allowedFileTypes: string[]; allowedFileTypes: string[];
}; };
taxationType?: string | null;
} }
export function DealerCompletionDocumentsModal({ export function DealerCompletionDocumentsModal({
@ -58,12 +80,26 @@ export function DealerCompletionDocumentsModal({
onClose, onClose,
onSubmit, onSubmit,
dealerName = 'Jaipur Royal Enfield', dealerName = 'Jaipur Royal Enfield',
dealerGSTIN,
activityName = 'Activity', activityName = 'Activity',
requestId: _requestId, requestId: _requestId,
defaultGstRate = 18,
documentPolicy, documentPolicy,
taxationType,
}: DealerCompletionDocumentsModalProps) { }: DealerCompletionDocumentsModalProps) {
const [activityCompletionDate, setActivityCompletionDate] = useState(''); const [activityCompletionDate, setActivityCompletionDate] = useState('');
const [numberOfParticipants, setNumberOfParticipants] = useState(''); const [numberOfParticipants, setNumberOfParticipants] = useState('');
// Determine active tax components based on dealer GSTIN
const taxConfig = useMemo(() => {
const stateCode = getStateCodeFromGSTIN(dealerGSTIN);
return getActiveTaxComponents(stateCode);
}, [dealerGSTIN]);
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
const [expenseItems, setExpenseItems] = useState<ExpenseItem[]>([]); const [expenseItems, setExpenseItems] = useState<ExpenseItem[]>([]);
const [completionDocuments, setCompletionDocuments] = useState<File[]>([]); const [completionDocuments, setCompletionDocuments] = useState<File[]>([]);
const [activityPhotos, setActivityPhotos] = useState<File[]>([]); const [activityPhotos, setActivityPhotos] = useState<File[]>([]);
@ -101,6 +137,33 @@ export function DealerCompletionDocumentsModal({
}; };
}, [previewFile]); }, [previewFile]);
// Initialize with one empty row if none exist
useEffect(() => {
if (expenseItems.length === 0) {
setExpenseItems([{
id: '1',
description: '',
amount: 0,
gstRate: defaultGstRate || 0,
gstAmt: 0,
quantity: 1,
hsnCode: '',
isService: false,
cgstRate: 0,
cgstAmt: 0,
sgstRate: 0,
sgstAmt: 0,
igstRate: 0,
igstAmt: 0,
utgstRate: 0,
utgstAmt: 0,
cessRate: 0,
cessAmt: 0,
totalAmt: 0
}]);
}
}, [defaultGstRate]);
// Handle file preview // Handle file preview
const handlePreviewFile = (file: File) => { const handlePreviewFile = (file: File) => {
if (!canPreviewFile(file)) { if (!canPreviewFile(file)) {
@ -129,11 +192,36 @@ export function DealerCompletionDocumentsModal({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
// Calculate total closed expenses // Calculate total closed expenses (inclusive of GST)
const totalClosedExpenses = useMemo(() => { const totalClosedExpenses = useMemo(() => {
return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0); return expenseItems.reduce((sum, item) => sum + (item.totalAmt || item.amount || 0), 0);
}, [expenseItems]); }, [expenseItems]);
// GST Calculation Helper
const calculateGST = (amount: number, rates: { cgstRate: number; sgstRate: number; igstRate: number; utgstRate: number }, quantity: number = 1) => {
const baseTotal = amount * quantity;
const cgstAmt = (baseTotal * (rates.cgstRate || 0)) / 100;
const sgstAmt = (baseTotal * (rates.sgstRate || 0)) / 100;
const utgstAmt = (baseTotal * (rates.utgstRate || 0)) / 100;
const igstAmt = (baseTotal * (rates.igstRate || 0)) / 100;
const gstAmt = cgstAmt + sgstAmt + utgstAmt + igstAmt;
const totalAmt = baseTotal + gstAmt;
return {
cgstRate: rates.cgstRate,
cgstAmt,
sgstRate: rates.sgstRate,
sgstAmt,
utgstRate: rates.utgstRate,
utgstAmt,
igstRate: rates.igstRate,
igstAmt,
gstAmt,
gstRate: (rates.cgstRate || 0) + (rates.sgstRate || 0) + (rates.utgstRate || 0) + (rates.igstRate || 0),
totalAmt
};
};
// Check if all required fields are filled // Check if all required fields are filled
const isFormValid = useMemo(() => { const isFormValid = useMemo(() => {
const hasCompletionDate = activityCompletionDate !== ''; const hasCompletionDate = activityCompletionDate !== '';
@ -141,8 +229,13 @@ export function DealerCompletionDocumentsModal({
const hasPhotos = activityPhotos.length > 0; const hasPhotos = activityPhotos.length > 0;
const hasDescription = completionDescription.trim().length > 0; const hasDescription = completionDescription.trim().length > 0;
return hasCompletionDate && hasDocuments && hasPhotos && hasDescription; const hasHSNSACErrors = isNonGst ? false : expenseItems.some(item => {
}, [activityCompletionDate, completionDocuments, activityPhotos, completionDescription]); const { isValid } = validateHSNSAC(item.hsnCode, item.isService);
return !isValid;
});
return hasCompletionDate && hasDocuments && hasPhotos && hasDescription && !hasHSNSACErrors;
}, [activityCompletionDate, completionDocuments, activityPhotos, completionDescription, isNonGst, expenseItems]);
// Get today's date in YYYY-MM-DD format for max date // Get today's date in YYYY-MM-DD format for max date
const maxDate = new Date().toISOString().split('T')[0]; const maxDate = new Date().toISOString().split('T')[0];
@ -150,15 +243,110 @@ export function DealerCompletionDocumentsModal({
const handleAddExpense = () => { const handleAddExpense = () => {
setExpenseItems([ setExpenseItems([
...expenseItems, ...expenseItems,
{ id: Date.now().toString(), description: '', amount: 0 }, {
id: Date.now().toString(),
description: '',
amount: 0,
gstRate: defaultGstRate || 0,
gstAmt: 0,
quantity: 1,
hsnCode: '',
isService: false,
cgstRate: 0,
cgstAmt: 0,
sgstRate: 0,
sgstAmt: 0,
igstRate: 0,
igstAmt: 0,
utgstRate: 0,
utgstAmt: 0,
cessRate: 0,
cessAmt: 0,
totalAmt: 0
},
]); ]);
}; };
const handleExpenseChange = (id: string, field: 'description' | 'amount', value: string | number) => { const handleExpenseChange = (id: string, field: string, value: any) => {
setExpenseItems( setExpenseItems(
expenseItems.map((item) => expenseItems.map((item) => {
item.id === id ? { ...item, [field]: value } : item if (item.id === id) {
) let updatedItem = { ...item, [field]: value };
// Re-calculate GST if relevant fields change
if (['amount', 'gstRate', 'cgstRate', 'sgstRate', 'utgstRate', 'igstRate', 'quantity'].includes(field)) {
const amount = field === 'amount' ? parseFloat(value) || 0 : item.amount;
const quantity = field === 'quantity' ? parseInt(value) || 1 : item.quantity;
let cgstRate = item.cgstRate;
let sgstRate = item.sgstRate;
let utgstRate = item.utgstRate;
let igstRate = item.igstRate;
if (field === 'cgstRate') {
if (!taxConfig.isCGST) return item;
cgstRate = parseFloat(value) || 0;
// If UTGST is active for this dealer, sync with it
if (taxConfig.isUTGST) {
utgstRate = cgstRate;
sgstRate = 0;
} else {
sgstRate = cgstRate;
utgstRate = 0;
}
igstRate = 0;
} else if (field === 'sgstRate') {
if (!taxConfig.isSGST) return item;
sgstRate = parseFloat(value) || 0;
cgstRate = sgstRate;
utgstRate = 0;
igstRate = 0;
} else if (field === 'utgstRate') {
if (!taxConfig.isUTGST) return item;
utgstRate = parseFloat(value) || 0;
cgstRate = utgstRate;
sgstRate = 0;
igstRate = 0;
} else if (field === 'igstRate') {
if (!taxConfig.isIGST) return item;
igstRate = parseFloat(value) || 0;
cgstRate = 0;
sgstRate = 0;
utgstRate = 0;
} else if (field === 'gstRate') {
const totalRate = parseFloat(value) || 0;
if (taxConfig.isIGST) {
igstRate = totalRate;
cgstRate = 0;
sgstRate = 0;
utgstRate = 0;
} else {
cgstRate = totalRate / 2;
if (taxConfig.isUTGST) {
utgstRate = totalRate / 2;
sgstRate = 0;
} else {
sgstRate = totalRate / 2;
utgstRate = 0;
}
igstRate = 0;
}
}
const calculation = calculateGST(amount, { cgstRate, sgstRate, igstRate, utgstRate }, quantity);
return {
...updatedItem,
amount,
quantity,
...calculation
};
}
return updatedItem;
}
return item;
})
); );
}; };
@ -340,6 +528,21 @@ export function DealerCompletionDocumentsModal({
(item) => item.description.trim() !== '' && item.amount > 0 (item) => item.description.trim() !== '' && item.amount > 0
); );
// Validation: Alert for 0% GST on taxable items (Skip for Non-GST)
const hasZeroGstItems = !isNonGst && validExpenses.some(item =>
item.description.trim() !== '' && item.amount > 0 && (item.gstRate === 0 || !item.gstRate)
);
if (hasZeroGstItems) {
const confirmZeroGst = window.confirm(
"One or more expenses have 0% GST. Are you sure you want to proceed? \n\nNote: If these items are taxable, please provide a valid GST rate to ensure correct E-Invoice generation."
);
if (!confirmZeroGst) {
setSubmitting(false);
return;
}
}
try { try {
setSubmitting(true); setSubmitting(true);
await onSubmit({ await onSubmit({
@ -357,7 +560,9 @@ export function DealerCompletionDocumentsModal({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Failed to submit completion documents:', error); console.error('Failed to submit completion documents:', error);
if (!handleSecurityError(error)) {
toast.error('Failed to submit completion documents. Please try again.'); toast.error('Failed to submit completion documents. Please try again.');
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -371,7 +576,27 @@ export function DealerCompletionDocumentsModal({
setPreviewFile(null); setPreviewFile(null);
setActivityCompletionDate(''); setActivityCompletionDate('');
setNumberOfParticipants(''); setNumberOfParticipants('');
setExpenseItems([]); setExpenseItems([{
id: '1',
description: '',
amount: 0,
gstRate: defaultGstRate || 0,
gstAmt: 0,
quantity: 1,
hsnCode: '',
isService: false,
cgstRate: 0,
cgstAmt: 0,
sgstRate: 0,
sgstAmt: 0,
igstRate: 0,
igstAmt: 0,
utgstRate: 0,
utgstAmt: 0,
cessRate: 0,
cessAmt: 0,
totalAmt: 0
}]);
setCompletionDocuments([]); setCompletionDocuments([]);
setActivityPhotos([]); setActivityPhotos([]);
setInvoicesReceipts([]); setInvoicesReceipts([]);
@ -391,12 +616,20 @@ export function DealerCompletionDocumentsModal({
}; };
return ( return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-completion-documents-modal overflow-hidden flex flex-col"> <DialogContent className="dealer-completion-documents-modal overflow-hidden flex flex-col">
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0"> <DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
<DialogTitle className="font-semibold flex items-center gap-2 text-xl sm:text-2xl"> <DialogTitle className="font-semibold flex items-center gap-2 text-xl sm:text-2xl flex-wrap">
<div className="flex items-center gap-2">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 text-[--re-green]" /> <Upload className="w-5 h-5 sm:w-6 sm:h-6 text-[--re-green]" />
Activity Completion Documents Activity Completion Documents
</div>
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${taxationType === 'GST' ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{taxationType === 'GST' ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm sm:text-base"> <DialogDescription className="text-sm sm:text-base">
Step 5: Upload completion proof and final documents Step 5: Upload completion proof and final documents
@ -415,7 +648,7 @@ export function DealerCompletionDocumentsModal({
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3"> <div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
<div className="space-y-5 sm:space-y-6 max-w-4xl mx-auto"> <div className="space-y-5 sm:space-y-6">
{/* Activity Completion Date */} {/* Activity Completion Date */}
<div className="space-y-1.5 sm:space-y-2"> <div className="space-y-1.5 sm:space-y-2">
<Label className="text-sm sm:text-base font-semibold flex items-center gap-2" htmlFor="completionDate"> <Label className="text-sm sm:text-base font-semibold flex items-center gap-2" htmlFor="completionDate">
@ -437,8 +670,12 @@ export function DealerCompletionDocumentsModal({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold text-base sm:text-lg">Closed Expenses</h3> <h3 className="font-semibold text-base sm:text-lg">Closed Expenses</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div> </div>
{!isNonGst && (
<div className="text-[10px] text-gray-500 italic mt-0.5">
Tax fields are automatically toggled based on the dealer's state (Inter-state vs Intra-state).
</div>
)}
<Button <Button
type="button" type="button"
onClick={handleAddExpense} onClick={handleAddExpense}
@ -449,43 +686,192 @@ export function DealerCompletionDocumentsModal({
Add Expense Add Expense
</Button> </Button>
</div> </div>
<div className="space-y-2 sm:space-y-3"> <div className="space-y-3 sm:space-y-4 max-h-[400px] overflow-y-auto pr-1">
{expenseItems.map((item) => ( {expenseItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start"> <div key={item.id} className="p-4 border rounded-lg bg-gray-50/50 space-y-4 relative group">
<div className="flex-1"> <div className="flex gap-3 items-start w-full">
<div className={`${isNonGst ? 'flex-[3]' : 'flex-1'} min-w-0`}>
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Item description</Label>
<Input <Input
placeholder="Item name (e.g., Venue rental, Refreshments, Printing)" placeholder="e.g., Venue rental, Refreshments"
value={item.description} value={item.description}
onChange={(e) => onChange={(e) =>
handleExpenseChange(item.id, 'description', e.target.value) handleExpenseChange(item.id, 'description', e.target.value)
} }
className="text-sm" className="w-full bg-white text-sm"
/> />
</div> </div>
<div className="w-32 sm:w-40"> {isNonGst && (
<div className="w-28 sm:w-36 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Amount</Label>
<div className="relative">
<IndianRupee className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<Input <Input
type="number" type="number"
placeholder="Amount" placeholder="0.00"
min="0" min="0"
step="0.01" step="0.01"
value={item.amount || ''} value={item.amount || ''}
onChange={(e) => onChange={(e) =>
handleExpenseChange(item.id, 'amount', parseFloat(e.target.value) || 0) handleExpenseChange(item.id, 'amount', e.target.value)
} }
className="text-sm" className="w-full bg-white text-sm pl-8"
/> />
</div> </div>
</div>
)}
{!isNonGst && (
<div className="w-28 sm:w-36 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Amount (Base)</Label>
<div className="relative">
<IndianRupee className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<Input
type="number"
placeholder="0.00"
min="0"
step="0.01"
value={item.amount || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'amount', e.target.value)
}
className="w-full bg-white text-sm pl-8"
/>
</div>
</div>
)}
{!isNonGst && (
<>
<div className="w-20 sm:w-24 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">HSN/SAC Code</Label>
<Input
placeholder="HSN/SAC Code"
value={item.hsnCode || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'hsnCode', e.target.value)
}
className={`w-full bg-white text-sm ${!validateHSNSAC(item.hsnCode, item.isService).isValid ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
/>
{!validateHSNSAC(item.hsnCode, item.isService).isValid && (
<span className="text-[9px] text-red-500 mt-1 block leading-tight">
{validateHSNSAC(item.hsnCode, item.isService).message}
</span>
)}
</div>
<div className="w-24 sm:w-28 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">Item Type</Label>
<select
value={item.isService ? 'SAC' : 'HSN'}
onChange={(e) =>
handleExpenseChange(item.id, 'isService', e.target.value === 'SAC')
}
className="flex h-9 w-full rounded-md border border-input bg-white px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="HSN">HSN (Goods)</option>
<option value="SAC">SAC (Service)</option>
</select>
</div>
<div className="w-14 sm:w-16 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">CGST %</Label>
<Input
type="number"
placeholder="%"
min="0"
max="100"
step="0.1"
value={item.cgstRate || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'cgstRate', e.target.value)
}
disabled={!taxConfig.isCGST}
className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400"
/>
</div>
<div className="w-14 sm:w-16 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">SGST %</Label>
<Input
type="number"
placeholder="%"
min="0"
max="100"
step="0.1"
value={item.sgstRate || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'sgstRate', e.target.value)
}
disabled={!taxConfig.isSGST}
className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400"
/>
</div>
<div className="w-14 sm:w-16 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">UTGST %</Label>
<Input
type="number"
placeholder="%"
min="0"
max="100"
step="0.1"
value={item.utgstRate || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'utgstRate', e.target.value)
}
disabled={!taxConfig.isUTGST}
className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400"
/>
</div>
<div className="w-14 sm:w-16 flex-shrink-0">
<Label className="text-[10px] uppercase text-gray-500 font-bold mb-1 block">IGST %</Label>
<Input
type="number"
placeholder="%"
min="0"
max="100"
step="0.1"
value={item.igstRate || ''}
onChange={(e) =>
handleExpenseChange(item.id, 'igstRate', e.target.value)
}
disabled={!taxConfig.isIGST}
className="w-full bg-white text-xs px-1 text-center disabled:bg-gray-100 disabled:text-gray-400"
/>
</div>
</>
)}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mt-0.5 hover:bg-red-100 hover:text-red-700 h-9 w-9 p-0" className="mt-5 hover:bg-red-100 hover:text-red-700 flex-shrink-0 h-9 w-9 p-0"
onClick={() => handleRemoveExpense(item.id)} onClick={() => handleRemoveExpense(item.id)}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
<div className={`grid grid-cols-2 sm:grid-cols-5 gap-3 pt-3 border-t border-dashed border-gray-200 ${isNonGst ? 'items-center' : ''}`}>
{!isNonGst ? (
<>
<div className="flex flex-wrap gap-4 text-gray-500 font-medium">
<span>CGST: <span className="text-gray-900 font-semibold">{(item.cgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>
{item.sgstAmt > 0 && <span>SGST: <span className="text-gray-900 font-semibold">{(item.sgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>}
{item.utgstAmt > 0 && <span>UTGST: <span className="text-gray-900 font-semibold">{(item.utgstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>}
<span>IGST: <span className="text-gray-900 font-semibold">{(item.igstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>
</div>
<div className="flex flex-wrap gap-4 items-center sm:justify-end">
<span className="text-gray-500">GST Total: <span className="text-gray-900 font-bold">{(item.gstAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</span></span>
<Badge className="bg-[#2d4a3e] text-white px-3 py-1 text-xs">Item Total: {(item.totalAmt || 0).toLocaleString('en-IN', { minimumFractionDigits: 1 })}</Badge>
</div>
</>
) : (
<div className="col-span-4 invisible"></div>
)}
<div className="flex flex-col items-end">
<span className="text-[10px] text-gray-500 uppercase">Item Total</span>
<span className="text-sm font-bold text-[#2d4a3e]">{(item.amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
</div>
</div>
</div>
))} ))}
</div>
{expenseItems.length === 0 && ( {expenseItems.length === 0 && (
<p className="text-xs sm:text-sm text-gray-500 italic"> <p className="text-xs sm:text-sm text-gray-500 italic">
No expenses added. Click "Add Expense" to add expense items. No expenses added. Click "Add Expense" to add expense items.
@ -523,8 +909,7 @@ export function DealerCompletionDocumentsModal({
Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder
</p> </p>
<div <div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${ className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${completionDocuments.length > 0
completionDocuments.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600' ? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white' : 'border-gray-300 hover:border-blue-500 bg-white'
}`} }`}
@ -629,8 +1014,7 @@ export function DealerCompletionDocumentsModal({
Upload photos from the completed activity (event photos, installations, etc.) Upload photos from the completed activity (event photos, installations, etc.)
</p> </p>
<div <div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${ className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${activityPhotos.length > 0
activityPhotos.length > 0
? 'border-green-500 bg-green-50 hover:border-green-600' ? 'border-green-500 bg-green-50 hover:border-green-600'
: 'border-gray-300 hover:border-blue-500 bg-white' : 'border-gray-300 hover:border-blue-500 bg-white'
}`} }`}
@ -745,8 +1129,7 @@ export function DealerCompletionDocumentsModal({
Upload invoices and receipts for expenses incurred Upload invoices and receipts for expenses incurred
</p> </p>
<div <div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${ className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${invoicesReceipts.length > 0
invoicesReceipts.length > 0
? 'border-blue-500 bg-blue-50 hover:border-blue-600' ? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white' : 'border-gray-300 hover:border-blue-500 bg-white'
}`} }`}
@ -849,8 +1232,7 @@ export function DealerCompletionDocumentsModal({
Upload attendance records or participant lists (if applicable) Upload attendance records or participant lists (if applicable)
</p> </p>
<div <div
className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${ className={`border-2 border-dashed rounded-lg p-3 sm:p-4 transition-all duration-200 ${attendanceSheet
attendanceSheet
? 'border-blue-500 bg-blue-50 hover:border-blue-600' ? 'border-blue-500 bg-blue-50 hover:border-blue-600'
: 'border-gray-300 hover:border-blue-500 bg-white' : 'border-gray-300 hover:border-blue-500 bg-white'
}`} }`}
@ -971,7 +1353,6 @@ export function DealerCompletionDocumentsModal({
</div> </div>
)} )}
</div> </div>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0"> <DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
<Button <Button
@ -991,9 +1372,11 @@ export function DealerCompletionDocumentsModal({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog >
{/* File Preview Modal - Matching DocumentsTab style */} {/* File Preview Modal - Matching DocumentsTab style */}
{previewFile && ( {
previewFile && (
<Dialog <Dialog
open={!!previewFile} open={!!previewFile}
onOpenChange={() => { onOpenChange={() => {
@ -1080,7 +1463,8 @@ export function DealerCompletionDocumentsModal({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )
</Dialog> }
</>
); );
} }

View File

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

View File

@ -37,6 +37,7 @@ interface DeptLeadIOApprovalModalProps {
preFilledIONumber?: string; preFilledIONumber?: string;
preFilledBlockedAmount?: number; preFilledBlockedAmount?: number;
preFilledRemainingBalance?: number; preFilledRemainingBalance?: number;
taxationType?: string | null;
} }
export function DeptLeadIOApprovalModal({ export function DeptLeadIOApprovalModal({
@ -49,11 +50,16 @@ export function DeptLeadIOApprovalModal({
preFilledIONumber, preFilledIONumber,
preFilledBlockedAmount, preFilledBlockedAmount,
preFilledRemainingBalance, preFilledRemainingBalance,
taxationType,
}: DeptLeadIOApprovalModalProps) { }: DeptLeadIOApprovalModalProps) {
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve'); const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
// Get IO number from props (read-only, from IO table) // Get IO number from props (read-only, from IO table)
const ioNumber = preFilledIONumber || ''; const ioNumber = preFilledIONumber || '';
@ -138,8 +144,13 @@ export function DeptLeadIOApprovalModal({
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" /> <CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<DialogTitle className="font-semibold text-lg lg:text-xl"> <DialogTitle className="font-semibold text-lg lg:text-xl flex items-center gap-2 flex-wrap">
Review and Approve Review and Approve
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs lg:text-sm mt-1"> <DialogDescription className="text-xs lg:text-sm mt-1">
Review IO details and provide your approval comments Review IO details and provide your approval comments
@ -174,8 +185,7 @@ export function DeptLeadIOApprovalModal({
<Button <Button
type="button" type="button"
onClick={() => setActionType('approve')} onClick={() => setActionType('approve')}
className={`flex-1 text-sm lg:text-base ${ className={`flex-1 text-sm lg:text-base ${actionType === 'approve'
actionType === 'approve'
? 'bg-green-600 text-white shadow-sm' ? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200' : 'text-gray-700 hover:bg-gray-200'
}`} }`}
@ -187,8 +197,7 @@ export function DeptLeadIOApprovalModal({
<Button <Button
type="button" type="button"
onClick={() => setActionType('reject')} onClick={() => setActionType('reject')}
className={`flex-1 text-sm lg:text-base ${ className={`flex-1 text-sm lg:text-base ${actionType === 'reject'
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm' ? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200' : 'text-gray-700 hover:bg-gray-200'
}`} }`}
@ -309,8 +318,7 @@ export function DeptLeadIOApprovalModal({
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isFormValid || submitting} disabled={!isFormValid || submitting}
className={`text-sm lg:text-base ${ className={`text-sm lg:text-base ${actionType === 'approve'
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700' ? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700' : 'bg-red-600 hover:bg-red-700'
} text-white`} } text-white`}

View File

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

View File

@ -39,6 +39,7 @@ interface CostItem {
id: string; id: string;
description: string; description: string;
amount: number; amount: number;
quantity?: number;
} }
interface ProposalData { interface ProposalData {
@ -70,6 +71,7 @@ interface InitiatorProposalApprovalModalProps {
requestId?: string; requestId?: string;
request?: any; // Request object to check IO blocking status request?: any; // Request object to check IO blocking status
previousProposalData?: any; previousProposalData?: any;
taxationType?: string | null;
} }
export function InitiatorProposalApprovalModal({ export function InitiatorProposalApprovalModal({
@ -84,25 +86,18 @@ export function InitiatorProposalApprovalModal({
requestId: _requestId, // Prefix with _ to indicate intentionally unused requestId: _requestId, // Prefix with _ to indicate intentionally unused
request, request,
previousProposalData, previousProposalData,
taxationType,
}: InitiatorProposalApprovalModalProps) { }: InitiatorProposalApprovalModalProps) {
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null); const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
const [showPreviousProposal, setShowPreviousProposal] = useState(false); const [showPreviousProposal, setShowPreviousProposal] = useState(false);
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level) // Calculate total budget (needed for display)
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;
type?: string;
size?: number;
id?: string;
} | null>(null);
// Calculate total budget
const totalBudget = useMemo(() => { const totalBudget = useMemo(() => {
if (!proposalData?.costBreakup) return 0; if (!proposalData?.costBreakup) return 0;
@ -117,10 +112,65 @@ export function InitiatorProposalApprovalModal({
return costBreakup.reduce((sum: number, item: any) => { return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0; const amount = typeof item === 'object' ? (item.amount || 0) : 0;
return sum + (Number(amount) || 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); }, 0);
}, [proposalData]); }, [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 [previewDoc, setPreviewDoc] = useState<{
name: string;
url: string;
type?: string;
size?: number;
id?: string;
} | null>(null);
// Format date // Format date
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return '—'; if (!dateString) return '—';
@ -273,9 +323,16 @@ export function InitiatorProposalApprovalModal({
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col"> <DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b"> <DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl"> <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" /> <CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
Requestor Evaluation & Confirmation Requestor Evaluation & Confirmation
</div>
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs lg:text-sm"> <DialogDescription className="text-xs lg:text-sm">
Step 2: Review dealer proposal and make a decision Step 2: Review dealer proposal and make a decision
@ -603,18 +660,44 @@ export function InitiatorProposalApprovalModal({
<> <>
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto"> <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="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 className={`grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4 text-xs lg:text-sm font-semibold text-gray-700`}>
<div>Item Description</div> <div className="col-span-1">Item Description</div>
<div className="text-right">Amount</div> <div className="text-right">Base</div>
{!isNonGst && <div className="text-right">GST</div>}
<div className="text-right">Total</div>
</div> </div>
</div> </div>
<div className="divide-y"> <div className="divide-y">
{costBreakup.map((item: any, index: number) => ( {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 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="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div> <div className="col-span-1 text-xs lg:text-sm text-gray-700">
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right"> <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 })} {(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div> </div>
{!isNonGst && (
<div className="text-xs lg:text-sm text-gray-900 text-right">
{(Number(item?.gstAmt) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.totalAmt || ((item?.amount || 0) * (item?.quantity || 1) + (item?.gstAmt || 0))) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div> </div>
))} ))}
</div> </div>
@ -756,8 +839,10 @@ export function InitiatorProposalApprovalModal({
</div> </div>
{/* Warning for IO not blocked - shown below Approve button */} {/* Warning for IO not blocked - shown below Approve button */}
{!isIOBlocked && ( {!isIOBlocked && (
<p className="text-xs text-red-600 text-center sm:text-left"> <p className="text-xs text-red-600 text-center sm:text-left font-medium">
Please block IO budget in the IO Tab before approving {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> </p>
)} )}
</div> </div>

View File

@ -41,7 +41,7 @@ import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal'; import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
// Dealer Claim Components (import from index to get properly aliased exports) // Dealer Claim Components (import from index to get properly aliased exports)
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index'; import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
@ -155,8 +155,12 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const currentUserId = (user as any)?.userId || ''; const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims // IO tab visibility for dealer claims
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO // Restricted: Hide from Dealers, show for internal roles (Initiator, Dept Lead, Finance, Admin)
const showIOTab = isInitiator; 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;
const { const {
mergedMessages, mergedMessages,
@ -219,8 +223,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Closure functionality - only for initiator when request is approved/rejected // Closure functionality - only for initiator when request is approved/rejected
// Check both lowercase and uppercase status values // Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase(); const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed'; const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
// Closure check completed // Closure check completed
const { const {
@ -322,7 +325,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowShareSummaryModal(true); setShowShareSummaryModal(true);
}; };
// Summary check already handled by isClosed above const isClosed = request?.status === 'closed';
// Fetch summary details if request is closed // Fetch summary details if request is closed
useEffect(() => { useEffect(() => {
@ -491,7 +494,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
refreshing={refreshing} refreshing={refreshing}
onBack={onBack || (() => window.history.back())} onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onShareSummary={summaryId ? handleShareSummary : undefined} onShareSummary={handleShareSummary}
isInitiator={isInitiator} isInitiator={isInitiator}
// Dealer-claim module: Business logic for preparing SLA data // Dealer-claim module: Business logic for preparing SLA data
slaData={request?.summary?.sla || request?.sla || null} slaData={request?.summary?.sla || request?.sla || null}
@ -594,7 +597,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
generationAttempts={generationAttempts} generationAttempts={generationAttempts}
generationFailed={generationFailed} generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached} maxAttemptsReached={maxAttemptsReached}
isClosed={isClosed}
/> />
</TabsContent> </TabsContent>
@ -675,7 +677,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
request={request} request={request}
isInitiator={isInitiator} isInitiator={isInitiator}
isSpectator={isSpectator} isSpectator={isSpectator}
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel} currentApprovalLevel={currentApprovalLevel}
onAddApprover={() => setShowAddApproverModal(true)} onAddApprover={() => setShowAddApproverModal(true)}
onAddSpectator={() => setShowAddSpectatorModal(true)} onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)} onApprove={() => setShowApproveModal(true)}

View File

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

View File

@ -1,8 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import workflowApi, { getPauseDetails } from '@/services/workflowApi'; import workflowApi, { getPauseDetails } from '@/services/workflowApi';
import apiClient from '@/services/authApi'; import apiClient from '@/services/authApi';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { getSocket } from '@/utils/socket'; import { getSocket } from '@/utils/socket';
/** /**
@ -240,6 +238,7 @@ export function useRequestDetails(
let proposalDetails = null; let proposalDetails = null;
let completionDetails = null; let completionDetails = null;
let internalOrder = null; let internalOrder = null;
let internalOrders = [];
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try { try {
@ -252,6 +251,7 @@ export function useRequestDetails(
proposalDetails = claimData.proposalDetails || claimData.proposal_details; proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details; completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null; internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables // New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null; const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
@ -294,7 +294,6 @@ export function useRequestDetails(
title: wf.title, title: wf.title,
description: wf.description, description: wf.description,
status: statusMap(wf.status), status: statusMap(wf.status),
workflowState: wf.workflowState,
priority: (wf.priority || '').toString().toLowerCase(), priority: (wf.priority || '').toString().toLowerCase(),
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'), workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
approvalFlow, approvalFlow,
@ -329,6 +328,7 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null, proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null, completionDetails: completionDetails || null,
internalOrder: internalOrder || null, internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility) // New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null, budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
@ -520,6 +520,7 @@ export function useRequestDetails(
let proposalDetails = null; let proposalDetails = null;
let completionDetails = null; let completionDetails = null;
let internalOrder = null; let internalOrder = null;
let internalOrders = [];
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try { try {
@ -531,6 +532,7 @@ export function useRequestDetails(
proposalDetails = claimData.proposalDetails || claimData.proposal_details; proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details; completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null; internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables // New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null; const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
@ -565,7 +567,6 @@ export function useRequestDetails(
description: wf.description, description: wf.description,
priority, priority,
status: statusMap(wf.status), status: statusMap(wf.status),
workflowState: wf.workflowState,
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'), workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
summary, summary,
initiator: { initiator: {
@ -595,6 +596,7 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null, proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null, completionDetails: completionDetails || null,
internalOrder: internalOrder || null, internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility) // New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null, budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
@ -653,21 +655,13 @@ export function useRequestDetails(
/** /**
* Computed: Get final request object with fallback to static databases * Computed: Get final request object with fallback to static databases
* Priority: API data Custom DB Claim DB Dynamic props null * Priority: API data Custom Database Claim Database Dynamic props null
*/ */
const request = useMemo(() => { const request = useMemo(() => {
// Primary source: API data // Primary source: API data
if (apiRequest) return apiRequest; if (apiRequest) return apiRequest;
// Fallback 1: Static custom request database // Fallback: Dynamic requests passed as props
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) => const dynamicRequest = dynamicRequests.find((req: any) =>
req.id === requestIdentifier || req.id === requestIdentifier ||
req.requestNumber === requestIdentifier || req.requestNumber === requestIdentifier ||

View File

@ -5,6 +5,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { AuthenticatedApp } from './pages/Auth'; import { AuthenticatedApp } from './pages/Auth';
import { store } from './redux/store'; import { store } from './redux/store';
import './styles/globals.css'; import './styles/globals.css';
import './styles/base-layout.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>

View File

@ -1,22 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Trash2, Search, FileText, AlertTriangle } from 'lucide-react'; import { Plus, Pencil, Search, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { import { getTemplates, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { getTemplates, deleteTemplate, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
export function AdminTemplatesList() { export function AdminTemplatesList() {
@ -25,8 +15,6 @@ export function AdminTemplatesList() {
// Only show full loading skeleton if we don't have any data yet // Only show full loading skeleton if we don't have any data yet
const [loading, setLoading] = useState(() => !getCachedTemplates()); const [loading, setLoading] = useState(() => !getCachedTemplates());
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchTemplates = async () => { const fetchTemplates = async () => {
try { try {
@ -49,22 +37,6 @@ export function AdminTemplatesList() {
fetchTemplates(); fetchTemplates();
}, []); }, []);
const handleDelete = async () => {
if (!deleteId) return;
try {
setDeleting(true);
await deleteTemplate(deleteId);
toast.success('Template deleted successfully');
setTemplates(prev => prev.filter(t => t.id !== deleteId));
} catch (error) {
console.error('Failed to delete template:', error);
toast.error('Failed to delete template');
} finally {
setDeleting(false);
setDeleteId(null);
}
};
const filteredTemplates = templates.filter(template => const filteredTemplates = templates.filter(template =>
template.name.toLowerCase().includes(searchQuery.toLowerCase()) || template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
@ -152,7 +124,7 @@ export function AdminTemplatesList() {
</Badge> </Badge>
</div> </div>
<CardTitle className="line-clamp-1 text-lg">{template.name}</CardTitle> <CardTitle className="line-clamp-1 text-lg">{template.name}</CardTitle>
<CardDescription className="line-clamp-2 h-10"> <CardDescription className="line-clamp-3 min-h-[4.5rem]">
{template.description} {template.description}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -181,14 +153,6 @@ export function AdminTemplatesList() {
<Pencil className="w-4 h-4 mr-2" /> <Pencil className="w-4 h-4 mr-2" />
Edit Edit
</Button> </Button>
<Button
variant="outline"
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-100"
onClick={() => setDeleteId(template.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -196,33 +160,6 @@ export function AdminTemplatesList() {
</div> </div>
)} )}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
Delete Template
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this template? This action cannot be undone.
Active requests using this template will not be affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
className="bg-red-600 hover:bg-red-700"
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View File

@ -2,13 +2,13 @@ import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { LogIn } from 'lucide-react'; import { LogIn, Shield } from 'lucide-react';
import { ReLogo, LandingPageImage } from '@/assets'; import { ReLogo, LandingPageImage } from '@/assets';
// import { initiateTanflowLogin } from '@/services/tanflowAuth'; import { initiateTanflowLogin } from '@/services/tanflowAuth';
export function Auth() { export function Auth() {
const { login, isLoading, error } = useAuth(); const { login, isLoading, error } = useAuth();
const [tanflowLoading] = useState(false); const [tanflowLoading, setTanflowLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
// Preload the background image // Preload the background image
@ -41,7 +41,7 @@ export function Auth() {
} }
}; };
/* const handleTanflowLogin = () => { const handleTanflowLogin = () => {
// Clear any existing session data // Clear any existing session data
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
@ -55,7 +55,7 @@ export function Auth() {
console.error('Error details:', loginError); console.error('Error details:', loginError);
setTanflowLoading(false); setTanflowLoading(false);
} }
}; */ };
if (error) { if (error) {
console.error('Auth Error in Auth Component:', { console.error('Auth Error in Auth Component:', {
@ -123,7 +123,7 @@ export function Auth() {
</> </>
)} )}
</Button> </Button>
{/*
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-700"></span> <span className="w-full border-t border-gray-700"></span>
@ -152,7 +152,7 @@ export function Auth() {
Dealer Login Dealer Login
</> </>
)} )}
</Button> */} </Button>
</div> </div>
<div className="text-center text-sm text-gray-400 mt-4"> <div className="text-center text-sm text-gray-400 mt-4">

View File

@ -8,7 +8,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowRight, Calendar, CheckCircle } from 'lucide-react'; import { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { ClosedRequest } from '../types/closedRequests.types'; import { ClosedRequest } from '../types/closedRequests.types';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
interface ClosedRequestCardProps { interface ClosedRequestCardProps {
request: ClosedRequest; request: ClosedRequest;
@ -18,7 +18,6 @@ interface ClosedRequestCardProps {
export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) { export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) {
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status); const statusConfig = getStatusConfig(request.status);
const stateConfig = getWorkflowStateConfig(request.workflowState || 'CLOSED');
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
@ -51,12 +50,6 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
<StatusIcon className="w-3.5 h-3.5 mr-1" /> <StatusIcon className="w-3.5 h-3.5 mr-1" />
{statusConfig.label} {statusConfig.label}
</Badge> </Badge>
<Badge
variant="outline"
className={`${stateConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
>
{stateConfig.label}
</Badge>
{request.department && ( {request.department && (
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex"> <Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
{request.department} {request.department}

View File

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

View File

@ -8,7 +8,7 @@ export interface ClosedRequest {
displayId?: string; displayId?: string;
title: string; title: string;
description: string; description: string;
status: 'rejected' | 'closed' | 'approved'; status: 'rejected' | 'closed';
priority: 'express' | 'standard'; priority: 'express' | 'standard';
initiator: { name: string; avatar: string }; initiator: { name: string; avatar: string };
createdAt: string; createdAt: string;
@ -18,7 +18,6 @@ export interface ClosedRequest {
totalLevels?: number; totalLevels?: number;
completedLevels?: number; completedLevels?: number;
templateType?: string; // Template type for badge display templateType?: string; // Template type for badge display
workflowState?: string;
} }
export interface ClosedRequestsProps { export interface ClosedRequestsProps {

View File

@ -38,14 +38,6 @@ export function getStatusConfig(status: string): StatusConfig {
label: 'Closed', label: 'Closed',
description: 'Request finalized and archived' description: 'Request finalized and archived'
}; };
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
icon: CheckCircle,
iconColor: 'text-green-600',
label: 'Approved',
description: 'Request was approved'
};
case 'rejected': case 'rejected':
return { return {
color: 'bg-red-100 text-red-800 border-red-300', color: 'bg-red-100 text-red-800 border-red-300',
@ -65,25 +57,3 @@ export function getStatusConfig(status: string): StatusConfig {
} }
} }
export function getWorkflowStateConfig(state: string) {
const s = (state || '').toUpperCase();
switch (s) {
case 'CLOSED':
return {
color: 'bg-slate-100 text-slate-800 border-slate-200',
label: 'closed'
};
case 'DRAFT':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft'
};
case 'OPEN':
default:
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'open'
};
}
}

View File

@ -11,7 +11,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
displayId: r.requestNumber || r.request_number || r.requestId, displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title, title: r.title,
description: r.description, description: r.description,
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed' | 'approved', status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed',
priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard', priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
initiator: { initiator: {
name: r.initiator?.displayName || r.initiator?.email || '—', name: r.initiator?.displayName || r.initiator?.email || '—',
@ -29,7 +29,6 @@ export function transformClosedRequest(r: any): ClosedRequest {
totalLevels: r.totalLevels || 0, totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0, completedLevels: r.summary?.approvedLevels || 0,
templateType: r.templateType || r.template_type, // Template type for badge display templateType: r.templateType || r.template_type, // Template type for badge display
workflowState: r.workflowState || r.workflow_state,
}; };
} }

View File

@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { FileText, AlertCircle } from 'lucide-react'; import { FileText, AlertCircle } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm'; import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { sanitizeHTML } from '@/utils/sanitizer';
interface AdminRequestReviewStepProps { interface AdminRequestReviewStepProps {
template: RequestTemplate; template: RequestTemplate;
@ -47,7 +48,7 @@ export function AdminRequestReviewStep({
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span> <span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
<div <div
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none" className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: formData.description }} dangerouslySetInnerHTML={{ __html: sanitizeHTML(formData.description) }}
/> />
</div> </div>

View File

@ -11,8 +11,6 @@ import {
validateApproversForSubmission, validateApproversForSubmission,
} from '../utils/payloadBuilders'; } from '../utils/payloadBuilders';
import { import {
createAndSubmitWorkflow,
updateAndSubmitWorkflow,
createWorkflow, createWorkflow,
updateWorkflowRequest, updateWorkflowRequest,
} from '../services/createRequestService'; } from '../services/createRequestService';
@ -59,14 +57,15 @@ export function useCreateRequestSubmission({
try { try {
if (isEditing && editRequestId) { if (isEditing && editRequestId) {
// Update existing workflow // Update existing workflow with isDraft: false (Submit)
const updatePayload = buildUpdatePayload( const updatePayload = buildUpdatePayload(
formData, formData,
user, user,
documentsToDelete documentsToDelete,
false
); );
await updateAndSubmitWorkflow( await updateWorkflowRequest(
editRequestId, editRequestId,
updatePayload, updatePayload,
documents, documents,
@ -85,14 +84,15 @@ export function useCreateRequestSubmission({
template: selectedTemplate, template: selectedTemplate,
}); });
} else { } else {
// Create new workflow // Create new workflow with isDraft: false (Submit)
const createPayload = buildCreatePayload( const createPayload = buildCreatePayload(
formData, formData,
selectedTemplate, selectedTemplate,
user user,
false
); );
const result = await createAndSubmitWorkflow(createPayload, documents); const result = await createWorkflow(createPayload, documents);
// Show toast after backend confirmation // Show toast after backend confirmation
toast.success('Request Submitted Successfully!', { toast.success('Request Submitted Successfully!', {
@ -133,13 +133,13 @@ export function useCreateRequestSubmission({
try { try {
if (isEditing && editRequestId) { if (isEditing && editRequestId) {
// Update existing draft // Update existing draft with isDraft: true
const updatePayload = buildUpdatePayload( const updatePayload = buildUpdatePayload(
formData, formData,
user, user,
documentsToDelete documentsToDelete,
true
); );
(updatePayload as any).isDraft = true;
await updateWorkflowRequest( await updateWorkflowRequest(
editRequestId, editRequestId,
@ -159,13 +159,13 @@ export function useCreateRequestSubmission({
template: selectedTemplate, template: selectedTemplate,
}); });
} else { } else {
// Create new draft // Create new draft with isDraft: true
const createPayload = buildCreatePayload( const createPayload = buildCreatePayload(
formData, formData,
selectedTemplate, selectedTemplate,
user user,
true
); );
(createPayload as any).isDraft = true;
const result = await createWorkflow(createPayload, documents); const result = await createWorkflow(createPayload, documents);

View File

@ -4,7 +4,6 @@
import { import {
createWorkflowMultipart, createWorkflowMultipart,
submitWorkflow,
updateWorkflow, updateWorkflow,
updateWorkflowMultipart, updateWorkflowMultipart,
} from '@/services/workflowApi'; } from '@/services/workflowApi';
@ -14,7 +13,7 @@ import {
} from '../types/createRequest.types'; } from '../types/createRequest.types';
/** /**
* Create a new workflow * Create a new workflow (supports both draft and direct submission via isDraft flag)
*/ */
export async function createWorkflow( export async function createWorkflow(
payload: CreateWorkflowPayload, payload: CreateWorkflowPayload,
@ -29,7 +28,7 @@ export async function createWorkflow(
} }
/** /**
* Update an existing workflow * Update an existing workflow (supports both draft and direct submission via isDraft flag)
*/ */
export async function updateWorkflowRequest( export async function updateWorkflowRequest(
requestId: string, requestId: string,
@ -51,30 +50,3 @@ export async function updateWorkflowRequest(
await updateWorkflow(requestId, payload); await updateWorkflow(requestId, payload);
} }
} }
/**
* Submit a workflow
*/
export async function submitWorkflowRequest(requestId: string): Promise<void> {
await submitWorkflow(requestId);
}
export async function createAndSubmitWorkflow(
payload: CreateWorkflowPayload,
documents: File[]
): Promise<{ id: string }> {
// Pass isDraft: false (or omit) to trigger backend auto-submit
const res = await createWorkflow({ ...payload, isDraft: false }, documents);
return res;
}
export async function updateAndSubmitWorkflow(
requestId: string,
payload: UpdateWorkflowPayload,
documents: File[],
documentsToDelete: string[]
): Promise<void> {
// Pass isDraft: false (or omit) to trigger backend auto-submit
await updateWorkflowRequest(requestId, { ...payload, isDraft: false }, documents, documentsToDelete);
}

View File

@ -17,16 +17,9 @@ import { buildApprovalLevels } from './approvalLevelBuilders';
export function buildCreatePayload( export function buildCreatePayload(
formData: FormData, formData: FormData,
selectedTemplate: RequestTemplate | null, selectedTemplate: RequestTemplate | null,
_user: any _user: any,
isDraft: boolean = false
): CreateWorkflowPayload { ): CreateWorkflowPayload {
// Filter out spectators who are also approvers (backend will handle validation)
const approverEmails = new Set(
(formData.approvers || []).map((a: any) => a?.email?.toLowerCase()).filter(Boolean)
);
const filteredSpectators = (formData.spectators || []).filter(
(s: any) => s?.email && !approverEmails.has(s.email.toLowerCase())
);
return { return {
templateId: selectedTemplate?.id || null, templateId: selectedTemplate?.id || null,
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE', templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE',
@ -38,16 +31,17 @@ export function buildCreatePayload(
userId: a?.userId || '', userId: a?.userId || '',
email: a?.email || '', email: a?.email || '',
name: a?.name, name: a?.name,
tat: a?.tat || '', tat: a?.tat || 24,
tatType: a?.tatType || 'hours', tatType: a?.tatType || 'hours',
})), })),
spectators: filteredSpectators.map((s: any) => ({ spectators: (formData.spectators || []).map((s: any) => ({
userId: s?.userId || '', userId: s?.userId || '',
name: s?.name || '', name: s?.name || '',
email: s?.email || '', email: s?.email || '',
})), })),
ccList: [], // Auto-generated by backend ccList: [], // Auto-generated by backend
participants: [], // Auto-generated by backend from approvers and spectators participants: [], // Auto-generated by backend from approvers and spectators
isDraft,
}; };
} }
@ -58,7 +52,8 @@ export function buildCreatePayload(
export function buildUpdatePayload( export function buildUpdatePayload(
formData: FormData, formData: FormData,
_user: any, _user: any,
documentsToDelete: string[] documentsToDelete: string[],
isDraft: boolean = false
): UpdateWorkflowPayload { ): UpdateWorkflowPayload {
const approvalLevels = buildApprovalLevels( const approvalLevels = buildApprovalLevels(
formData.approvers || [], formData.approvers || [],
@ -72,6 +67,7 @@ export function buildUpdatePayload(
approvalLevels, approvalLevels,
participants: [], // Auto-generated by backend from approval levels participants: [], // Auto-generated by backend from approval levels
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined, deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
isDraft,
}; };
} }
@ -112,4 +108,3 @@ export function validateApproversForSubmission(
return { valid: true }; return { valid: true };
} }

View File

@ -71,8 +71,8 @@ export function AdminKPICards({
}} }}
/> />
</div> </div>
{/* Row 2: Pending and Paused */} {/* Row 2: Pending and Closed */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2 mb-2">
<StatCard <StatCard
label="Pending" label="Pending"
value={kpis?.requestVolume.openRequests || 0} value={kpis?.requestVolume.openRequests || 0}
@ -84,7 +84,21 @@ export function AdminKPICards({
onKPIClick({ ...getFilterParams(), status: 'pending' }); onKPIClick({ ...getFilterParams(), status: 'pending' });
}} }}
/> />
<StatCard
label="Closed"
value={kpis?.requestVolume.closedRequests || 0}
bgColor="bg-gray-50"
textColor="text-gray-600"
testId="stat-closed"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'closed' });
}}
/>
</div>
{/* Row 3: Paused (if available) */}
{kpis?.requestVolume.pausedRequests !== undefined && ( {kpis?.requestVolume.pausedRequests !== undefined && (
<div className="grid grid-cols-2 gap-2">
<StatCard <StatCard
label="Paused" label="Paused"
value={kpis.requestVolume.pausedRequests || 0} value={kpis.requestVolume.pausedRequests || 0}
@ -96,8 +110,8 @@ export function AdminKPICards({
onKPIClick({ ...getFilterParams(), status: 'paused' }); onKPIClick({ ...getFilterParams(), status: 'paused' });
}} }}
/> />
)}
</div> </div>
)}
</KPICard> </KPICard>
{/* SLA Compliance */} {/* SLA Compliance */}

View File

@ -33,8 +33,7 @@ export function CriticalAlertsSection({
}: CriticalAlertsSectionProps) { }: CriticalAlertsSectionProps) {
return ( return (
<Card <Card
className="lg:col-span-2 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden" className="lg:col-span-2 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden h-full"
style={{ height: '100%' }}
data-testid="critical-alerts-section" data-testid="critical-alerts-section"
> >
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0"> <CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
@ -60,8 +59,7 @@ export function CriticalAlertsSection({
</div> </div>
</CardHeader> </CardHeader>
<CardContent <CardContent
className="overflow-y-auto flex-1 p-4" className={`overflow-y-auto flex-1 p-4 ${pagination.totalPages > 1 ? 'max-h-[calc(100%-140px)]' : 'max-h-[calc(100%-80px)]'}`}
style={{ maxHeight: pagination.totalPages > 1 ? 'calc(100% - 140px)' : 'calc(100% - 80px)' }}
> >
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
{breachedRequests.length === 0 ? ( {breachedRequests.length === 0 ? (

View File

@ -84,11 +84,7 @@ export function PriorityDistributionReport({
fill="#1f2937" fill="#1f2937"
textAnchor={x > cx ? 'start' : 'end'} textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central" dominantBaseline="central"
style={{ className="text-sm font-semibold pointer-events-none"
fontSize: '14px',
fontWeight: '600',
pointerEvents: 'none',
}}
> >
{`${name}: ${percentage}%`} {`${name}: ${percentage}%`}
</text> </text>
@ -102,13 +98,13 @@ export function PriorityDistributionReport({
onNavigate(`requests?priority=${data.priority}`); onNavigate(`requests?priority=${data.priority}`);
} }
}} }}
style={{ cursor: 'pointer' }} className="cursor-pointer"
> >
{priorityDistribution.map((priority, index) => ( {priorityDistribution.map((priority, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'} fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'}
style={{ cursor: 'pointer' }} className="cursor-pointer"
/> />
))} ))}
</Pie> </Pie>

View File

@ -40,8 +40,7 @@ export function RecentActivitySection({
}: RecentActivitySectionProps) { }: RecentActivitySectionProps) {
return ( return (
<Card <Card
className="lg:col-span-1 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden" className="lg:col-span-1 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden h-full"
style={{ height: '100%' }}
data-testid="recent-activity-section" data-testid="recent-activity-section"
> >
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0"> <CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
@ -73,8 +72,7 @@ export function RecentActivitySection({
</div> </div>
</CardHeader> </CardHeader>
<CardContent <CardContent
className="overflow-y-auto flex-1 p-4" className={`overflow-y-auto flex-1 p-4 ${pagination.totalPages > 1 ? 'max-h-[calc(100%-140px)]' : 'max-h-[calc(100%-80px)]'}`}
style={{ maxHeight: pagination.totalPages > 1 ? 'calc(100% - 140px)' : 'calc(100% - 80px)' }}
> >
<div className="space-y-2 sm:space-y-3"> <div className="space-y-2 sm:space-y-3">
{recentActivity.length === 0 ? ( {recentActivity.length === 0 ? (

View File

@ -62,7 +62,7 @@ export function TATBreachReport({
</div> </div>
</div> </div>
<Badge variant="destructive" className="text-sm font-medium self-start sm:self-auto"> <Badge variant="destructive" className="text-sm font-medium self-start sm:self-auto">
{breachedRequests.length} {breachedRequests.length === 1 ? 'Breach' : 'Breaches'} {pagination.totalRecords} {pagination.totalRecords === 1 ? 'Breach' : 'Breaches'}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
@ -164,8 +164,7 @@ export function TATBreachReport({
<td className="py-3 px-4"> <td className="py-3 px-4">
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs font-medium ${ className={`text-xs font-medium ${req.priority === 'express'
req.priority === 'express'
? 'bg-orange-100 text-orange-800 border-orange-200' ? 'bg-orange-100 text-orange-800 border-orange-200'
: 'bg-blue-100 text-blue-800 border-blue-200' : 'bg-blue-100 text-blue-800 border-blue-200'
}`} }`}

View File

@ -9,7 +9,7 @@ import { Progress } from '@/components/ui/progress';
import { Calendar as CalendarIcon } from 'lucide-react'; import { Calendar as CalendarIcon } from 'lucide-react';
import { UpcomingDeadline } from '@/services/dashboard.service'; import { UpcomingDeadline } from '@/services/dashboard.service';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import { formatBreachTime } from '../../utils/dashboardCalculations'; import { formatHoursMinutes } from '@/utils/slaTracker';
interface UpcomingDeadlinesSectionProps { interface UpcomingDeadlinesSectionProps {
isAdmin: boolean; isAdmin: boolean;
@ -67,7 +67,8 @@ export function UpcomingDeadlinesSection({
<span className="font-semibold text-xs sm:text-sm">{deadline.requestNumber}</span> <span className="font-semibold text-xs sm:text-sm">{deadline.requestNumber}</span>
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs ${deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700' className={`text-xs ${
deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
}`} }`}
> >
{deadline.priority} {deadline.priority}
@ -84,7 +85,8 @@ export function UpcomingDeadlinesSection({
<div className="text-right flex-shrink-0"> <div className="text-right flex-shrink-0">
<p className="text-xs text-muted-foreground">TAT Used</p> <p className="text-xs text-muted-foreground">TAT Used</p>
<p <p
className={`text-base sm:text-lg font-bold ${tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600' className={`text-base sm:text-lg font-bold ${
tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
}`} }`}
> >
{tatPercentage.toFixed(0)}% {tatPercentage.toFixed(0)}%
@ -94,12 +96,13 @@ export function UpcomingDeadlinesSection({
<div className="space-y-1"> <div className="space-y-1">
<Progress <Progress
value={tatPercentage} value={tatPercentage}
className={`h-1.5 sm:h-2 ${tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100' className={`h-1.5 sm:h-2 ${
tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
}`} }`}
/> />
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>{formatBreachTime(elapsedHours)} elapsed</span> <span>{formatHoursMinutes(elapsedHours)} elapsed</span>
<span>{formatBreachTime(Math.abs(remainingHours))} {remainingHours < 0 ? 'overdue' : 'left'}</span> <span>{formatHoursMinutes(remainingHours)} left</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -70,7 +70,7 @@ export function UserKPICards({
testId="kpi-my-requests" testId="kpi-my-requests"
onClick={() => onKPIClick(getFilterParams())} onClick={() => onKPIClick(getFilterParams())}
> >
<div className="grid grid-cols-2 gap-1.5 sm:gap-2"> <div className="grid grid-cols-3 gap-1.5 sm:gap-2">
<StatCard <StatCard
label="Approved" label="Approved"
value={kpis?.requestVolume.approvedRequests || 0} value={kpis?.requestVolume.approvedRequests || 0}
@ -115,6 +115,17 @@ export function UserKPICards({
onKPIClick({ ...getFilterParams(), status: 'rejected' }); onKPIClick({ ...getFilterParams(), status: 'rejected' });
}} }}
/> />
<StatCard
label="Closed"
value={kpis?.requestVolume.closedRequests || 0}
bgColor="bg-blue-50"
textColor="text-blue-600"
testId="stat-user-closed"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'closed' });
}}
/>
</div> </div>
</KPICard> </KPICard>

View File

@ -38,7 +38,6 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter, templateTypeFilter: filters.templateTypeFilter,
lifecycleFilter: filters.lifecycleFilter,
}); });
const hasInitialFetchRun = useRef(false); const hasInitialFetchRun = useRef(false);
@ -50,7 +49,6 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
}); });
hasInitialFetchRun.current = true; hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -65,8 +63,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
prev.searchTerm !== filters.searchTerm || prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter || prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter || prev.priorityFilter !== filters.priorityFilter ||
prev.templateTypeFilter !== filters.templateTypeFilter || prev.templateTypeFilter !== filters.templateTypeFilter;
prev.lifecycleFilter !== filters.lifecycleFilter;
if (!hasChanged) return; // No actual change, skip if (!hasChanged) return; // No actual change, skip
@ -78,7 +75,6 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
}); });
// Update previous values // Update previous values
@ -87,13 +83,12 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter, templateTypeFilter: filters.templateTypeFilter,
lifecycleFilter: filters.lifecycleFilter,
}; };
}, filters.searchTerm !== prev.searchTerm ? 500 : 0); }, filters.searchTerm !== prev.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]); }, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter]);
// State for backend stats (calculated from entire dataset via SQL queries) // State for backend stats (calculated from entire dataset via SQL queries)
const [backendStats, setBackendStats] = useState<{ const [backendStats, setBackendStats] = useState<{
@ -136,8 +131,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
undefined, // approverType undefined, // approverType
filters.searchTerm || undefined, filters.searchTerm || undefined,
undefined, // slaCompliance undefined, // slaCompliance
true, // viewAsUser - treat as normal user even if admin true // viewAsUser - treat as normal user even if admin
filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined // lifecycle
); );
setBackendStats({ setBackendStats({
@ -155,7 +149,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
} finally { } finally {
setLoadingStats(false); setLoadingStats(false);
} }
}, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]); // Exclude statusFilter - stats don't change when only status changes }, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter]); // Exclude statusFilter - stats don't change when only status changes
// Fetch stats when filters change (excluding status filter) // Fetch stats when filters change (excluding status filter)
// Stats should reflect priority and search filters, but NOT status filter // Stats should reflect priority and search filters, but NOT status filter
@ -166,7 +160,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
}, filters.searchTerm ? 500 : 0); }, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes }, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes
// Handle dynamic requests (fallback until API loads) // Handle dynamic requests (fallback until API loads)
const convertedDynamicRequests = transformRequests(dynamicRequests); const convertedDynamicRequests = transformRequests(dynamicRequests);
@ -210,7 +204,6 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
}); });
} }
}, },
@ -250,8 +243,6 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
onStatusChange={filters.setStatusFilter} onStatusChange={filters.setStatusFilter}
onPriorityChange={filters.setPriorityFilter} onPriorityChange={filters.setPriorityFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter} onTemplateTypeChange={filters.setTemplateTypeFilter}
lifecycleFilter={filters.lifecycleFilter}
onLifecycleChange={filters.setLifecycleFilter}
/> />
{/* Requests List */} {/* Requests List */}

View File

@ -12,25 +12,21 @@ interface MyRequestsFiltersProps {
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
lifecycleFilter: string;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void; onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void; onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void; onTemplateTypeChange: (value: string) => void;
onLifecycleChange: (value: string) => void;
} }
export function MyRequestsFilters({ export function MyRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
// templateTypeFilter, templateTypeFilter,
lifecycleFilter, // Destructure new prop
onSearchChange, onSearchChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
// onTemplateTypeChange, onTemplateTypeChange,
onLifecycleChange, // Destructure new prop
}: MyRequestsFiltersProps) { }: MyRequestsFiltersProps) {
return ( return (
<Card className="border-gray-200" data-testid="my-requests-filters"> <Card className="border-gray-200" data-testid="my-requests-filters">
@ -48,21 +44,6 @@ export function MyRequestsFilters({
</div> </div>
<div className="flex gap-2 sm:gap-3 w-full md:w-auto"> <div className="flex gap-2 sm:gap-3 w-full md:w-auto">
{/* Lifecycle Filter */}
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
<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="lifecycle-filter"
>
<SelectValue placeholder="Lifecycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Requests</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}> <Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger <SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10" className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
@ -77,6 +58,7 @@ export function MyRequestsFilters({
<SelectItem value="paused">Paused</SelectItem> <SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem> <SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -94,7 +76,7 @@ export function MyRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger <SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10" className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
data-testid="template-type-filter" data-testid="template-type-filter"
@ -106,7 +88,7 @@ export function MyRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> */} </Select>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -2,7 +2,7 @@
* My Requests Stats Section Component * My Requests Stats Section Component
*/ */
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit } from 'lucide-react'; import { FileText, Clock, Pause, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard'; import { StatsCard } from '@/components/dashboard/StatsCard';
import { MyRequestsStats } from '../types/myRequests.types'; import { MyRequestsStats } from '../types/myRequests.types';
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
} }
}; };
return ( return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="my-requests-stats"> <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4" data-testid="my-requests-stats">
<StatsCard <StatsCard
label="Total" label="Total"
value={stats.total} value={stats.total}
@ -90,6 +90,18 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
testId="stat-draft" testId="stat-draft"
onClick={onStatusFilter ? () => handleCardClick('draft') : undefined} onClick={onStatusFilter ? () => handleCardClick('draft') : undefined}
/> />
<StatsCard
label="Closed"
value={stats.closed}
icon={Archive}
iconColor="text-purple-600"
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
textColor="text-purple-700"
valueColor="text-purple-900"
testId="stat-closed"
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
/>
</div> </div>
); );
} }

View File

@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react'; import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { MyRequest } from '../types/myRequests.types'; import { MyRequest } from '../types/myRequests.types';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
/** /**
@ -17,22 +17,28 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
const stripHtmlTags = (html: string): string => { const stripHtmlTags = (html: string): string => {
if (!html) return ''; if (!html) return '';
// Check if we're in a browser environment // 1. Replace block-level tags with a space to avoid merging words (e.g. </div><div> -> " ")
if (typeof document === 'undefined') { // This preserves readability for the card preview
// Fallback for SSR: use regex to strip HTML tags let text = html.replace(/<(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|tfoot|ul|video)[^>]*>/gi, ' ');
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
}
// Create a temporary div to parse HTML // 2. Replace <br> with space
const tempDiv = document.createElement('div'); text = text.replace(/<br\s*\/?>/gi, ' ');
tempDiv.innerHTML = html;
// Get text content (automatically strips HTML tags) // 3. Strip all other tags
let text = tempDiv.textContent || tempDiv.innerText || ''; text = text.replace(/<[^>]*>/g, '');
// Clean up extra whitespace // 4. Clean up extra whitespace
text = text.replace(/\s+/g, ' ').trim(); text = text.replace(/\s+/g, ' ').trim();
// 5. Basic HTML entity decoding for common characters
text = text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'");
return text; return text;
}; };
@ -44,7 +50,6 @@ interface RequestCardProps {
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) { export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
const statusConfig = getStatusConfig(request.status); const statusConfig = getStatusConfig(request.status);
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@ -80,15 +85,6 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<StatusIcon className="w-3 h-3 mr-1" /> <StatusIcon className="w-3 h-3 mr-1" />
<span className="capitalize">{request.status}</span> <span className="capitalize">{request.status}</span>
</Badge> </Badge>
{stateConfig.label.toLowerCase() !== request.status.toLowerCase() && (
<Badge
variant="outline"
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
data-testid="state-badge"
>
<span className="capitalize">{stateConfig.label}</span>
</Badge>
)}
{(request.pauseInfo?.isPaused || (request as any).isPaused) && ( {(request.pauseInfo?.isPaused || (request as any).isPaused) && (
<Badge <Badge
variant="outline" variant="outline"

View File

@ -14,7 +14,6 @@ interface UseMyRequestsOptions {
status?: string; status?: string;
priority?: string; priority?: string;
templateType?: string; templateType?: string;
lifecycle?: string;
}; };
} }
@ -30,7 +29,7 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
}); });
const fetchMyRequests = useCallback( const fetchMyRequests = useCallback(
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string, lifecycle?: string }) => { async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
@ -44,7 +43,6 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
status: filters?.status, status: filters?.status,
priority: filters?.priority, priority: filters?.priority,
templateType: filters?.templateType, templateType: filters?.templateType,
lifecycle: filters?.lifecycle,
}); });
// Extract data - workflowApi now returns { data: [], pagination: {} } // Extract data - workflowApi now returns { data: [], pagination: {} }

View File

@ -11,7 +11,6 @@ import {
setPriorityFilter as setPriorityFilterAction, setPriorityFilter as setPriorityFilterAction,
setTemplateTypeFilter as setTemplateTypeFilterAction, setTemplateTypeFilter as setTemplateTypeFilterAction,
setCurrentPage as setCurrentPageAction, setCurrentPage as setCurrentPageAction,
setLifecycleFilter as setLifecycleFilterAction,
clearFilters as clearFiltersAction, clearFilters as clearFiltersAction,
} from '../redux/myRequestsSlice'; } from '../redux/myRequestsSlice';
@ -26,7 +25,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
// Get filters from Redux // Get filters from Redux
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage, lifecycleFilter } = useAppSelector((state) => state.myRequests); const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage } = useAppSelector((state) => state.myRequests);
// Create setters that dispatch Redux actions // Create setters that dispatch Redux actions
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]); const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
@ -34,7 +33,6 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]); const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]); const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]); const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
const getFilters = useCallback((): MyRequestsFilters => { const getFilters = useCallback((): MyRequestsFilters => {
return { return {
@ -42,9 +40,8 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
status: statusFilter, status: statusFilter,
priority: priorityFilter, priority: priorityFilter,
templateType: templateTypeFilter, templateType: templateTypeFilter,
lifecycle: lifecycleFilter,
}; };
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter]); }, [searchTerm, statusFilter, priorityFilter, templateTypeFilter]);
// Debounced filter change handler // Debounced filter change handler
useEffect(() => { useEffect(() => {
@ -71,7 +68,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
clearTimeout(debounceTimeoutRef.current); clearTimeout(debounceTimeoutRef.current);
} }
}; };
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter, onFiltersChange, getFilters, debounceMs]); }, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, onFiltersChange, getFilters, debounceMs]);
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
dispatch(clearFiltersAction()); dispatch(clearFiltersAction());
@ -83,13 +80,11 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
priorityFilter, priorityFilter,
templateTypeFilter, templateTypeFilter,
currentPage, currentPage,
lifecycleFilter,
setSearchTerm, setSearchTerm,
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setCurrentPage, setCurrentPage,
setLifecycleFilter,
getFilters, getFilters,
resetFilters, resetFilters,
}; };

View File

@ -6,7 +6,6 @@ export interface MyRequestsFiltersState {
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
currentPage: number; currentPage: number;
lifecycleFilter: string;
} }
const initialState: MyRequestsFiltersState = { const initialState: MyRequestsFiltersState = {
@ -15,7 +14,6 @@ const initialState: MyRequestsFiltersState = {
priorityFilter: 'all', priorityFilter: 'all',
templateTypeFilter: 'all', templateTypeFilter: 'all',
currentPage: 1, currentPage: 1,
lifecycleFilter: 'all',
}; };
const myRequestsSlice = createSlice({ const myRequestsSlice = createSlice({
@ -39,16 +37,12 @@ const myRequestsSlice = createSlice({
setCurrentPage: (state, action: PayloadAction<number>) => { setCurrentPage: (state, action: PayloadAction<number>) => {
state.currentPage = action.payload; state.currentPage = action.payload;
}, },
setLifecycleFilter: (state, action: PayloadAction<string>) => {
state.lifecycleFilter = action.payload;
},
clearFilters: (state) => { clearFilters: (state) => {
state.searchTerm = ''; state.searchTerm = '';
state.statusFilter = 'all'; state.statusFilter = 'all';
state.priorityFilter = 'all'; state.priorityFilter = 'all';
state.templateTypeFilter = 'all'; state.templateTypeFilter = 'all';
state.currentPage = 1; state.currentPage = 1;
state.lifecycleFilter = 'all';
}, },
}, },
}); });
@ -59,7 +53,6 @@ export const {
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter, setTemplateTypeFilter,
setCurrentPage, setCurrentPage,
setLifecycleFilter,
clearFilters, clearFilters,
} = myRequestsSlice.actions; } = myRequestsSlice.actions;

View File

@ -17,7 +17,6 @@ export interface MyRequest {
approverLevel?: string; approverLevel?: string;
templateType?: string; templateType?: string;
workflowType?: string; workflowType?: string;
workflowState?: string;
templateName?: string; templateName?: string;
pauseInfo?: { pauseInfo?: {
isPaused: boolean; isPaused: boolean;
@ -42,7 +41,6 @@ export interface MyRequestsFilters {
status: string; status: string;
priority: string; priority: string;
templateType?: string; templateType?: string;
lifecycle?: string;
} }
export interface PaginationState { export interface PaginationState {

View File

@ -87,25 +87,3 @@ export function getStatusConfig(status: string): StatusConfig {
} }
} }
export function getWorkflowStateConfig(state: string) {
const s = (state || '').toUpperCase();
switch (s) {
case 'CLOSED':
return {
color: 'bg-slate-100 text-slate-800 border-slate-200',
label: 'closed',
};
case 'DRAFT':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft',
};
case 'OPEN':
default:
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'open',
};
}
}

View File

@ -31,7 +31,6 @@ export function transformRequest(req: any): MyRequest {
: '—', : '—',
templateType: req.templateType || req.template_type, templateType: req.templateType || req.template_type,
workflowType: req.workflowType || req.workflow_type, workflowType: req.workflowType || req.workflow_type,
workflowState: req.workflowState || req.workflow_state,
templateName: req.templateName || req.template_name, templateName: req.templateName || req.template_name,
}; };
} }

View File

@ -57,7 +57,7 @@ export function QuickActionsSidebar({
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]); const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false); const [loadingRecipients, setLoadingRecipients] = useState(false);
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false); const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
const isClosed = apiRequest?.workflowState === 'CLOSED' || request?.status === 'closed'; const isClosed = request?.status === 'closed';
const isPaused = request?.pauseInfo?.isPaused || false; const isPaused = request?.pauseInfo?.isPaused || false;
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId; const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
const currentUserId = currentUserIdProp || (user as any)?.userId || ''; const currentUserId = currentUserIdProp || (user as any)?.userId || '';

View File

@ -5,7 +5,7 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ArrowLeft, FileText, RefreshCw, Share2 } from 'lucide-react'; import { ArrowLeft, FileText, RefreshCw, Share2 } from 'lucide-react';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '@/utils/requestDetailHelpers'; import { getPriorityConfig, getStatusConfig } from '@/utils/requestDetailHelpers';
import { SLAProgressBar } from '@/components/sla/SLAProgressBar'; import { SLAProgressBar } from '@/components/sla/SLAProgressBar';
interface RequestDetailHeaderProps { interface RequestDetailHeaderProps {
@ -32,7 +32,6 @@ export function RequestDetailHeader({
}: RequestDetailHeaderProps) { }: RequestDetailHeaderProps) {
const priorityConfig = getPriorityConfig(request?.priority || 'standard'); const priorityConfig = getPriorityConfig(request?.priority || 'standard');
const statusConfig = getStatusConfig(request?.status || 'pending'); const statusConfig = getStatusConfig(request?.status || 'pending');
const stateConfig = getWorkflowStateConfig(request?.workflowState || (request?.status === 'DRAFT' ? 'DRAFT' : 'OPEN'));
return ( return (
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header"> <div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
@ -78,15 +77,6 @@ export function RequestDetailHeader({
> >
{statusConfig.label} {statusConfig.label}
</Badge> </Badge>
{stateConfig.label.toLowerCase() !== (request?.status || '').toLowerCase() && (
<Badge
className={`${stateConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
variant="outline"
data-testid="state-badge"
>
{stateConfig.label}
</Badge>
)}
{/* Template Type Badge */} {/* Template Type Badge */}
{(() => { {(() => {
const workflowType = request?.workflowType || request?.workflow_type; const workflowType = request?.workflowType || request?.workflow_type;
@ -130,7 +120,7 @@ export function RequestDetailHeader({
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Share Summary Button - Only show for closed requests if user is initiator */} {/* Share Summary Button - Only show for closed requests if user is initiator */}
{onShareSummary && isInitiator && request?.workflowState?.toLowerCase() === 'closed' && ( {onShareSummary && isInitiator && request?.status?.toLowerCase() === 'closed' && (
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
@ -167,7 +157,8 @@ export function RequestDetailHeader({
{/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */} {/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */}
{slaData !== undefined && ( {slaData !== undefined && (
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50' <div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
}`} data-testid="sla-section"> }`} data-testid="sla-section">
<SLAProgressBar <SLAProgressBar
sla={slaData} sla={slaData}

View File

@ -35,7 +35,6 @@ interface OverviewTabProps {
generationAttempts?: number; generationAttempts?: number;
generationFailed?: boolean; generationFailed?: boolean;
maxAttemptsReached?: boolean; maxAttemptsReached?: boolean;
isClosed?: boolean;
} }
export function OverviewTab({ export function OverviewTab({
@ -58,7 +57,6 @@ export function OverviewTab({
generationAttempts = 0, generationAttempts = 0,
generationFailed = false, generationFailed = false,
maxAttemptsReached = false, maxAttemptsReached = false,
isClosed = false,
}: OverviewTabProps) { }: OverviewTabProps) {
void _onPause; // Marked as intentionally unused - available for future use void _onPause; // Marked as intentionally unused - available for future use
const { user } = useAuth(); const { user } = useAuth();
@ -186,7 +184,10 @@ export function OverviewTab({
{pauseInfo.pauseReason && ( {pauseInfo.pauseReason && (
<div> <div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label> <label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p> <FormattedDescription
content={pauseInfo.pauseReason}
className="text-sm text-gray-900 mt-1"
/>
</div> </div>
)} )}
@ -303,7 +304,7 @@ export function OverviewTab({
)} )}
{/* Read-Only Conclusion Remark */} {/* Read-Only Conclusion Remark */}
{isClosed && request.conclusionRemark && ( {request.status === 'closed' && request.conclusionRemark && (
<Card> <Card>
<CardHeader className="bg-gradient-to-r from-gray-50 to-slate-50 border-b border-gray-200"> <CardHeader className="bg-gradient-to-r from-gray-50 to-slate-50 border-b border-gray-200">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">

View File

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

View File

@ -11,7 +11,6 @@ import { useAppSelector } from '@/redux/hooks';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import type { DateRange } from '@/services/dashboard.service'; import type { DateRange } from '@/services/dashboard.service';
import dashboardService from '@/services/dashboard.service'; import dashboardService from '@/services/dashboard.service';
import userApi from '@/services/userApi';
// Components // Components
import { RequestsHeader } from './components/RequestsHeader'; import { RequestsHeader } from './components/RequestsHeader';
@ -70,7 +69,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); const [backendStats, setBackendStats] = useState<BackendStats | null>(null);
const [departments, setDepartments] = useState<string[]>([]); const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false); const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination (currentPage now in Redux) // Pagination (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@ -79,15 +77,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
// User search hooks // User search hooks
const initiatorSearch = useUserSearch({ const initiatorSearch = useUserSearch({
allUsers,
filterValue: filters.initiatorFilter, filterValue: filters.initiatorFilter,
onFilterChange: filters.setInitiatorFilter onFilterChange: filters.setInitiatorFilter,
source: 'local'
}); });
const approverSearch = useUserSearch({ const approverSearch = useUserSearch({
allUsers,
filterValue: filters.approverFilter, filterValue: filters.approverFilter,
onFilterChange: filters.setApproverFilter onFilterChange: filters.setApproverFilter,
source: 'local'
}); });
// Fetch backend stats // Fetch backend stats
@ -100,6 +98,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
statsEndDate?: Date, statsEndDate?: Date,
filtersWithoutStatus?: { filtersWithoutStatus?: {
priority?: string; priority?: string;
templateType?: string;
department?: string; department?: string;
initiator?: string; initiator?: string;
approver?: string; approver?: string;
@ -163,8 +162,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
}).length; }).length;
const closed = filteredData.filter((r: any) => { const closed = filteredData.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase(); const status = (r.status || '').toString().toUpperCase();
const state = (r.workflowState || '').toString().toUpperCase(); return status === 'CLOSED';
return (status === 'CLOSED' || state === 'CLOSED') && status !== 'APPROVED' && status !== 'REJECTED';
}).length; }).length;
setBackendStats({ setBackendStats({
@ -186,7 +184,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
statsEndDate ? statsEndDate.toISOString() : undefined, statsEndDate ? statsEndDate.toISOString() : undefined,
undefined, // status - All Requests stats show all statuses, not filtered by status undefined, // status - All Requests stats show all statuses, not filtered by status
filtersWithoutStatus?.priority, filtersWithoutStatus?.priority,
undefined, // templateType filtersWithoutStatus?.templateType,
filtersWithoutStatus?.department, filtersWithoutStatus?.department,
filtersWithoutStatus?.initiator, filtersWithoutStatus?.initiator,
filtersWithoutStatus?.approver, filtersWithoutStatus?.approver,
@ -227,20 +225,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
} }
}, []); }, []);
// Fetch users
const fetchUsers = useCallback(async () => {
try {
const usersData = await userApi.getAllUsers();
const usersList = usersData.map((user: any) => ({
userId: user.userId,
email: user.email,
displayName: user.displayName || user.email
}));
setAllUsers(usersList);
} catch (error) {
console.error('Failed to fetch users:', error);
}
}, []);
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
@ -333,8 +317,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
fetchDepartments(); fetchDepartments();
fetchUsers(); }, [fetchDepartments]);
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when filters change (excluding status) // Fetch backend stats when filters change (excluding status)
// Stats should reflect priority, department, initiator, approver, search, and date range filters // Stats should reflect priority, department, initiator, approver, search, and date range filters
@ -397,7 +380,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange, dateRange: filters.dateRange,
customStartDate: filters.customStartDate, customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate, customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
isOrgLevel, isOrgLevel,
}); });
const hasInitialFetchRun = useRef(false); const hasInitialFetchRun = useRef(false);
@ -428,7 +410,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
prev.dateRange !== filters.dateRange || prev.dateRange !== filters.dateRange ||
prev.customStartDate !== filters.customStartDate || prev.customStartDate !== filters.customStartDate ||
prev.customEndDate !== filters.customEndDate || prev.customEndDate !== filters.customEndDate ||
prev.lifecycleFilter !== filters.lifecycleFilter ||
prev.isOrgLevel !== isOrgLevel; prev.isOrgLevel !== isOrgLevel;
if (!hasChanged) return; if (!hasChanged) return;
@ -450,7 +431,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange, dateRange: filters.dateRange,
customStartDate: filters.customStartDate, customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate, customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
isOrgLevel, isOrgLevel,
}; };
}, filters.searchTerm !== prev.searchTerm ? 500 : 0); }, filters.searchTerm !== prev.searchTerm ? 500 : 0);
@ -470,8 +450,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
filters.approverFilterType, filters.approverFilterType,
filters.dateRange, filters.dateRange,
filters.customStartDate, filters.customStartDate,
filters.customEndDate, filters.customEndDate
filters.lifecycleFilter
]); ]);
// Page change handler // Page change handler
@ -558,8 +537,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
<Separator /> <Separator />
{/* Primary Filters */} {/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 sm:gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative md:col-span-2 lg:col-span-1"> <div className="relative md:col-span-3 lg:col-span-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input <Input
placeholder="Search requests..." placeholder="Search requests..."
@ -570,17 +549,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
/> />
</div> </div>
<Select value={filters.lifecycleFilter} onValueChange={filters.setLifecycleFilter}>
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
<SelectValue placeholder="Lifecycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Requests</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}> <Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
<SelectTrigger className="h-10" data-testid="status-filter"> <SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" /> <SelectValue placeholder="All Status" />
@ -591,6 +559,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
<SelectItem value="paused">Paused</SelectItem> <SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem> <SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -605,7 +574,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
</SelectContent> </SelectContent>
</Select> </Select>
{/* <Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}> <Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
<SelectTrigger className="h-10" data-testid="template-type-filter"> <SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -614,7 +583,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> */} </Select>
<Select <Select
value={filters.departmentFilter} value={filters.departmentFilter}
@ -665,7 +634,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
) : ( ) : (
<> <>
<Input <Input
placeholder="Use @ to search initiator..." placeholder="Search initiator..."
value={initiatorSearch.searchQuery} value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)} onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => { onFocus={() => {
@ -735,7 +704,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
) : ( ) : (
<> <>
<Input <Input
placeholder="Use @ to search approver..." placeholder="Search approver..."
value={approverSearch.searchQuery} value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)} onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => { onFocus={() => {

View File

@ -11,7 +11,6 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import dashboardService from '@/services/dashboard.service'; import dashboardService from '@/services/dashboard.service';
import type { DateRange } from '@/services/dashboard.service'; import type { DateRange } from '@/services/dashboard.service';
import userApi from '@/services/userApi';
// Components // Components
import { RequestsHeader } from './components/RequestsHeader'; import { RequestsHeader } from './components/RequestsHeader';
@ -96,7 +95,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
const [departments, setDepartments] = useState<string[]>([]); const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false); const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination (currentPage now in Redux) // Pagination (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@ -105,15 +103,15 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// User search hooks // User search hooks
const initiatorSearch = useUserSearch({ const initiatorSearch = useUserSearch({
allUsers,
filterValue: filters.initiatorFilter, filterValue: filters.initiatorFilter,
onFilterChange: filters.setInitiatorFilter onFilterChange: filters.setInitiatorFilter,
source: 'local'
}); });
const approverSearch = useUserSearch({ const approverSearch = useUserSearch({
allUsers,
filterValue: filters.approverFilter, filterValue: filters.approverFilter,
onFilterChange: filters.setApproverFilter onFilterChange: filters.setApproverFilter,
source: 'local'
}); });
// Fetch backend stats using dashboard API // Fetch backend stats using dashboard API
@ -180,20 +178,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
} }
}, []); }, []);
// Fetch users
const fetchUsers = useCallback(async () => {
try {
const usersData = await userApi.getAllUsers();
const usersList = usersData.map((user: any) => ({
userId: user.userId,
email: user.email,
displayName: user.displayName || user.email
}));
setAllUsers(usersList);
} catch (error) {
console.error('Failed to fetch users:', error);
}
}, []);
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
@ -253,8 +237,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
fetchDepartments(); fetchDepartments();
fetchUsers(); }, [fetchDepartments]);
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when filters change (except status filter) // Fetch backend stats when filters change (except status filter)
// OPTIMIZED: Uses backend stats API instead of fetching 100 records // OPTIMIZED: Uses backend stats API instead of fetching 100 records
@ -327,7 +310,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange, dateRange: filters.dateRange,
customStartDate: filters.customStartDate, customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate, customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
}); });
const hasInitialFetchRun = useRef(false); const hasInitialFetchRun = useRef(false);
@ -356,8 +338,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
prev.approverFilterType !== filters.approverFilterType || prev.approverFilterType !== filters.approverFilterType ||
prev.dateRange !== filters.dateRange || prev.dateRange !== filters.dateRange ||
prev.customStartDate !== filters.customStartDate || prev.customStartDate !== filters.customStartDate ||
prev.customEndDate !== filters.customEndDate || prev.customEndDate !== filters.customEndDate;
prev.lifecycleFilter !== filters.lifecycleFilter;
if (!hasChanged) return; if (!hasChanged) return;
@ -378,7 +359,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange, dateRange: filters.dateRange,
customStartDate: filters.customStartDate, customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate, customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
}; };
}, filters.searchTerm !== prev.searchTerm ? 500 : 0); }, filters.searchTerm !== prev.searchTerm ? 500 : 0);
@ -396,9 +376,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
filters.approverFilterType, filters.approverFilterType,
filters.dateRange, filters.dateRange,
filters.customStartDate, filters.customStartDate,
filters.customStartDate, filters.customEndDate
filters.customEndDate,
filters.lifecycleFilter
]); ]);
// Page change handler // Page change handler
@ -482,7 +460,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
{/* Filters - Plug-and-play pattern */} {/* Filters - Plug-and-play pattern */}
<UserAllRequestsFiltersComponent <UserAllRequestsFiltersComponent
searchTerm={filters.searchTerm} searchTerm={filters.searchTerm}
lifecycleFilter={filters.lifecycleFilter}
statusFilter={filters.statusFilter} statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter} priorityFilter={filters.priorityFilter}
templateTypeFilter={filters.templateTypeFilter} templateTypeFilter={filters.templateTypeFilter}
@ -500,7 +477,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
initiatorSearch={initiatorSearch} initiatorSearch={initiatorSearch}
approverSearch={approverSearch} approverSearch={approverSearch}
onSearchChange={filters.setSearchTerm} onSearchChange={filters.setSearchTerm}
onLifecycleChange={filters.setLifecycleFilter}
onStatusChange={filters.setStatusFilter} onStatusChange={filters.setStatusFilter}
onPriorityChange={filters.setPriorityFilter} onPriorityChange={filters.setPriorityFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter} onTemplateTypeChange={filters.setTemplateTypeFilter}

View File

@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react'; import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
import type { ConvertedRequest } from '../types/requests.types'; import type { ConvertedRequest } from '../types/requests.types';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
@ -16,22 +16,28 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
const stripHtmlTags = (html: string): string => { const stripHtmlTags = (html: string): string => {
if (!html) return ''; if (!html) return '';
// Check if we're in a browser environment // 1. Replace block-level tags with a space to avoid merging words (e.g. </div><div> -> " ")
if (typeof document === 'undefined') { // This preserves readability for the card preview
// Fallback for SSR: use regex to strip HTML tags let text = html.replace(/<(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|tfoot|ul|video)[^>]*>/gi, ' ');
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
}
// Create a temporary div to parse HTML // 2. Replace <br> with space
const tempDiv = document.createElement('div'); text = text.replace(/<br\s*\/?>/gi, ' ');
tempDiv.innerHTML = html;
// Get text content (automatically strips HTML tags) // 3. Strip all other tags
let text = tempDiv.textContent || tempDiv.innerText || ''; text = text.replace(/<[^>]*>/g, '');
// Clean up extra whitespace // 4. Clean up extra whitespace
text = text.replace(/\s+/g, ' ').trim(); text = text.replace(/\s+/g, ' ').trim();
// 5. Basic HTML entity decoding for common characters
text = text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'");
return text; return text;
}; };
@ -43,7 +49,6 @@ interface RequestCardProps {
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) { export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
const statusConfig = getStatusConfig(request.status); const statusConfig = getStatusConfig(request.status);
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
@ -79,15 +84,6 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<StatusIcon className="w-3 h-3 mr-1" /> <StatusIcon className="w-3 h-3 mr-1" />
<span className="capitalize">{request.status}</span> <span className="capitalize">{request.status}</span>
</Badge> </Badge>
{stateConfig.label.toLowerCase() !== (request.status || '').toLowerCase() && (
<Badge
variant="outline"
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
data-testid="state-badge"
>
<span className="capitalize">{stateConfig.label}</span>
</Badge>
)}
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && ( {((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
<Badge <Badge
variant="outline" variant="outline"

View File

@ -3,7 +3,7 @@
* Displays statistics cards for requests with click handlers to filter * Displays statistics cards for requests with click handlers to filter
*/ */
import { FileText, Clock, Pause, CheckCircle, XCircle } from 'lucide-react'; import { FileText, Clock, Pause, CheckCircle, XCircle, Archive } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard'; import { StatsCard } from '@/components/dashboard/StatsCard';
import type { RequestStats } from '../types/requests.types'; import type { RequestStats } from '../types/requests.types';
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
}; };
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4" data-testid="requests-stats"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="requests-stats">
<StatsCard <StatsCard
label="Total" label="Total"
value={stats.total} value={stats.total}
@ -80,6 +80,18 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
testId="stat-rejected" testId="stat-rejected"
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined} onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined}
/> />
<StatsCard
label="Closed"
value={stats.closed}
icon={Archive}
iconColor="text-purple-600"
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
textColor="text-purple-700"
valueColor="text-purple-900"
testId="stat-closed"
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
/>
</div> </div>
); );
} }

View File

@ -22,7 +22,6 @@ import {
setCustomEndDate as setCustomEndDateAction, setCustomEndDate as setCustomEndDateAction,
setShowCustomDatePicker as setShowCustomDatePickerAction, setShowCustomDatePicker as setShowCustomDatePickerAction,
setCurrentPage as setCurrentPageAction, setCurrentPage as setCurrentPageAction,
setLifecycleFilter as setLifecycleFilterAction,
clearFilters as clearFiltersAction, clearFilters as clearFiltersAction,
} from '../redux/requestsSlice'; } from '../redux/requestsSlice';
@ -45,7 +44,6 @@ export function useRequestsFilters() {
customEndDate, customEndDate,
showCustomDatePicker, showCustomDatePicker,
currentPage, currentPage,
lifecycleFilter,
} = useAppSelector((state) => state.requests); } = useAppSelector((state) => state.requests);
// Create setters that dispatch Redux actions // Create setters that dispatch Redux actions
@ -63,7 +61,6 @@ export function useRequestsFilters() {
const setCustomEndDate = useCallback((value: Date | undefined) => dispatch(setCustomEndDateAction(value)), [dispatch]); const setCustomEndDate = useCallback((value: Date | undefined) => dispatch(setCustomEndDateAction(value)), [dispatch]);
const setShowCustomDatePicker = useCallback((value: boolean) => dispatch(setShowCustomDatePickerAction(value)), [dispatch]); const setShowCustomDatePicker = useCallback((value: boolean) => dispatch(setShowCustomDatePickerAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]); const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
const getFilters = useCallback((): RequestFilters => { const getFilters = useCallback((): RequestFilters => {
return { return {
@ -76,7 +73,6 @@ export function useRequestsFilters() {
initiator: initiatorFilter !== 'all' ? initiatorFilter : undefined, initiator: initiatorFilter !== 'all' ? initiatorFilter : undefined,
approver: approverFilter !== 'all' ? approverFilter : undefined, approver: approverFilter !== 'all' ? approverFilter : undefined,
approverType: approverFilter !== 'all' ? approverFilterType : undefined, approverType: approverFilter !== 'all' ? approverFilterType : undefined,
lifecycle: lifecycleFilter !== 'all' ? lifecycleFilter : undefined,
dateRange, dateRange,
startDate: customStartDate, startDate: customStartDate,
endDate: customEndDate endDate: customEndDate
@ -91,7 +87,6 @@ export function useRequestsFilters() {
initiatorFilter, initiatorFilter,
approverFilter, approverFilter,
approverFilterType, approverFilterType,
lifecycleFilter, // Ensure lifecycleFilter is in dependencies
dateRange, dateRange,
customStartDate, customStartDate,
customEndDate customEndDate
@ -133,7 +128,6 @@ export function useRequestsFilters() {
departmentFilter !== 'all' || departmentFilter !== 'all' ||
initiatorFilter !== 'all' || initiatorFilter !== 'all' ||
approverFilter !== 'all' || approverFilter !== 'all' ||
lifecycleFilter !== 'all' ||
dateRange !== 'all' || dateRange !== 'all' ||
customStartDate || customStartDate ||
customEndDate customEndDate
@ -153,7 +147,6 @@ export function useRequestsFilters() {
dateRange, dateRange,
customStartDate, customStartDate,
customEndDate, customEndDate,
lifecycleFilter,
showCustomDatePicker, showCustomDatePicker,
currentPage, currentPage,
hasActiveFilters, hasActiveFilters,
@ -172,7 +165,6 @@ export function useRequestsFilters() {
setCustomEndDate, setCustomEndDate,
setShowCustomDatePicker, setShowCustomDatePicker,
setCurrentPage, setCurrentPage,
setLifecycleFilter,
// Helpers // Helpers
getFilters, getFilters,
clearFilters, clearFilters,

View File

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

View File

@ -16,7 +16,6 @@ export interface RequestsFiltersState {
customEndDate?: Date; customEndDate?: Date;
showCustomDatePicker: boolean; showCustomDatePicker: boolean;
currentPage: number; currentPage: number;
lifecycleFilter: string;
} }
const initialState: RequestsFiltersState = { const initialState: RequestsFiltersState = {
@ -34,7 +33,6 @@ const initialState: RequestsFiltersState = {
customEndDate: undefined, customEndDate: undefined,
showCustomDatePicker: false, showCustomDatePicker: false,
currentPage: 1, currentPage: 1,
lifecycleFilter: 'all',
}; };
const requestsSlice = createSlice({ const requestsSlice = createSlice({
@ -83,9 +81,6 @@ const requestsSlice = createSlice({
setCurrentPage: (state, action: PayloadAction<number>) => { setCurrentPage: (state, action: PayloadAction<number>) => {
state.currentPage = action.payload; state.currentPage = action.payload;
}, },
setLifecycleFilter: (state, action: PayloadAction<string>) => {
state.lifecycleFilter = action.payload;
},
clearFilters: (state) => { clearFilters: (state) => {
state.searchTerm = ''; state.searchTerm = '';
state.statusFilter = 'all'; state.statusFilter = 'all';
@ -101,7 +96,6 @@ const requestsSlice = createSlice({
state.customEndDate = undefined; state.customEndDate = undefined;
state.showCustomDatePicker = false; state.showCustomDatePicker = false;
state.currentPage = 1; state.currentPage = 1;
state.lifecycleFilter = 'all';
}, },
}, },
}); });
@ -121,7 +115,6 @@ export const {
setCustomEndDate, setCustomEndDate,
setShowCustomDatePicker, setShowCustomDatePicker,
setCurrentPage, setCurrentPage,
setLifecycleFilter,
clearFilters, clearFilters,
} = requestsSlice.actions; } = requestsSlice.actions;

View File

@ -36,7 +36,6 @@ export async function fetchRequestsData({
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange; if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString(); if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString(); if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Fetch paginated data for list display (with status filter) // Fetch paginated data for list display (with status filter)
const pageResult = await workflowApi.listWorkflows({ const pageResult = await workflowApi.listWorkflows({
@ -99,7 +98,6 @@ export async function fetchRequestsData({
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange; if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString(); if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString(); if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Fetch paginated data using endpoint for regular users // Fetch paginated data using endpoint for regular users
// This endpoint includes all requests where user is initiator, approver, or participant // This endpoint includes all requests where user is initiator, approver, or participant

View File

@ -41,7 +41,6 @@ export async function fetchUserParticipantRequestsData({
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange; if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate; if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate; if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Use single optimized endpoint - listParticipantRequests now includes initiator requests // Use single optimized endpoint - listParticipantRequests now includes initiator requests
// Only fetch the requested page (10 records) for optimal performance // Only fetch the requested page (10 records) for optimal performance
@ -114,7 +113,6 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange; if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate; if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate; if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Fetch all pages using the single optimized endpoint // Fetch all pages using the single optimized endpoint
while (hasMore && currentPage <= maxPages) { while (hasMore && currentPage <= maxPages) {
@ -152,3 +150,4 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
return allPages; return allPages;
} }

View File

@ -21,7 +21,6 @@ export interface RequestFilters {
dateRange?: DateRange; dateRange?: DateRange;
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
lifecycle?: string;
} }
export interface RequestStats { export interface RequestStats {
@ -65,7 +64,6 @@ export interface ConvertedRequest {
approverLevel: string; approverLevel: string;
templateType?: string; templateType?: string;
workflowType?: string; workflowType?: string;
workflowState?: string;
templateName?: string; templateName?: string;
} }

View File

@ -68,25 +68,3 @@ export const getStatusConfig = (status: string) => {
} }
}; };
export const getWorkflowStateConfig = (state: string) => {
const s = (state || '').toUpperCase();
switch (s) {
case 'CLOSED':
return {
color: 'bg-slate-100 text-slate-800 border-slate-200',
label: 'closed'
};
case 'DRAFT':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft'
};
case 'OPEN':
default:
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'open'
};
}
};

View File

@ -99,7 +99,6 @@ export function transformRequest(req: any): ConvertedRequest {
approverLevel: approverLevel, approverLevel: approverLevel,
templateType: req.templateType || req.template_type, templateType: req.templateType || req.template_type,
workflowType: req.workflowType || req.workflow_type, workflowType: req.workflowType || req.workflow_type,
workflowState: req.workflowState || req.workflow_state,
templateName: req.templateName || req.template_name templateName: req.templateName || req.template_name
}; };
} }

View File

@ -0,0 +1,67 @@
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,10 +19,13 @@ import { UserRoleManager } from '@/components/admin/UserRoleManager';
import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager'; import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager';
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal'; import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal'; import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
// import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserSubscriptions } from '@/services/notificationApi'; import { getUserSubscriptions } from '@/services/notificationApi';
export function Settings() { export function Settings() {
const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = checkIsAdmin(user); const isAdmin = checkIsAdmin(user);
const [showNotificationModal, setShowNotificationModal] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false);
@ -197,14 +200,14 @@ export function Settings() {
<span className="hidden sm:inline">Holidays</span> <span className="hidden sm:inline">Holidays</span>
<span className="sm:hidden">Holidays</span> <span className="sm:hidden">Holidays</span>
</TabsTrigger> </TabsTrigger>
{/* <TabsTrigger <TabsTrigger
value="templates" value="templates"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all" className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
> >
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
<span className="hidden sm:inline">Templates</span> <span className="hidden sm:inline">Templates</span>
<span className="sm:hidden">Templates</span> <span className="sm:hidden">Templates</span>
</TabsTrigger> */} </TabsTrigger>
</TabsList> </TabsList>
{/* Fixed width container to prevent layout shifts */} {/* Fixed width container to prevent layout shifts */}
@ -271,9 +274,18 @@ export function Settings() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-6">
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200"> <div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p> <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>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -324,6 +336,9 @@ export function Settings() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Additional Settings if needed */}
</div> </div>
</TabsContent> </TabsContent>
@ -491,9 +506,18 @@ export function Settings() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-6">
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200"> <div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p> <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>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -545,6 +569,8 @@ export function Settings() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Additional sections if needed */}
</> </>
)} )}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More