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
93 changed files with 8173 additions and 7179 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>
<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" />
<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="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Preload critical fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<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="description"
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</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,7 +12,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
FileText, FileText,
Plus, Plus,
Trash2, Trash2,
@ -21,12 +28,12 @@ import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getAllActivityTypes, getAllActivityTypes,
createActivityType, createActivityType,
updateActivityType, updateActivityType,
deleteActivityType, deleteActivityType,
ActivityType ActivityType
} from '@/services/adminApi'; } from '@/services/adminApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -88,17 +95,18 @@ export function ActivityTypeManager() {
const handleSave = async () => { const handleSave = async () => {
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) {
@ -165,9 +173,9 @@ export function ActivityTypeManager() {
<AlertCircle className="w-4 h-4 text-white shrink-0" /> <AlertCircle className="w-4 h-4 text-white shrink-0" />
</div> </div>
<p className="text-sm font-medium text-red-900">{error}</p> <p className="text-sm font-medium text-red-900">{error}</p>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => setError(null)} onClick={() => setError(null)}
className="ml-auto hover:bg-red-100" className="ml-auto hover:bg-red-100"
> >
@ -191,8 +199,8 @@ export function ActivityTypeManager() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm" className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@ -216,9 +224,9 @@ export function ActivityTypeManager() {
</div> </div>
<p className="text-slate-700 font-medium text-lg">No activity types found</p> <p className="text-slate-700 font-medium text-lg">No activity types found</p>
<p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p> <p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
variant="outline" variant="outline"
className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm" className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
@ -245,7 +253,7 @@ export function ActivityTypeManager() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-4"> <CardContent className="space-y-3 pt-4">
{activeActivityTypes.map(activityType => ( {activeActivityTypes.map(activityType => (
<div <div
key={activityType.activityTypeId} key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm" className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm"
> >
@ -314,7 +322,7 @@ export function ActivityTypeManager() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-4"> <CardContent className="space-y-3 pt-4">
{inactiveActivityTypes.map(activityType => ( {inactiveActivityTypes.map(activityType => (
<div <div
key={activityType.activityTypeId} key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm" className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm"
> >
@ -397,46 +405,51 @@ 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>
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0"> <DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowAddDialog(false)} onClick={() => setShowAddDialog(false)}
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm" className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={!formData.title.trim()} 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

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

View File

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

View File

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

View File

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

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;
@ -15,25 +16,26 @@ interface FormattedDescriptionProps {
export function FormattedDescription({ content, className }: FormattedDescriptionProps) { export function FormattedDescription({ content, className }: FormattedDescriptionProps) {
const processedContent = React.useMemo(() => { const processedContent = React.useMemo(() => {
if (!content) return ''; if (!content) return '';
// Wrap tables that aren't already wrapped in a scrollable container using regex // Wrap tables that aren't already wrapped in a scrollable container using regex
// Match <table> tags that aren't already inside a .table-wrapper // Match <table> tags that aren't already inside a .table-wrapper
let processed = content; let processed = content;
// Pattern to match table tags that aren't already wrapped // Pattern to match table tags that aren't already wrapped
const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi; const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi;
processed = processed.replace(tablePattern, (match) => { processed = processed.replace(tablePattern, (match) => {
// Check if this table is already wrapped // Check if this table is already wrapped
if (match.includes('table-wrapper')) { if (match.includes('table-wrapper')) {
return match; return match;
} }
// 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

@ -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,18 +275,18 @@ 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}
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium" className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
size="sm" size="sm"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Raise New Request Raise New Request
</Button> </Button>
</div> </div>
{/* )} */} )}
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -12,12 +12,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Calendar } from '../ui/calendar'; import { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Calendar as CalendarIcon, Calendar as CalendarIcon,
Upload, Upload,
X, X,
FileText, FileText,
Check, Check,
Users Users
@ -150,7 +150,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
onChange={(e) => updateFormData('title', e.target.value)} onChange={(e) => updateFormData('title', e.target.value)}
/> />
</div> </div>
<div> <div>
<Label htmlFor="description">Description *</Label> <Label htmlFor="description">Description *</Label>
<Textarea <Textarea
@ -215,9 +215,9 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label> <Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label>
</div> </div>
</div> </div>
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground"> <div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
{formData.workflowType === 'sequential' {formData.workflowType === 'sequential'
? 'Approvers will review the request one after another in the order you specify.' ? 'Approvers will review the request one after another in the order you specify.'
: 'All approvers will review the request simultaneously.' : 'All approvers will review the request simultaneously.'
} }
@ -311,7 +311,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableUsers {availableUsers
.filter(user => .filter(user =>
!formData.spectators.find(s => s.id === user.id) && !formData.spectators.find(s => s.id === user.id) &&
!formData.approvers.find(a => a.id === user.id) !formData.approvers.find(a => a.id === user.id)
) )
@ -378,7 +378,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center"> <div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> <Upload className="h-8 w-8 mx-auto mb-2 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

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

View File

@ -1,18 +1,19 @@
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';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import { import {
Send, Send,
Smile, Smile,
Paperclip, Paperclip,
Users, Users,
FileText, FileText,
Download, Download,
Eye, Eye,
MoreHorizontal MoreHorizontal
} from 'lucide-react'; } from 'lucide-react';
@ -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 (
@ -187,7 +189,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<TabsTrigger value="chat">Chat</TabsTrigger> <TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger> <TabsTrigger value="media">Media</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="chat" className="flex-1 flex flex-col"> <TabsContent value="chat" className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-4 border rounded-lg"> <ScrollArea className="flex-1 p-4 border rounded-lg">
<div className="space-y-4"> <div className="space-y-4">
@ -195,16 +197,15 @@ 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'
}`}> }`}>
{msg.user.avatar} {msg.user.avatar}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}
<div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}> <div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}>
{msg.isSystem ? ( {msg.isSystem ? (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground"> <div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground">
@ -222,7 +223,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
{msg.timestamp} {msg.timestamp}
</span> </span>
</div> </div>
<div <div
className="text-sm bg-muted/30 p-3 rounded-lg" className="text-sm bg-muted/30 p-3 rounded-lg"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }} dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/> />
@ -300,15 +301,14 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<h4 className="font-medium">Participants</h4> <h4 className="font-medium">Participants</h4>
<Badge variant="outline">{participants.length}</Badge> <Badge variant="outline">{participants.length}</Badge>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{participants.map((participant, index) => ( {participants.map((participant, index) => (
<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>
</Avatar> </Avatar>

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; // Handle dark theme if present
return color ? ` --color-${key}: ${color};` : null; const darkColor = itemConfig.theme?.dark;
}) if (darkColor) {
.join("\n")} styles[`--color-${key}-dark`] = darkColor;
} }
`, });
)
.join("\n"), 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;
@ -316,8 +318,8 @@ function getPayloadConfigFromPayload(
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload &&
typeof payload.payload === "object" && typeof payload.payload === "object" &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined; : undefined;

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]);
@ -68,55 +70,55 @@ export function RichTextEditor({
const cleanWordHTML = React.useCallback((html: string): string => { const cleanWordHTML = React.useCallback((html: string): string => {
// Remove HTML comments (like Word style definitions) // Remove HTML comments (like Word style definitions)
html = html.replace(/<!--[\s\S]*?-->/g, ''); html = html.replace(/<!--[\s\S]*?-->/g, '');
// Remove style tags (Word CSS) // Remove style tags (Word CSS)
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ''); html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Remove script tags // Remove script tags
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ''); html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
// Remove meta tags // Remove meta tags
html = html.replace(/<meta[^>]*>/gi, ''); html = html.replace(/<meta[^>]*>/gi, '');
// Remove Word-specific classes and attributes // Remove Word-specific classes and attributes
html = html.replace(/\s*class="Mso[^"]*"/gi, ''); html = html.replace(/\s*class="Mso[^"]*"/gi, '');
html = html.replace(/\s*class="mso[^"]*"/gi, ''); html = html.replace(/\s*class="mso[^"]*"/gi, '');
html = html.replace(/\s*style="[^"]*mso-[^"]*"/gi, ''); html = html.replace(/\s*style="[^"]*mso-[^"]*"/gi, '');
html = html.replace(/\s*style="[^"]*font-family:[^"]*"/gi, ''); html = html.replace(/\s*style="[^"]*font-family:[^"]*"/gi, '');
// Remove xmlns attributes // Remove xmlns attributes
html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, ''); html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, '');
// Remove o:p tags (Word paragraph markers) // Remove o:p tags (Word paragraph markers)
html = html.replace(/<\/?o:p[^>]*>/gi, ''); html = html.replace(/<\/?o:p[^>]*>/gi, '');
// Remove v:shapes and other Word-specific elements // Remove v:shapes and other Word-specific elements
html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, ''); html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
html = html.replace(/<v:[^>]*\/>/gi, ''); html = html.replace(/<v:[^>]*\/>/gi, '');
// Clean up empty paragraphs // Clean up empty paragraphs
html = html.replace(/<p[^>]*>\s*<\/p>/gi, ''); html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
html = html.replace(/<div[^>]*>\s*<\/div>/gi, ''); html = html.replace(/<div[^>]*>\s*<\/div>/gi, '');
// Remove excessive whitespace // Remove excessive whitespace
html = html.replace(/\s+/g, ' '); html = html.replace(/\s+/g, ' ');
html = html.trim(); html = html.trim();
return html; return html;
}, []); }, []);
// Handle paste event to preserve formatting // Handle paste event to preserve formatting
const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => { const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
const clipboardData = e.clipboardData; const clipboardData = e.clipboardData;
let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain'); let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain');
// Clean Word/Office metadata if HTML // Clean Word/Office metadata if HTML
if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) { if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) {
pastedData = cleanWordHTML(pastedData); pastedData = cleanWordHTML(pastedData);
} }
if (!editorRef.current) return; if (!editorRef.current) return;
const selection = window.getSelection(); const selection = window.getSelection();
@ -131,12 +133,12 @@ export function RichTextEditor({
// Clean and preserve formatting // Clean and preserve formatting
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// Process each node to preserve lists, tables, and basic formatting // Process each node to preserve lists, tables, and basic formatting
Array.from(tempDiv.childNodes).forEach((node) => { Array.from(tempDiv.childNodes).forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) { if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement; const element = node as HTMLElement;
// Preserve lists (ul, ol) // Preserve lists (ul, ol)
if (element.tagName === 'UL' || element.tagName === 'OL') { if (element.tagName === 'UL' || element.tagName === 'OL') {
const list = element.cloneNode(true) as HTMLElement; const list = element.cloneNode(true) as HTMLElement;
@ -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);
} }
@ -182,7 +181,7 @@ export function RichTextEditor({
const innerHTML = element.innerHTML; const innerHTML = element.innerHTML;
// Remove style tags and comments from inner HTML // Remove style tags and comments from inner HTML
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, ''); .replace(/<!--[\s\S]*?-->/g, '');
p.innerHTML = cleaned; p.innerHTML = cleaned;
p.removeAttribute('style'); p.removeAttribute('style');
p.removeAttribute('class'); p.removeAttribute('class');
@ -227,36 +226,36 @@ export function RichTextEditor({
} }
range.insertNode(fragment); range.insertNode(fragment);
// Move cursor to end of inserted content // Move cursor to end of inserted content
range.collapse(false); range.collapse(false);
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]);
// Check active formats (bold, italic, etc.) // Check active formats (bold, italic, etc.)
const checkActiveFormats = React.useCallback(() => { const checkActiveFormats = React.useCallback(() => {
if (!editorRef.current || !isFocused) return; if (!editorRef.current || !isFocused) return;
const formats = new Set<string>(); const formats = new Set<string>();
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer; const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null; let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) { if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement; element = commonAncestor.parentElement;
} else { } else {
element = commonAncestor as HTMLElement; element = commonAncestor as HTMLElement;
} }
while (element && element !== editorRef.current) { while (element && element !== editorRef.current) {
const tagName = element.tagName.toLowerCase(); const tagName = element.tagName.toLowerCase();
if (tagName === 'strong' || tagName === 'b') formats.add('bold'); if (tagName === 'strong' || tagName === 'b') formats.add('bold');
@ -267,40 +266,40 @@ export function RichTextEditor({
if (tagName === 'h3') formats.add('h3'); if (tagName === 'h3') formats.add('h3');
if (tagName === 'ul') formats.add('ul'); if (tagName === 'ul') formats.add('ul');
if (tagName === 'ol') formats.add('ol'); if (tagName === 'ol') formats.add('ol');
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
if (style.textAlign === 'center') formats.add('center'); if (style.textAlign === 'center') formats.add('center');
if (style.textAlign === 'right') formats.add('right'); if (style.textAlign === 'right') formats.add('right');
if (style.textAlign === 'left') formats.add('left'); if (style.textAlign === 'left') formats.add('left');
// Convert RGB/RGBA to hex for comparison // Convert RGB/RGBA to hex for comparison
const colorToHex = (color: string): string | null => { const colorToHex = (color: string): string | null => {
// If already hex format // If already hex format
if (color.startsWith('#')) { if (color.startsWith('#')) {
return color.toUpperCase(); return color.toUpperCase();
} }
// If RGB/RGBA format // If RGB/RGBA format
const result = color.match(/\d+/g); const result = color.match(/\d+/g);
if (!result || result.length < 3) return null; if (!result || result.length < 3) return null;
const r = result[0]; const r = result[0];
const g = result[1]; const g = result[1];
const b = result[2]; const b = result[2];
if (!r || !g || !b) return null; if (!r || !g || !b) return null;
const rHex = parseInt(r).toString(16).padStart(2, '0'); const rHex = parseInt(r).toString(16).padStart(2, '0');
const gHex = parseInt(g).toString(16).padStart(2, '0'); const gHex = parseInt(g).toString(16).padStart(2, '0');
const bHex = parseInt(b).toString(16).padStart(2, '0'); const bHex = parseInt(b).toString(16).padStart(2, '0');
return `#${rHex}${gHex}${bHex}`.toUpperCase(); return `#${rHex}${gHex}${bHex}`.toUpperCase();
}; };
// Check for background color (highlight) // Check for background color (highlight)
const bgColor = style.backgroundColor; const bgColor = style.backgroundColor;
// Check if background color is set and not transparent/default // Check if background color is set and not transparent/default
if (bgColor && if (bgColor &&
bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'rgba(0, 0, 0, 0)' &&
bgColor !== 'transparent' && bgColor !== 'transparent' &&
bgColor !== 'rgb(255, 255, 255)' && bgColor !== 'rgb(255, 255, 255)' &&
bgColor !== '#ffffff' && bgColor !== '#ffffff' &&
bgColor !== '#FFFFFF') { bgColor !== '#FFFFFF') {
formats.add('highlight'); formats.add('highlight');
const hexColor = colorToHex(bgColor); const hexColor = colorToHex(bgColor);
if (hexColor) { if (hexColor) {
@ -321,15 +320,15 @@ export function RichTextEditor({
// Only reset if we haven't found a highlight yet // Only reset if we haven't found a highlight yet
setCurrentHighlightColor(null); setCurrentHighlightColor(null);
} }
// Check for text color // Check for text color
const textColor = style.color; const textColor = style.color;
// Convert to hex for comparison // Convert to hex for comparison
const hexTextColor = colorToHex(textColor); const hexTextColor = colorToHex(textColor);
// Check if text color is set and not default black // Check if text color is set and not default black
if (textColor && hexTextColor && if (textColor && hexTextColor &&
textColor !== 'rgba(0, 0, 0, 0)' && textColor !== 'rgba(0, 0, 0, 0)' &&
hexTextColor !== '#000000') { hexTextColor !== '#000000') {
formats.add('textColor'); formats.add('textColor');
// Find matching color from our palette // Find matching color from our palette
const matchedColor = HIGHLIGHT_COLORS.find(c => { const matchedColor = HIGHLIGHT_COLORS.find(c => {
@ -350,23 +349,23 @@ export function RichTextEditor({
setCurrentTextColor(null); setCurrentTextColor(null);
} }
} }
element = element.parentElement; element = element.parentElement;
} }
} }
setActiveFormats(formats); setActiveFormats(formats);
}, [isFocused]); }, [isFocused]);
// Apply formatting command // Apply formatting command
const applyFormat = React.useCallback((command: string, value?: string) => { const applyFormat = React.useCallback((command: string, value?: string) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Restore focus if needed // Restore focus if needed
if (!isFocused) { if (!isFocused) {
editorRef.current.focus(); editorRef.current.focus();
} }
// Save current selection // Save current selection
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -374,15 +373,15 @@ export function RichTextEditor({
editorRef.current.focus(); editorRef.current.focus();
return; return;
} }
// Execute formatting command // Execute formatting command
document.execCommand(command, false, value); document.execCommand(command, false, value);
// 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
setTimeout(checkActiveFormats, 10); setTimeout(checkActiveFormats, 10);
}, [isFocused, onChange, checkActiveFormats]); }, [isFocused, onChange, checkActiveFormats]);
@ -390,12 +389,12 @@ export function RichTextEditor({
// Apply highlight color // Apply highlight color
const applyHighlight = React.useCallback((color: string) => { const applyHighlight = React.useCallback((color: string) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Restore focus if needed // Restore focus if needed
if (!isFocused) { if (!isFocused) {
editorRef.current.focus(); editorRef.current.focus();
} }
// Save current selection // Save current selection
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -403,26 +402,26 @@ export function RichTextEditor({
editorRef.current.focus(); editorRef.current.focus();
return; return;
} }
// Check if this color is already applied by checking the selection's style // Check if this color is already applied by checking the selection's style
let isAlreadyApplied = false; let isAlreadyApplied = false;
if (selection.rangeCount > 0) { if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer; const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null; let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) { if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement; element = commonAncestor.parentElement;
} else { } else {
element = commonAncestor as HTMLElement; element = commonAncestor as HTMLElement;
} }
// Check if the selected element has the same background color // Check if the selected element has the same background color
while (element && element !== editorRef.current) { while (element && element !== editorRef.current) {
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
const bgColor = style.backgroundColor; const bgColor = style.backgroundColor;
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' && if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') { bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
// Convert to hex and compare // Convert to hex and compare
const colorToHex = (c: string): string | null => { const colorToHex = (c: string): string | null => {
if (c.startsWith('#')) return c.toUpperCase(); if (c.startsWith('#')) return c.toUpperCase();
@ -446,7 +445,7 @@ export function RichTextEditor({
element = element.parentElement; element = element.parentElement;
} }
} }
// Use backColor command for highlight (background color) // Use backColor command for highlight (background color)
if (color === 'transparent' || isAlreadyApplied) { if (color === 'transparent' || isAlreadyApplied) {
// Remove highlight - use a more aggressive approach to fully remove // Remove highlight - use a more aggressive approach to fully remove
@ -454,10 +453,10 @@ export function RichTextEditor({
if (!range.collapsed) { if (!range.collapsed) {
// Store the range before manipulation // Store the range before manipulation
const contents = range.extractContents(); const contents = range.extractContents();
// Create a new text node or span without background color // Create a new text node or span without background color
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// Process extracted contents to remove background colors // Process extracted contents to remove background colors
const processNode = (node: Node) => { const processNode = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
@ -465,14 +464,14 @@ export function RichTextEditor({
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement; const el = node as HTMLElement;
const newEl = document.createElement(el.tagName.toLowerCase()); const newEl = document.createElement(el.tagName.toLowerCase());
// Copy all attributes except style-related ones // Copy all attributes except style-related ones
Array.from(el.attributes).forEach(attr => { Array.from(el.attributes).forEach(attr => {
if (attr.name !== 'style' && attr.name !== 'class') { if (attr.name !== 'style' && attr.name !== 'class') {
newEl.setAttribute(attr.name, attr.value); newEl.setAttribute(attr.name, attr.value);
} }
}); });
// Process children and copy without background color // Process children and copy without background color
Array.from(el.childNodes).forEach(child => { Array.from(el.childNodes).forEach(child => {
const processed = processNode(child); const processed = processNode(child);
@ -480,27 +479,27 @@ export function RichTextEditor({
newEl.appendChild(processed); newEl.appendChild(processed);
} }
}); });
// Remove background color if present // Remove background color if present
if (el.style.backgroundColor) { if (el.style.backgroundColor) {
newEl.style.backgroundColor = ''; newEl.style.backgroundColor = '';
} }
return newEl; return newEl;
} }
return null; return null;
}; };
Array.from(contents.childNodes).forEach(child => { Array.from(contents.childNodes).forEach(child => {
const processed = processNode(child); const processed = processNode(child);
if (processed) { if (processed) {
fragment.appendChild(processed); fragment.appendChild(processed);
} }
}); });
// Insert the cleaned fragment // Insert the cleaned fragment
range.insertNode(fragment); range.insertNode(fragment);
// Also use execCommand to ensure removal // Also use execCommand to ensure removal
document.execCommand('removeFormat', false); document.execCommand('removeFormat', false);
} else { } else {
@ -523,21 +522,21 @@ export function RichTextEditor({
return; return;
} }
} }
// Clear selection immediately after applying to prevent "sticky" highlight mode // Clear selection immediately after applying to prevent "sticky" highlight mode
const sel = window.getSelection(); const sel = window.getSelection();
if (sel) { if (sel) {
sel.removeAllRanges(); sel.removeAllRanges();
} }
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Close popover // Close popover
setHighlightColorOpen(false); setHighlightColorOpen(false);
// Refocus editor after a short delay and check formats // Refocus editor after a short delay and check formats
setTimeout(() => { setTimeout(() => {
if (editorRef.current) { if (editorRef.current) {
@ -550,12 +549,12 @@ export function RichTextEditor({
// Apply text color // Apply text color
const applyTextColor = React.useCallback((color: string) => { const applyTextColor = React.useCallback((color: string) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Restore focus if needed // Restore focus if needed
if (!isFocused) { if (!isFocused) {
editorRef.current.focus(); editorRef.current.focus();
} }
// Save current selection // Save current selection
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -563,20 +562,20 @@ export function RichTextEditor({
editorRef.current.focus(); editorRef.current.focus();
return; return;
} }
// Check if this color is already applied by checking the selection's style // Check if this color is already applied by checking the selection's style
let isAlreadyApplied = false; let isAlreadyApplied = false;
if (selection.rangeCount > 0) { if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer; const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null; let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) { if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement; element = commonAncestor.parentElement;
} else { } else {
element = commonAncestor as HTMLElement; element = commonAncestor as HTMLElement;
} }
// Check if the selected element has the same text color // Check if the selected element has the same text color
while (element && element !== editorRef.current) { while (element && element !== editorRef.current) {
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
@ -612,7 +611,7 @@ export function RichTextEditor({
element = element.parentElement; element = element.parentElement;
} }
} }
// Use foreColor command for text color // Use foreColor command for text color
if (color === 'transparent' || color === 'default' || isAlreadyApplied) { if (color === 'transparent' || color === 'default' || isAlreadyApplied) {
// Remove text color by removing format or setting to default // Remove text color by removing format or setting to default
@ -633,15 +632,15 @@ export function RichTextEditor({
setCustomTextColor(color); setCustomTextColor(color);
} }
} }
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Close popover // Close popover
setTextColorOpen(false); setTextColorOpen(false);
// Check active formats after a short delay // Check active formats after a short delay
setTimeout(checkActiveFormats, 10); setTimeout(checkActiveFormats, 10);
}, [isFocused, onChange, checkActiveFormats]); }, [isFocused, onChange, checkActiveFormats]);
@ -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,18 +684,18 @@ 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]);
// Handle selection change to update active formats // Handle selection change to update active formats
React.useEffect(() => { React.useEffect(() => {
if (!isFocused) return; if (!isFocused) return;
const handleSelectionChange = () => { const handleSelectionChange = () => {
checkActiveFormats(); checkActiveFormats();
}; };
document.addEventListener('selectionchange', handleSelectionChange); document.addEventListener('selectionchange', handleSelectionChange);
return () => { return () => {
document.removeEventListener('selectionchange', handleSelectionChange); document.removeEventListener('selectionchange', handleSelectionChange);
@ -748,7 +747,7 @@ export function RichTextEditor({
> >
<Underline className="h-4 w-4" /> <Underline className="h-4 w-4" />
</Button> </Button>
{/* Highlight Color Picker */} {/* Highlight Color Picker */}
<Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}> <Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -765,8 +764,8 @@ export function RichTextEditor({
<Highlighter className="h-4 w-4" /> <Highlighter className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-auto p-2" className="w-auto p-2"
align="start" align="start"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
// Prevent closing when clicking inside popover // Prevent closing when clicking inside popover
@ -791,7 +790,7 @@ export function RichTextEditor({
> >
<X className="h-4 w-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
</Button> </Button>
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Highlight Color</div> <div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Highlight Color</div>
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2"> <div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
{HIGHLIGHT_COLORS.map((color) => { {HIGHLIGHT_COLORS.map((color) => {
@ -833,7 +832,7 @@ export function RichTextEditor({
); );
})} })}
</div> </div>
{/* Remove Highlight Button - Standard pattern */} {/* Remove Highlight Button - Standard pattern */}
{currentHighlightColor && currentHighlightColor !== 'transparent' && ( {currentHighlightColor && currentHighlightColor !== 'transparent' && (
<div className="mb-2"> <div className="mb-2">
@ -852,7 +851,7 @@ export function RichTextEditor({
</Button> </Button>
</div> </div>
)} )}
{/* Custom Color Picker */} {/* Custom Color Picker */}
<div className="border-t border-gray-200 pt-2 mt-2"> <div className="border-t border-gray-200 pt-2 mt-2">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div> <div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
@ -899,7 +898,7 @@ export function RichTextEditor({
// Get pasted text from clipboard // Get pasted text from clipboard
const pastedText = e.clipboardData.getData('text').trim(); const pastedText = e.clipboardData.getData('text').trim();
e.preventDefault(); e.preventDefault();
// Process after paste event completes // Process after paste event completes
setTimeout(() => { setTimeout(() => {
// Check if it's a valid hex color with # // Check if it's a valid hex color with #
@ -980,7 +979,7 @@ export function RichTextEditor({
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* Text Color Picker */} {/* Text Color Picker */}
<Popover open={textColorOpen} onOpenChange={setTextColorOpen}> <Popover open={textColorOpen} onOpenChange={setTextColorOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -997,8 +996,8 @@ export function RichTextEditor({
<Type className="h-4 w-4" /> <Type className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-auto p-2" className="w-auto p-2"
align="start" align="start"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@ -1022,7 +1021,7 @@ export function RichTextEditor({
> >
<X className="h-4 w-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
</Button> </Button>
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Text Color</div> <div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Text Color</div>
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2"> <div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
{/* Default/Black Color Option - First position (standard) */} {/* Default/Black Color Option - First position (standard) */}
@ -1085,7 +1084,7 @@ export function RichTextEditor({
); );
})} })}
</div> </div>
{/* Remove Text Color Button - Standard pattern */} {/* Remove Text Color Button - Standard pattern */}
{currentTextColor && currentTextColor !== '#000000' && ( {currentTextColor && currentTextColor !== '#000000' && (
<div className="mb-2"> <div className="mb-2">
@ -1104,7 +1103,7 @@ export function RichTextEditor({
</Button> </Button>
</div> </div>
)} )}
{/* Custom Text Color Picker */} {/* Custom Text Color Picker */}
<div className="border-t border-gray-200 pt-2 mt-2"> <div className="border-t border-gray-200 pt-2 mt-2">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div> <div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>

File diff suppressed because it is too large Load Diff

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';
@ -8,12 +9,12 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import {
Send, Send,
Smile, Smile,
Paperclip, Paperclip,
FileText, FileText,
Download, Download,
Clock, Clock,
Flag, Flag,
X, X,
@ -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 }) => {
@ -102,7 +105,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const filteredMessages = messages.filter(msg => const filteredMessages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) || msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase()) msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
@ -132,7 +135,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
// Realtime updates via Socket.IO // Realtime updates via Socket.IO
useEffect(() => { useEffect(() => {
if (!currentUserId) return; // Wait for currentUserId to be loaded if (!currentUserId) return; // Wait for currentUserId to be loaded
let joinedId = requestId; let joinedId = requestId;
(async () => { (async () => {
try { try {
@ -140,39 +143,39 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
if (details?.workflow?.requestId) { if (details?.workflow?.requestId) {
joinedId = details.workflow.requestId; joinedId = details.workflow.requestId;
} }
} catch {} } catch { }
try { try {
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL) // Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
const s = getSocket(); // Uses getSocketBaseUrl() helper internally const s = getSocket(); // Uses getSocketBaseUrl() helper internally
joinRequestRoom(s, joinedId, currentUserId || undefined); joinRequestRoom(s, joinedId, currentUserId || undefined);
const noteHandler = (payload: any) => { const noteHandler = (payload: any) => {
const n = payload?.note || payload; const n = payload?.note || payload;
if (!n) return; if (!n) return;
setMessages(prev => { setMessages(prev => {
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) { if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
return prev; return prev;
} }
const userName = n.userName || n.user_name || 'User'; const userName = n.userName || n.user_name || 'User';
const userRole = n.userRole || n.user_role; const userRole = n.userRole || n.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = n.userId || n.user_id; const noteUserId = n.userId || n.user_id;
const newMsg = { const newMsg = {
id: n.noteId || n.note_id || String(Date.now()), id: n.noteId || n.note_id || String(Date.now()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: n.message || '', content: n.message || '',
timestamp: n.createdAt || n.created_at || new Date().toISOString(), timestamp: n.createdAt || n.created_at || new Date().toISOString(),
isCurrentUser: noteUserId === currentUserId isCurrentUser: noteUserId === currentUserId
} as any; } as any;
return [...prev, newMsg]; return [...prev, newMsg];
}); });
}; };
@ -189,7 +192,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} }
})(); })();
return () => { return () => {
try { (window as any).__wn_cleanup?.(); } catch {} try { (window as any).__wn_cleanup?.(); } catch { }
}; };
}, [requestId, currentUserId]); }, [requestId, currentUserId]);
@ -205,20 +208,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userName = m.userName || m.user_name || 'User'; const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || '', content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(), timestamp: m.createdAt || m.created_at || new Date().toISOString(),
}; };
}) : []; }) : [];
setMessages(mapped as any); setMessages(mapped as any);
} catch {} } catch { }
} }
setMessage(''); setMessage('');
setSelectedFiles([]); setSelectedFiles([]);
@ -234,21 +237,21 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id; const noteUserId = m.userId || m.user_id;
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || m.content || '', content: m.message || m.content || '',
timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(), timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id, attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name, name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name, fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#', url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file', type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file', fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size fileSize: a.fileSize || a.file_size
@ -257,24 +260,24 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} as any; } as any;
}); });
setMessages(mapped); setMessages(mapped);
} catch {} } catch { }
} else { } else {
(async () => { (async () => {
try { try {
const rows = await getWorkNotes(requestId); const rows = await getWorkNotes(requestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => { const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User'; const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id; const noteUserId = m.userId || m.user_id;
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || '', content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(), timestamp: m.createdAt || m.created_at || new Date().toISOString(),
@ -336,7 +339,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
if (msg.id === messageId) { if (msg.id === messageId) {
const reactions = msg.reactions || []; const reactions = msg.reactions || [];
const existingReaction = reactions.find(r => r.emoji === emoji); const existingReaction = reactions.find(r => r.emoji === emoji);
if (existingReaction) { if (existingReaction) {
if (existingReaction.users.includes('You')) { if (existingReaction.users.includes('You')) {
existingReaction.users = existingReaction.users.filter(u => u !== 'You'); existingReaction.users = existingReaction.users.filter(u => u !== 'You');
@ -349,7 +352,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} else { } else {
reactions.push({ emoji, users: ['You'] }); reactions.push({ emoji, users: ['You'] });
} }
return { ...msg, reactions }; return { ...msg, reactions };
} }
return msg; return msg;
@ -371,7 +374,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
</div> </div>
</div> </div>
{/* Search Bar */} {/* Search Bar */}
<div className="relative"> <div className="relative">
<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" />
@ -389,21 +392,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div className="space-y-6 max-w-full"> <div className="space-y-6 max-w-full">
{filteredMessages.map((msg) => { {filteredMessages.map((msg) => {
const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You'; const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You';
return ( return (
<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' }`}>
}`}>
{msg.user.avatar} {msg.user.avatar}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}> <div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}>
{msg.isSystem ? ( {msg.isSystem ? (
<div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full"> <div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full">
@ -435,7 +437,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
{/* Message Content */} {/* Message Content */}
<div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}> <div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}>
<div <div
className="text-gray-800 leading-relaxed text-base" className="text-gray-800 leading-relaxed text-base"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }} dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/> />
@ -449,72 +451,72 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const fileName = attachment.fileName || attachment.file_name || attachment.name; const fileName = attachment.fileName || attachment.file_name || attachment.name;
const fileType = attachment.fileType || attachment.file_type || attachment.type || ''; const fileType = attachment.fileType || attachment.file_type || attachment.type || '';
const attachmentId = attachment.attachmentId || attachment.attachment_id; const attachmentId = attachment.attachmentId || attachment.attachment_id;
return ( return (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"> <div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<FileIcon type={fileType} /> <FileIcon type={fileType} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate"> <p className="text-sm font-medium text-gray-700 truncate">
{fileName} {fileName}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p> </p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p>
)}
</div>
{/* Preview button for images and PDFs */}
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
setPreviewFile({
fileName,
fileType,
fileUrl: previewUrl,
fileSize,
attachmentId
});
}}
title="Preview file"
>
<Eye className="w-4 h-4" />
</Button>
)} )}
</div>
{/* Download button */}
{/* Preview button for images and PDFs */} <Button
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && ( variant="ghost"
<Button size="sm"
variant="ghost" className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
size="sm" onClick={async (e) => {
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
setPreviewFile({ if (!attachmentId) {
fileName, toast.error('Cannot download: Attachment ID missing');
fileType, return;
fileUrl: previewUrl, }
fileSize,
attachmentId try {
}); await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
toast.error('Failed to download file');
}
}} }}
title="Preview file" title="Download file"
> >
<Eye className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
)} </div>
{/* Download button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!attachmentId) {
toast.error('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
toast.error('Failed to download file');
}
}}
title="Download file"
>
<Download className="w-4 h-4" />
</Button>
</div>
); );
})} })}
</div> </div>
@ -528,19 +530,18 @@ 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'
}`} }`}
> >
<span>{reaction.emoji}</span> <span>{reaction.emoji}</span>
<span className="text-xs font-medium">{reaction.users.length}</span> <span className="text-xs font-medium">{reaction.users.length}</span>
</button> </button>
))} ))}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 flex-shrink-0" className="h-7 w-7 p-0 flex-shrink-0"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
> >
@ -552,7 +553,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
)} )}
</div> </div>
{!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="bg-blue-500 text-white font-semibold text-sm"> <AvatarFallback className="bg-blue-500 text-white font-semibold text-sm">
@ -648,27 +649,27 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{/* Left side - Action buttons */} {/* Left side - Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={handleAttachmentClick} onClick={handleAttachmentClick}
title="Attach file" title="Attach file"
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Add emoji" title="Add emoji"
> >
<Smile className="h-4 w-4" /> <Smile className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setMessage(prev => prev + '@')} onClick={() => setMessage(prev => prev + '@')}
title="Mention someone" title="Mention someone"
@ -682,8 +683,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<span className="text-xs text-gray-500 whitespace-nowrap"> <span className="text-xs text-gray-500 whitespace-nowrap">
{message.length}/2000 {message.length}/2000
</span> </span>
<Button <Button
onClick={handleSendMessage} onClick={handleSendMessage}
disabled={!message.trim() && selectedFiles.length === 0} disabled={!message.trim() && selectedFiles.length === 0}
className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed" className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
size="sm" size="sm"
@ -695,7 +696,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
</div> </div>
</div> </div>
{/* File Preview Modal */} {/* File Preview Modal */}
{previewFile && ( {previewFile && (
<FilePreview <FilePreview

View File

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

@ -69,7 +69,7 @@ export function DocumentsStep({
// Check file extension // Check file extension
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
validationErrors.push({ validationErrors.push({
fileName: file.name, fileName: file.name,
@ -111,16 +111,16 @@ export function DocumentsStep({
const type = (doc.fileType || doc.file_type || '').toLowerCase(); const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase(); const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') || return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf'); name.endsWith('.pdf');
} else { } else {
const type = (doc.type || '').toLowerCase(); const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase(); const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') || return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf'); name.endsWith('.pdf');
} }
}; };
@ -160,7 +160,7 @@ export function DocumentsStep({
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" /> <Upload className="h-12 w-12 mx-auto mb-4 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"
@ -172,10 +172,10 @@ export function DocumentsStep({
ref={fileInputRef} ref={fileInputRef}
data-testid="documents-file-input" data-testid="documents-file-input"
/> />
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
data-testid="documents-browse-button" data-testid="documents-browse-button"
> >
@ -206,7 +206,7 @@ export function DocumentsStep({
const docId = doc.documentId || doc.document_id || ''; const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId); const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null; if (isDeleted) return null;
return ( return (
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}> <div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -222,9 +222,9 @@ export function DocumentsStep({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canPreview(doc, true) && ( {canPreview(doc, true) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onPreviewDocument(doc, true)} onClick={() => onPreviewDocument(doc, true)}
data-testid={`documents-existing-${docId}-preview`} data-testid={`documents-existing-${docId}-preview`}
> >
@ -276,9 +276,9 @@ export function DocumentsStep({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canPreview(file, false) && ( {canPreview(file, false) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onPreviewDocument(file, false)} onClick={() => onPreviewDocument(file, false)}
data-testid={`documents-new-${index}-preview`} data-testid={`documents-new-${index}-preview`}
> >

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

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

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

View File

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

View File

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

View File

@ -5,13 +5,13 @@
* Located in: src/dealer-claim/components/request-detail/ * Located in: src/dealer-claim/components/request-detail/
*/ */
import { useState, useEffect } 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 || 'Unknown User';
organizer?.display_name || return {
organizer?.name || ioNumber: io.ioNumber || io.io_number,
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) || blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0),
organizer?.email || availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0),
'Unknown User'; remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0),
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
// Set IO number from existing data
setIoNumber(existingIONumber);
// Only set blocked details if amount is blocked
if (existingBlockedAmount > 0) {
const blockedAmt = Number(existingBlockedAmount) || 0;
const backendRemaining = Number(existingRemainingBalance) || 0;
// Calculate expected remaining balance for validation/debugging
// Formula: remaining = availableBeforeBlock - blockedAmount
const expectedRemaining = availableBeforeBlock - blockedAmt;
// Loading existing IO block
// Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(backendRemaining - expectedRemaining) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
availableBalance: availableBeforeBlock,
blockedAmount: blockedAmt,
expectedRemaining,
backendRemaining,
difference: backendRemaining - expectedRemaining,
});
}
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: blockedAmt,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: backendRemaining, // Use backend calculated value
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName, blockedBy: blockedByName,
sapDocumentNumber: 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,
};
// Set fetched amount if available balance exists });
if (availableBeforeBlock > 0) { setBlockedIOs(formattedIOs);
setFetchedAmount(availableBeforeBlock);
} // If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience
if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) {
setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
} }
} }
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]); }, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]);
/** /**
* Fetch available budget from SAP * Fetch available budget from SAP
@ -140,15 +132,25 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
try { try {
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later // Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
const ioData = await validateIO(requestId, ioNumber.trim()); const ioData = await validateIO(requestId, ioNumber.trim());
if (ioData.isValid && ioData.availableBalance > 0) { if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance); setFetchedAmount(ioData.availableBalance);
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
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');
@ -184,26 +186,33 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
} }
const blockAmountRaw = parseFloat(amountToBlock); const blockAmountRaw = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) { if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
toast.error('Please enter a valid amount to block'); toast.error('Please enter a valid amount to block');
return; return;
} }
// Round to exactly 2 decimal places to avoid floating point precision issues // Round to exactly 2 decimal places to avoid floating point precision issues
// Use parseFloat with toFixed to ensure exact 2 decimal precision // Use parseFloat with toFixed to ensure exact 2 decimal precision
const blockAmount = parseFloat(blockAmountRaw.toFixed(2)); const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
if (blockAmount > fetchedAmount) { if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget'); toast.error('Amount to block exceeds available IO budget');
return; return;
} }
// Validate that amount to block must exactly match estimated budget
// Calculate total already blocked
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
const totalPlanned = totalAlreadyBlocked + blockAmount;
// Validate that total planned must exactly match estimated budget
if (estimatedBudget > 0) { if (estimatedBudget > 0) {
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2)); const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
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;
} }
} }
@ -224,29 +233,29 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
ioBlockedAmount: blockAmount, ioBlockedAmount: blockAmount,
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value) ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value)
}; };
// Sending to backend // Sending to backend
await updateIODetails(requestId, payload); await updateIODetails(requestId, payload);
// Fetch updated claim details to get the blocked IO data // Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId); const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) { if (updatedInternalOrder) {
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount); const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0); const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
// Calculate expected remaining balance for validation/debugging // Calculate expected remaining balance for validation/debugging
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount; const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
// Blocking result processed // Blocking result processed
// Warn if the saved amount differs from what we sent // Warn if the saved amount differs from what we sent
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) { if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount); console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
} }
// Warn if remaining balance calculation seems incorrect (for backend debugging) // Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) { if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', { console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
@ -257,17 +266,17 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
difference: savedRemainingBalance - expectedRemainingBalance, difference: savedRemainingBalance - expectedRemainingBalance,
}); });
} }
const currentUser = user as any; const currentUser = user as any;
// When blocking, always use the current user who is performing the block action // When blocking, always use the current user who is performing the block action
// The organizer association may be from initial IO organization, but we want who blocked the amount // The organizer association may be from initial IO organization, but we want who blocked the amount
const blockedByName = currentUser?.displayName || const blockedByName = currentUser?.displayName ||
currentUser?.display_name || currentUser?.display_name ||
currentUser?.name || currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) || (currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email || currentUser?.email ||
'Current User'; 'Current User';
const blocked: IOBlockedDetails = { const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber, ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: savedBlockedAmount, blockedAmount: savedBlockedAmount,
@ -278,11 +287,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '', sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
status: 'blocked', status: 'blocked',
}; };
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
onRefresh?.(); onRefresh?.();
} else { } else {
@ -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">
@ -392,11 +402,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
<Button <Button
onClick={handleBlockBudget} onClick={handleBlockBudget}
disabled={ disabled={
blockingBudget || blockingBudget ||
!amountToBlock || !amountToBlock ||
parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount || parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs(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> </Badge>
<p className="text-xl font-bold text-green-700"> </div>
{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <div className="grid grid-cols-2 divide-x divide-y">
</p> <div className="p-3">
</div> <p className="text-[10px] text-gray-500 uppercase">Amount</p>
<div className="p-4"> <p className="text-sm font-bold text-green-700">{io.blockedAmount.toLocaleString('en-IN')}</p>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p> </div>
<p className="text-sm font-medium text-gray-900"> <div className="p-3">
{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <p className="text-[10px] text-gray-500 uppercase">SAP Doc</p>
</p> <p className="text-sm font-medium">{io.sapDocumentNumber || 'N/A'}</p>
</div> </div>
<div className="p-4 bg-blue-50"> <div className="p-3">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p> <p className="text-[10px] text-gray-500 uppercase">Blocked By</p>
<p className="text-sm font-bold text-blue-700"> <p className="text-xs">{io.blockedBy}</p>
{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} </div>
</p> <div className="p-3">
</div> <p className="text-[10px] text-gray-500 uppercase">Date</p>
<div className="p-4"> <p className="text-[10px]">{new Date(io.blockedDate).toLocaleString()}</p>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p> </div>
<p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p> </div>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked At</p>
<p className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
</p>
</div>
<div className="p-4 bg-gray-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
Blocked
</Badge>
</div> </div>
))}
<div className="mt-4 p-4 bg-[#2d4a3e] text-white rounded-lg flex justify-between items-center">
<span className="font-bold">Total Blocked:</span>
<span className="text-xl font-bold">{blockedIOs.reduce((s, i) => s + i.blockedAmount, 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
</div> </div>
</div> </div>
) : ( ) : (

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,6 +1,6 @@
/** /**
* ProcessDetailsCard Component * ProcessDetailsCard Component
* Displays process-related details: IO Number, 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
@ -120,7 +127,7 @@ export function ProcessDetailsCard({
</Label> </Label>
</div> </div>
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p> <p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
{ioDetails.remarks && ( {ioDetails.remarks && (
<div className="pt-2 border-t border-blue-100"> <div className="pt-2 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">Remark:</p> <p className="text-xs text-gray-600 mb-1">Remark:</p>
@ -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,19 +37,22 @@ 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;
} }
return 0; return 0;
}; };
@ -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,13 +54,16 @@ export function CreditNoteSAPModal({
requestNumber, requestNumber,
requestId: _requestId, requestId: _requestId,
dueDate, dueDate,
taxationType,
}: CreditNoteSAPModalProps) { }: CreditNoteSAPModalProps) {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== ''; const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
const creditNoteNumber = creditNoteData?.creditNoteNumber || ''; const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
const creditNoteDate = creditNoteData?.creditNoteDate const creditNoteDate = creditNoteData?.creditNoteDate
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' }) ? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
: ''; : '';
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0; const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
@ -69,7 +73,7 @@ export function CreditNoteSAPModal({
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009'; const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
const activity = activityName || 'Activity'; const activity = activityName || 'Activity';
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101'; const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
const dueDateDisplay = dueDate const dueDateDisplay = dueDate
? formatDateTime(dueDate, { includeTime: false, format: 'short' }) ? formatDateTime(dueDate, { includeTime: false, format: 'short' })
: 'Jan 4, 2026'; : 'Jan 4, 2026';
@ -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">
<Receipt className="w-6 h-6 text-[--re-green]" /> <div className="flex items-center gap-2">
Credit Note from SAP <Receipt className="w-6 h-6 text-[--re-green]" />
Credit Note from SAP
</div>
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </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

@ -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;
} }
} }
@ -64,5 +64,4 @@
right: 0.5rem; right: 0.5rem;
cursor: pointer; cursor: pointer;
opacity: 1; opacity: 1;
} }

View File

@ -25,7 +25,7 @@
@media (min-width: 1024px) { @media (min-width: 1024px) {
.dealer-proposal-modal { .dealer-proposal-modal {
width: 90vw !important; width: 90vw !important;
max-width: 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;
} }
} }
@ -64,5 +64,4 @@
right: 0.5rem; right: 0.5rem;
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 || '';
@ -97,7 +103,7 @@ export function DeptLeadIOApprovalModal({
try { try {
setSubmitting(true); setSubmitting(true);
if (actionType === 'approve') { if (actionType === 'approve') {
await onApprove({ await onApprove({
ioNumber: ioNumber.trim(), ioNumber: ioNumber.trim(),
@ -106,7 +112,7 @@ export function DeptLeadIOApprovalModal({
} else { } else {
await onReject(comments.trim()); await onReject(comments.trim());
} }
handleReset(); handleReset();
onClose(); onClose();
} catch (error) { } catch (error) {
@ -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,11 +185,10 @@ 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' }`}
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'} variant={actionType === 'approve' ? 'default' : 'ghost'}
> >
<CircleCheckBig className="w-4 h-4 mr-1" /> <CircleCheckBig className="w-4 h-4 mr-1" />
@ -187,11 +197,10 @@ 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' }`}
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'} variant={actionType === 'reject' ? 'destructive' : 'ghost'}
> >
<CircleX className="w-4 h-4 mr-1" /> <CircleX className="w-4 h-4 mr-1" />
@ -208,7 +217,7 @@ export function DeptLeadIOApprovalModal({
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" /> <Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4> <h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
</div> </div>
{/* IO Number - Read-only from IO table */} {/* IO Number - Read-only from IO table */}
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2"> <Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
@ -309,11 +318,10 @@ 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`}
> >
{submitting ? ( {submitting ? (
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...` `${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`

View File

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

View File

@ -16,12 +16,12 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
CheckCircle, CheckCircle,
XCircle, XCircle,
FileText, FileText,
IndianRupee, IndianRupee,
Calendar, Calendar,
MessageSquare, MessageSquare,
Download, Download,
Eye, Eye,
@ -39,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,16 +86,83 @@ export function InitiatorProposalApprovalModal({
requestId: _requestId, // Prefix with _ to indicate intentionally unused requestId: _requestId, // Prefix with _ to indicate intentionally unused
request, request,
previousProposalData, previousProposalData,
taxationType,
}: InitiatorProposalApprovalModalProps) { }: InitiatorProposalApprovalModalProps) {
const isNonGst = useMemo(() => {
return taxationType === 'Non GST' || taxationType === 'Non-GST';
}, [taxationType]);
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null); const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
const [showPreviousProposal, setShowPreviousProposal] = useState(false); const [showPreviousProposal, setShowPreviousProposal] = useState(false);
// Calculate total budget (needed for display)
const totalBudget = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
// Ensure costBreakup is an array
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
const baseTotal = amount * quantity;
const gst = typeof item === 'object' ? (item.gstAmt || 0) : 0;
const total = item.totalAmt || (baseTotal + gst);
return sum + (Number(total) || 0);
}, 0);
}, [proposalData]);
// Calculate total base amount (needed for budget verification as requested)
// This is the taxable amount excluding GST
const totalBaseAmount = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
return sum + (Number(amount) * Number(quantity));
}, 0);
}, [proposalData]);
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level) // Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
const internalOrder = request?.internalOrder || request?.internal_order; // Sum up all successful blocks from internalOrders array
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0; const totalBlockedAmount = useMemo(() => {
const isIOBlocked = ioBlockedAmount > 0; 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<{ const [previewDoc, setPreviewDoc] = useState<{
name: string; name: string;
url: string; url: string;
@ -102,25 +171,6 @@ export function InitiatorProposalApprovalModal({
id?: string; id?: string;
} | null>(null); } | null>(null);
// Calculate total budget
const totalBudget = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
// Ensure costBreakup is an array
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
return sum + (Number(amount) || 0);
}, 0);
}, [proposalData]);
// Format date // Format date
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return '—'; if (!dateString) return '—';
@ -141,11 +191,11 @@ export function InitiatorProposalApprovalModal({
if (!doc.name) return false; if (!doc.name) return false;
const name = doc.name.toLowerCase(); const name = doc.name.toLowerCase();
return name.endsWith('.pdf') || return name.endsWith('.pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpg') ||
name.endsWith('.jpeg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.png') ||
name.endsWith('.gif') || name.endsWith('.gif') ||
name.endsWith('.webp'); name.endsWith('.webp');
}; };
// Handle document preview - leverage FilePreview's internal fetching // Handle document preview - leverage FilePreview's internal fetching
@ -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">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" /> <div className="flex items-center gap-2">
Requestor Evaluation & Confirmation <CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
Requestor Evaluation & Confirmation
</div>
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
</DialogTitle> </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
@ -296,11 +353,11 @@ export function InitiatorProposalApprovalModal({
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6"> <div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6">
{/* Previous Proposal Reference Section */} {/* Previous Proposal Reference Section */}
{previousProposalData && ( {previousProposalData && (
<div className="mb-6"> <div className="mb-6">
<div <div
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors" className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
onClick={() => setShowPreviousProposal(!showPreviousProposal)} onClick={() => setShowPreviousProposal(!showPreviousProposal)}
> >
@ -316,48 +373,48 @@ export function InitiatorProposalApprovalModal({
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />} {showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
</Button> </Button>
</div> </div>
{showPreviousProposal && ( {showPreviousProposal && (
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50"> <div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
{/* Header Info: Date & Document */} {/* Header Info: Date & Document */}
<div className="flex flex-wrap gap-4 text-xs mt-3"> <div className="flex flex-wrap gap-4 text-xs mt-3">
{previousProposalData.expectedCompletionDate && ( {previousProposalData.expectedCompletionDate && (
<div className="flex items-center gap-1.5 text-gray-700"> <div className="flex items-center gap-1.5 text-gray-700">
<Calendar className="w-3.5 h-3.5 text-gray-500" /> <Calendar className="w-3.5 h-3.5 text-gray-500" />
<span className="font-medium">Expected Completion:</span> <span className="font-medium">Expected Completion:</span>
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span> <span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
</div> </div>
)} )}
{previousProposalData.documentUrl && ( {previousProposalData.documentUrl && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? ( {canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
<> <>
<Eye className="w-3.5 h-3.5 text-blue-500" /> <Eye className="w-3.5 h-3.5 text-blue-500" />
<a <a
href={previousProposalData.documentUrl} href={previousProposalData.documentUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1" className="text-blue-600 hover:underline font-medium flex items-center gap-1"
> >
View Previous Document View Previous Document
</a> </a>
</> </>
) : ( ) : (
<> <>
<Download className="w-3.5 h-3.5 text-blue-500" /> <Download className="w-3.5 h-3.5 text-blue-500" />
<a <a
href={previousProposalData.documentUrl} href={previousProposalData.documentUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline font-medium flex items-center gap-1" className="text-blue-600 hover:underline font-medium flex items-center gap-1"
> >
Download Previous Document Download Previous Document
</a> </a>
</> </>
)} )}
</div> </div>
)} )}
</div> </div>
{/* Cost Breakdown */} {/* Cost Breakdown */}
@ -395,43 +452,43 @@ export function InitiatorProposalApprovalModal({
</div> </div>
</div> </div>
)} )}
{/* Additional/Supporting Documents */} {/* Additional/Supporting Documents */}
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && ( {previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
<div className="w-full pt-2 border-t border-amber-200/50"> <div className="w-full pt-2 border-t border-amber-200/50">
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1"> <p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
<FileText className="w-3 h-3" /> <FileText className="w-3 h-3" />
Supporting Documents Supporting Documents
</p> </p>
<div className="space-y-2 max-h-[150px] overflow-y-auto"> <div className="space-y-2 max-h-[150px] overflow-y-auto">
{previousProposalData.otherDocuments.map((doc: any, idx: number) => ( {previousProposalData.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard <DocumentCard
key={idx} key={idx}
document={{ document={{
documentId: doc.documentId || doc.id || '', documentId: doc.documentId || doc.id || '',
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document', name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file', fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
uploadedAt: doc.uploadedAt || new Date().toISOString() uploadedAt: doc.uploadedAt || new Date().toISOString()
}} }}
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined} onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
onDownload={async (id) => { onDownload={async (id) => {
if (id) { if (id) {
await downloadDocument(id); await downloadDocument(id);
} else { } else {
let downloadUrl = doc.storageUrl || doc.documentUrl; let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) { if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || ''; const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`; const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`; downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
} }
if (downloadUrl) window.open(downloadUrl, '_blank'); if (downloadUrl) window.open(downloadUrl, '_blank');
} }
}} }}
/> />
))} ))}
</div>
</div> </div>
</div>
)} )}
{/* Comments */} {/* Comments */}
@ -453,247 +510,273 @@ export function InitiatorProposalApprovalModal({
)} )}
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start"> <div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
{/* Left Column - Documents */} {/* Left Column - Documents */}
<div className="space-y-4 lg:space-y-4 flex flex-col"> <div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Proposal Document Section */} {/* Proposal Document Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-600" />
Proposal Document
</h3>
</div>
{proposalData?.proposalDocument ? (
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
{proposalData.proposalDocument.name}
</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500 truncate">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{proposalData.proposalDocument.id && (
<>
{canPreviewDocument(proposalData.proposalDocument) && (
<button
type="button"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (proposalData.proposalDocument?.id) {
await downloadDocument(proposalData.proposalDocument.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</>
)}
</div>
</div>
) : (
<p className="text-xs text-gray-500 italic">No proposal document available</p>
)}
</div>
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-600" />
Other Supporting Documents
</h3>
<Badge variant="secondary" className="text-xs">
{proposalData.otherDocuments.length} file(s)
</Badge>
</div>
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
>
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
{doc.name}
</p>
</div>
{doc.id && (
<div className="flex items-center gap-1 flex-shrink-0">
{canPreviewDocument(doc) && (
<button
type="button"
onClick={() => handlePreviewDocument(doc)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (doc.id) {
await downloadDocument(doc.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Right Column - Planning & Details */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Cost Breakup Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-green-600" />
Cost Breakup
</h3>
</div>
{(() => {
// Ensure costBreakup is an array
const costBreakup = proposalData?.costBreakup
? (Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []))
: [];
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
<>
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
<div className="grid grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
<div>Item Description</div>
<div className="text-right">Amount</div>
</div>
</div>
<div className="divide-y">
{costBreakup.map((item: any, index: number) => (
<div key={item?.id || item?.description || index} className="px-3 lg:px-4 py-2 lg:py-3 grid grid-cols-2 gap-4">
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
))}
</div>
</div>
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-[--re-green]" />
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
</div>
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</>
) : (
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<Calendar className="w-4 h-4 text-purple-600" />
Expected Completion Date
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p>
</div>
</div>
</div>
{/* Comments Section - Side by Side */}
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
{/* Dealer Comments */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2"> <h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-600" /> <FileText className="w-4 h-4 text-blue-600" />
Dealer Comments Proposal Document
</h3> </h3>
</div> </div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto"> {proposalData?.proposalDocument ? (
<p className="text-xs text-gray-700 whitespace-pre-wrap"> <div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
{proposalData?.dealerComments || 'No comments provided'} <div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
{proposalData.proposalDocument.name}
</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500 truncate">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{proposalData.proposalDocument.id && (
<>
{canPreviewDocument(proposalData.proposalDocument) && (
<button
type="button"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (proposalData.proposalDocument?.id) {
await downloadDocument(proposalData.proposalDocument.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</>
)}
</div>
</div>
) : (
<p className="text-xs text-gray-500 italic">No proposal document available</p>
)}
</div>
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-600" />
Other Supporting Documents
</h3>
<Badge variant="secondary" className="text-xs">
{proposalData.otherDocuments.length} file(s)
</Badge>
</div>
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
>
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
{doc.name}
</p>
</div>
{doc.id && (
<div className="flex items-center gap-1 flex-shrink-0">
{canPreviewDocument(doc) && (
<button
type="button"
onClick={() => handlePreviewDocument(doc)}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Preview document"
>
<Eye className="w-5 h-5 text-blue-600" />
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (doc.id) {
await downloadDocument(doc.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Right Column - Planning & Details */}
<div className="space-y-4 lg:space-y-4 flex flex-col">
{/* Cost Breakup Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-green-600" />
Cost Breakup
</h3>
</div>
{(() => {
// Ensure costBreakup is an array
const costBreakup = proposalData?.costBreakup
? (Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []))
: [];
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
<>
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
<div className={`grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4 text-xs lg:text-sm font-semibold text-gray-700`}>
<div className="col-span-1">Item Description</div>
<div className="text-right">Base</div>
{!isNonGst && <div className="text-right">GST</div>}
<div className="text-right">Total</div>
</div>
</div>
<div className="divide-y">
{costBreakup.map((item: any, index: number) => (
<div key={item?.id || item?.description || index} className={`px-3 lg:px-4 py-2 lg:py-3 grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4`}>
<div className="col-span-1 text-xs lg:text-sm text-gray-700">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="font-medium">
{item?.description?.startsWith('[ADDITIONAL]')
? item.description.replace('[ADDITIONAL]', '').trim()
: (item?.description || 'N/A')}
</span>
{costBreakup.some((i: any) => i?.description?.startsWith('[ADDITIONAL]')) && (
item?.description?.startsWith('[ADDITIONAL]') ? (
<Badge className="text-[9px] h-3.5 px-1 bg-amber-100 text-amber-700 hover:bg-amber-100 border-none leading-none">ADDITIONAL</Badge>
) : (
<Badge className="text-[9px] h-3.5 px-1 bg-gray-100 text-gray-600 hover:bg-gray-100 border-none leading-none">ORIGINAL</Badge>
)
)}
</div>
{!isNonGst && item?.gstRate ? <span className="block text-[10px] text-gray-400">{item.gstRate}% GST</span> : null}
</div>
<div className="text-xs lg:text-sm text-gray-900 text-right">
{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
{!isNonGst && (
<div className="text-xs lg:text-sm text-gray-900 text-right">
{(Number(item?.gstAmt) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
{(Number(item?.totalAmt || ((item?.amount || 0) * (item?.quantity || 1) + (item?.gstAmt || 0))) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
))}
</div>
</div>
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<IndianRupee className="w-4 h-4 text-[--re-green]" />
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
</div>
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</>
) : (
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<Calendar className="w-4 h-4 text-purple-600" />
Expected Completion Date
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p> </p>
</div> </div>
</div> </div>
</div>
{/* Your Decision & Comments */} {/* Comments Section - Side by Side */}
<div className="space-y-2"> <div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<Textarea {/* Dealer Comments */}
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..." <div className="space-y-2">
value={comments} <div className="flex items-center gap-2">
onChange={(e) => setComments(e.target.value)} <h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full" <MessageSquare className="w-4 h-4 text-blue-600" />
/> Dealer Comments
<p className="text-xs text-gray-500">{comments.length} characters</p> </h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
<p className="text-xs text-gray-700 whitespace-pre-wrap">
{proposalData?.dealerComments || 'No comments provided'}
</p>
</div>
</div>
{/* Your Decision & Comments */}
<div className="space-y-2">
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
<Textarea
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
</div> </div>
</div> </div>
</div>
{/* Warning for missing comments */} {/* Warning for missing comments */}
{!comments.trim() && ( {!comments.trim() && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" /> <XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800"> <p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants. Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p> </p>
</div> </div>
)} )}
</div> </div>
</div> </div>
@ -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';
@ -153,10 +153,14 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number) // Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
const currentUserId = (user as any)?.userId || ''; const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims // IO tab visibility for dealer claims
// 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,
@ -177,7 +181,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// State to temporarily store approval level for modal (used for additional approvers) // State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null); const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel // Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel; const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
@ -220,7 +224,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Check both lowercase and uppercase status values // Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase(); const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator; const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
// Closure check completed // Closure check completed
const { const {
conclusionRemark, conclusionRemark,
@ -335,7 +339,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try { try {
setLoadingSummary(true); setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId); const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) { if (summary?.summaryId) {
setSummaryId(summary.summaryId); setSummaryId(summary.summaryId);
try { try {
@ -376,9 +380,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const notifRequestId = notif.requestId || notif.request_id; const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number; const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId && if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier && notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return; notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata // Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) { if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
@ -427,15 +431,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message} {accessDenied.message}
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -460,15 +464,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
The dealer claim request you're looking for doesn't exist or may have been deleted. The dealer claim request you're looking for doesn't exist or may have been deleted.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -598,8 +602,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{isClosed && ( {isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content"> <TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab <SummaryTab
summary={summaryDetails} summary={summaryDetails}
loading={loadingSummary} loading={loadingSummary}
onShare={handleShareSummary} onShare={handleShareSummary}
isInitiator={isInitiator} isInitiator={isInitiator}
@ -673,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
@ -26,7 +27,7 @@ export function useDocumentUpload(
) { ) {
// State: Indicates if document is currently being uploaded // State: Indicates if document is currently being uploaded
const [uploadingDocument, setUploadingDocument] = useState(false); const [uploadingDocument, setUploadingDocument] = useState(false);
// State: Stores document for preview modal // State: Stores document for preview modal
const [previewDocument, setPreviewDocument] = useState<{ const [previewDocument, setPreviewDocument] = useState<{
fileName: string; fileName: string;
@ -101,7 +102,7 @@ export function useDocumentUpload(
// Check file extension // Check file extension
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return { return {
valid: false, valid: false,
@ -130,12 +131,12 @@ export function useDocumentUpload(
*/ */
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
// Validate: Check if file is selected // Validate: Check if file is selected
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const fileArray = Array.from(files); const fileArray = Array.from(files);
// Validate all files against document policy // Validate all files against document policy
const validationErrors: Array<{ fileName: string; reason: string }> = []; const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = []; const validFiles: File[] = [];
@ -169,11 +170,11 @@ export function useDocumentUpload(
} }
setUploadingDocument(true); setUploadingDocument(true);
try { try {
// Upload only the first valid file (backend currently supports single file) // Upload only the first valid file (backend currently supports single file)
const file = validFiles[0]; const file = validFiles[0];
// Validate: Ensure request ID is available // Validate: Ensure request ID is available
// Note: Backend requires UUID, not request number // Note: Backend requires UUID, not request number
const requestId = apiRequest?.requestId; const requestId = apiRequest?.requestId;
@ -181,17 +182,17 @@ export function useDocumentUpload(
toast.error('Request ID not found'); toast.error('Request ID not found');
return; return;
} }
// API Call: Upload document to backend // API Call: Upload document to backend
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED') // Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
if (file) { if (file) {
await uploadDocument(file, requestId, 'SUPPORTING'); await uploadDocument(file, requestId, 'SUPPORTING');
} }
// Refresh: Reload request details to show newly uploaded document // Refresh: Reload request details to show newly uploaded document
// This also updates the activity timeline // This also updates the activity timeline
await refreshDetails(); await refreshDetails();
// Success feedback // Success feedback
if (validFiles.length < fileArray.length) { if (validFiles.length < fileArray.length) {
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`); toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
@ -200,12 +201,14 @@ 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);
// Cleanup: Clear the file input to allow re-uploading same file // Cleanup: Clear the file input to allow re-uploading same file
if (event.target) { if (event.target) {
event.target.value = ''; event.target.value = '';

View File

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

View File

@ -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>
@ -141,8 +141,8 @@ export function Auth() {
> >
{tanflowLoading ? ( {tanflowLoading ? (
<> <>
<div <div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/> />
Redirecting... Redirecting...
</> </>
@ -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

@ -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

@ -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,11 +133,12 @@ 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
); );
await updateWorkflowRequest( await updateWorkflowRequest(
@ -158,11 +159,12 @@ 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
); );
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,36 +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);
}
/**
* Create and submit a workflow in one operation
*/
export async function createAndSubmitWorkflow(
payload: CreateWorkflowPayload,
documents: File[]
): Promise<{ id: string }> {
const result = await createWorkflow(payload, documents);
await submitWorkflowRequest(result.id);
return result;
}
/**
* Update and submit a workflow in one operation
*/
export async function updateAndSubmitWorkflow(
requestId: string,
payload: UpdateWorkflowPayload,
documents: File[],
documentsToDelete: string[]
): Promise<void> {
await updateWorkflowRequest(requestId, payload, documents, documentsToDelete);
await submitWorkflowRequest(requestId);
}

View File

@ -67,6 +67,7 @@ export interface CreateWorkflowPayload {
email: string; email: string;
}>; }>;
participants: Participant[]; participants: Participant[];
isDraft?: boolean;
} }
export interface UpdateWorkflowPayload { export interface UpdateWorkflowPayload {
@ -76,6 +77,7 @@ export interface UpdateWorkflowPayload {
approvalLevels: ApprovalLevel[]; approvalLevels: ApprovalLevel[];
participants: Participant[]; participants: Participant[];
deleteDocumentIds?: string[]; deleteDocumentIds?: string[];
isDraft?: boolean;
} }
export interface ValidationModalState { export interface ValidationModalState {

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,
}; };
} }
@ -84,7 +80,7 @@ export function validateApproversForSubmission(
approverCount: number approverCount: number
): { valid: boolean; message?: string } { ): { valid: boolean; message?: string } {
const approversToCheck = approvers.slice(0, approverCount); const approversToCheck = approvers.slice(0, approverCount);
// Check if all approvers have valid emails // Check if all approvers have valid emails
const hasMissingEmails = approversToCheck.some( const hasMissingEmails = approversToCheck.some(
(a: any) => !a?.email || !a.email.trim() (a: any) => !a?.email || !a.email.trim()
@ -112,4 +108,3 @@ export function validateApproversForSubmission(
return { valid: true }; return { valid: true };
} }

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>
@ -122,7 +122,7 @@ export function TATBreachReport({
params.set('approver', req.approverId!); params.set('approver', req.approverId!);
params.set('approverType', 'current'); params.set('approverType', 'current');
params.set('slaCompliance', 'breached'); params.set('slaCompliance', 'breached');
if (dateRange) params.set('dateRange', dateRange); if (dateRange) params.set('dateRange', dateRange);
if (customStartDate) params.set('startDate', customStartDate.toISOString()); if (customStartDate) params.set('startDate', customStartDate.toISOString());
if (customEndDate) params.set('endDate', customEndDate.toISOString()); if (customEndDate) params.set('endDate', customEndDate.toISOString());
@ -164,11 +164,10 @@ 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'
}`} }`}
> >
{req.priority} {req.priority}
</Badge> </Badge>

View File

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

@ -16,23 +16,29 @@ 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();
} // 2. Replace <br> with space
text = text.replace(/<br\s*\/?>/gi, ' ');
// Create a temporary div to parse HTML
const tempDiv = document.createElement('div'); // 3. Strip all other tags
tempDiv.innerHTML = html; text = text.replace(/<[^>]*>/g, '');
// Get text content (automatically strips HTML tags) // 4. Clean up extra whitespace
let text = tempDiv.textContent || tempDiv.innerText || '';
// 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;
}; };
@ -101,18 +107,18 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
{(() => { {(() => {
const templateType = request?.templateType || (request as any)?.template_type || ''; const templateType = request?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType
let templateLabel = 'Non-Templatized'; let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200'; let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') { if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim'; templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';
} }
return ( return (
<Badge <Badge
variant="outline" variant="outline"

View File

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

View File

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

View File

@ -11,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;
@ -185,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,
@ -226,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);
@ -332,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
@ -590,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>
@ -599,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}

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

View File

@ -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;
}; };

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) {
if (user) { try {
setSelectedUser(user); // Fetch specific user details by ID
setSearchQuery(user.displayName || user.email); const user = await userApi.getUserById(filterValue);
if (user) {
setSelectedUser(user);
setSearchQuery(user.displayName || user.email);
}
} catch (err) {
console.error('Failed to fetch user detail for search:', err);
}
} else if (filterValue === 'all') {
setSelectedUser(null);
setSearchQuery('');
} }
} }
}, [filterValue, allUsers]);
fetchUserDetail();
}, [filterValue]);
// Cleanup timer // Cleanup timer
useEffect(() => { useEffect(() => {
@ -51,17 +65,22 @@ export function useUserSearch({ allUsers, filterValue, onFilterChange }: UseUser
return; return;
} }
searchTimer.current = setTimeout(() => { searchTimer.current = setTimeout(async () => {
const searchLower = query.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

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,6 +25,7 @@ export interface CreateWorkflowFromFormPayload {
approvers: ApproverFormItem[]; approvers: ApproverFormItem[];
spectators?: ParticipantItem[]; spectators?: ParticipantItem[];
ccList?: ParticipantItem[]; ccList?: ParticipantItem[];
isDraft?: boolean; // Added isDraft to the payload interface
} }
// Utility to generate a RFC4122 v4 UUID (fallback if crypto.randomUUID not available) // Utility to generate a RFC4122 v4 UUID (fallback if crypto.randomUUID not available)
@ -102,6 +103,7 @@ export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload
priority, // STANDARD | EXPRESS priority, // STANDARD | EXPRESS
approvalLevels, approvalLevels,
participants: participants.length ? participants : undefined, participants: participants.length ? participants : undefined,
isDraft: form.isDraft, // Added isDraft to the payload
}; };
const res = await apiClient.post('/workflows', payload); const res = await apiClient.post('/workflows', payload);
@ -116,15 +118,16 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
title: form.title, title: form.title,
description: form.description, description: form.description,
priority: form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD', priority: form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD',
isDraft: form.isDraft, // Added isDraft to the payload
// Simplified approvers format - only email and tatHours required // Simplified approvers format - only email and tatHours required
approvers: Array.from({ length: form.approverCount || 1 }, (_, i) => { approvers: Array.from({ length: form.approverCount || 1 }, (_, i) => {
const a = form.approvers[i] || ({} as any); const a = form.approvers[i] || ({} as any);
const tat = typeof a.tat === 'number' ? a.tat : 0; const tat = typeof a.tat === 'number' ? a.tat : 0;
if (!a.email || !a.email.trim()) { if (!a.email || !a.email.trim()) {
throw new Error(`Email is required for approver at level ${i + 1}.`); throw new Error(`Email is required for approver at level ${i + 1}.`);
} }
return { return {
email: a.email, email: a.email,
tat: tat, tat: tat,
@ -132,14 +135,14 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
}; };
}), }),
}; };
// Add spectators if any (simplified - only email required) // Add spectators if any (simplified - only email required)
if (form.spectators && form.spectators.length > 0) { if (form.spectators && form.spectators.length > 0) {
payload.spectators = form.spectators payload.spectators = form.spectators
.filter((s: any) => s?.email) .filter((s: any) => s?.email)
.map((s: any) => ({ email: s.email })); .map((s: any) => ({ email: s.email }));
} }
// Note: participants array is auto-generated by backend from approvers and spectators // Note: participants array is auto-generated by backend from approvers and spectators
// No need to build or send it from frontend // No need to build or send it from frontend
@ -157,22 +160,22 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) { export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params; const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows', { const res = await apiClient.get('/workflows', {
params: { params: {
page, page,
limit, limit,
search, search,
status, status,
priority, priority,
templateType, templateType,
department, department,
initiator, initiator,
approver, approver,
slaCompliance, slaCompliance,
dateRange, dateRange,
startDate, startDate,
endDate endDate
} }
}); });
return res.data?.data || res.data; return res.data?.data || res.data;
} }
@ -181,12 +184,12 @@ export async function listWorkflows(params: { page?: number; limit?: number; sea
// SEPARATE from listWorkflows (admin) to avoid interference // SEPARATE from listWorkflows (admin) to avoid interference
export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) { export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params; const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/participant-requests', { const res = await apiClient.get('/workflows/participant-requests', {
params: { params: {
page, page,
limit, limit,
search, search,
status, status,
priority, priority,
templateType, templateType,
department, department,
@ -210,12 +213,12 @@ export async function listParticipantRequests(params: { page?: number; limit?: n
// List requests where user is a participant (not initiator) - for "All Requests" page // List requests where user is a participant (not initiator) - for "All Requests" page
export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) { export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params; const { page = 1, limit = 20, search, status, priority, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my', { const res = await apiClient.get('/workflows/my', {
params: { params: {
page, page,
limit, limit,
search, search,
status, status,
priority, priority,
department, department,
initiator, initiator,
@ -224,7 +227,7 @@ export async function listMyWorkflows(params: { page?: number; limit?: number; s
dateRange, dateRange,
startDate, startDate,
endDate endDate
} }
}); });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
@ -236,12 +239,12 @@ export async function listMyWorkflows(params: { page?: number; limit?: number; s
// List requests where user is the initiator - for "My Requests" page // List requests where user is the initiator - for "My Requests" page
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) { export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate } = params; const { page = 1, limit = 20, search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my-initiated', { const res = await apiClient.get('/workflows/my-initiated', {
params: { params: {
page, page,
limit, limit,
search, search,
status, status,
priority, priority,
templateType, templateType,
department, department,
@ -249,7 +252,7 @@ export async function listMyInitiatedWorkflows(params: { page?: number; limit?:
dateRange, dateRange,
startDate, startDate,
endDate endDate
} }
}); });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
@ -304,22 +307,22 @@ export async function addApprover(requestId: string, email: string) {
} }
export async function addApproverAtLevel( export async function addApproverAtLevel(
requestId: string, requestId: string,
email: string, email: string,
tatHours: number, tatHours: number,
level: number level: number
) { ) {
const res = await apiClient.post(`/workflows/${requestId}/approvers/at-level`, { const res = await apiClient.post(`/workflows/${requestId}/approvers/at-level`, {
email, email,
tatHours, tatHours,
level level
}); });
return res.data?.data || res.data; return res.data?.data || res.data;
} }
export async function skipApprover(requestId: string, levelId: string, reason?: string) { export async function skipApprover(requestId: string, levelId: string, reason?: string) {
const res = await apiClient.post(`/workflows/${requestId}/approvals/${levelId}/skip`, { const res = await apiClient.post(`/workflows/${requestId}/approvals/${levelId}/skip`, {
reason reason
}); });
return res.data?.data || res.data; return res.data?.data || res.data;
} }
@ -359,12 +362,12 @@ export async function getPauseDetails(requestId: string) {
} }
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string { export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000'; const baseURL = import.meta.env.VITE_BASE_URL || '';
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`; return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
} }
export function getDocumentPreviewUrl(documentId: string): string { export function getDocumentPreviewUrl(documentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000'; const baseURL = import.meta.env.VITE_BASE_URL || '';
return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`; return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`;
} }
@ -376,7 +379,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
if (!contentDisposition) { if (!contentDisposition) {
return 'download'; return 'download';
} }
// Try to extract from filename* first (RFC 5987 encoded) - preferred for non-ASCII // Try to extract from filename* first (RFC 5987 encoded) - preferred for non-ASCII
const filenameStarMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/); const filenameStarMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/);
if (filenameStarMatch && filenameStarMatch[1]) { if (filenameStarMatch && filenameStarMatch[1]) {
@ -386,7 +389,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
// If decoding fails, fall back to regular filename // If decoding fails, fall back to regular filename
} }
} }
// Fallback to regular filename (for ASCII-only filenames) // Fallback to regular filename (for ASCII-only filenames)
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/); const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/);
if (filenameMatch && filenameMatch.length > 1 && filenameMatch[1]) { if (filenameMatch && filenameMatch.length > 1 && filenameMatch[1]) {
@ -396,42 +399,42 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
const extracted = parts[0]?.trim(); const extracted = parts[0]?.trim();
return extracted || 'download'; return extracted || 'download';
} }
return 'download'; return 'download';
} }
export async function downloadDocument(documentId: string): Promise<void> { export async function downloadDocument(documentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000'; const baseURL = import.meta.env.VITE_BASE_URL || '';
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`; const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
// Build fetch options // Build fetch options
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production credentials: 'include', // Send cookies in production
}; };
// In development, add Authorization header from localStorage // In development, add Authorization header from localStorage
if (!isProduction) { if (!isProduction) {
const token = localStorage.getItem('accessToken'); const token = localStorage.getItem('accessToken');
fetchOptions.headers = { fetchOptions.headers = {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
}; };
} }
const response = await fetch(downloadUrl, fetchOptions); const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`); throw new Error(`Download failed: ${response.status} - ${errorText}`);
} }
const blob = await response.blob(); const blob = await response.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get('Content-Disposition');
const filename = extractFilenameFromContentDisposition(contentDisposition); const filename = extractFilenameFromContentDisposition(contentDisposition);
const downloadLink = document.createElement('a'); const downloadLink = document.createElement('a');
downloadLink.href = url; downloadLink.href = url;
downloadLink.download = filename; downloadLink.download = filename;
@ -446,38 +449,38 @@ export async function downloadDocument(documentId: string): Promise<void> {
} }
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> { export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000'; const downloadBaseURL = import.meta.env.VITE_BASE_URL || '';
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`; const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
// Build fetch options // Build fetch options
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production credentials: 'include', // Send cookies in production
}; };
// In development, add Authorization header from localStorage // In development, add Authorization header from localStorage
if (!isProduction) { if (!isProduction) {
const token = localStorage.getItem('accessToken'); const token = localStorage.getItem('accessToken');
fetchOptions.headers = { fetchOptions.headers = {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
}; };
} }
const response = await fetch(downloadUrl, fetchOptions); const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`); throw new Error(`Download failed: ${response.status} - ${errorText}`);
} }
const blob = await response.blob(); const blob = await response.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
// Get filename from Content-Disposition header or use default // Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition'); const contentDisposition = response.headers.get('Content-Disposition');
const filename = extractFilenameFromContentDisposition(contentDisposition); const filename = extractFilenameFromContentDisposition(contentDisposition);
const downloadLink = document.createElement('a'); const downloadLink = document.createElement('a');
downloadLink.href = url; downloadLink.href = url;
downloadLink.download = filename; downloadLink.download = filename;
@ -522,14 +525,14 @@ export async function updateWorkflowMultipart(requestId: string, updateData: any
...updateData, ...updateData,
deleteDocumentIds: deleteDocumentIds || [] deleteDocumentIds: deleteDocumentIds || []
}; };
const formData = new FormData(); const formData = new FormData();
formData.append('payload', JSON.stringify(payload)); formData.append('payload', JSON.stringify(payload));
formData.append('category', 'SUPPORTING'); formData.append('category', 'SUPPORTING');
if (files && files.length > 0) { if (files && files.length > 0) {
files.forEach(f => formData.append('files', f)); files.forEach(f => formData.append('files', f));
} }
const res = await apiClient.put(`/workflows/${requestId}/multipart`, formData, { const res = await apiClient.put(`/workflows/${requestId}/multipart`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
@ -560,10 +563,10 @@ export async function updateAndSubmitWorkflow(requestId: string, workflowData: C
description: workflowData.description, description: workflowData.description,
priority: workflowData.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD', priority: workflowData.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD',
}; };
// Update workflow details // Update workflow details
await apiClient.put(`/workflows/${requestId}`, payload); await apiClient.put(`/workflows/${requestId}`, payload);
// If files provided, update documents (this would need backend support for updating documents) // If files provided, update documents (this would need backend support for updating documents)
// For now, we'll just submit the updated workflow // For now, we'll just submit the updated workflow
const res = await apiClient.patch(`/workflows/${requestId}/submit`); const res = await apiClient.patch(`/workflows/${requestId}/submit`);
@ -577,7 +580,7 @@ export async function updateBreachReason(levelId: string, breachReason: string):
const response = await apiClient.put(`/tat/breach-reason/${levelId}`, { const response = await apiClient.put(`/tat/breach-reason/${levelId}`, {
breachReason breachReason
}); });
if (!response.data.success) { if (!response.data.success) {
throw new Error(response.data.error || 'Failed to update breach reason'); throw new Error(response.data.error || 'Failed to update breach reason');
} }

View File

@ -0,0 +1,42 @@
/* 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;
}
/* Table wrapper for CSP-compliant horizontal scrolling */
.table-wrapper {
overflow-x: auto;
max-width: 100%;
margin: 8px 0;
}

View File

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

View File

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

View File

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

View File

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

122
src/utils/gstUtils.ts Normal file
View File

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

28
src/utils/sanitizer.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* Sanitizes HTML content by removing dangerous attributes and tags.
* This is used to comply with CSP policies and prevent XSS.
*/
export function sanitizeHTML(html: string): string {
if (!html) return '';
// 1. Remove script tags completely
let sanitized = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
// 2. Remove all "on*" event handler attributes (onclick, onload, etc.)
// This handles attributes like onclick="alert(1)" or onclick='alert(1)' or onclick=alert(1)
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:'[^']*'|"[^"]*"|[^\s>]+)/gi, '');
// 3. Remove "javascript:" pseudo-protocols in href or src
sanitized = sanitized.replace(/(href|src)\s*=\s*(?:'javascript:[^']*'|"javascript:[^"]*"|javascript:[^\s>]+)/gi, '$1="#"');
// 4. Remove <style> tags (to comply with style-src)
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 5. Remove meta and link tags (except for purely visual ones if needed, but safer to remove)
sanitized = sanitized.replace(/<(meta|link|iframe|object|embed|applet)[^>]*>/gi, '');
// 6. Explicitly remove <a> tags to prevent HTML injection of links (VAPT compliance)
sanitized = sanitized.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, '$1');
return sanitized;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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