Compare commits
No commits in common. "80ed407cd8e6f660c98b0b50fc9bc45aaf57db32" and "08cda349f3ee4f3cfc4c12cd5e0adf8f8a3081f3" have entirely different histories.
80ed407cd8
...
08cda349f3
195
src/App.tsx
195
src/App.tsx
@ -412,6 +412,201 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the old code below for backward compatibility (local storage fallback)
|
||||||
|
// This can be removed once API integration is fully tested
|
||||||
|
/*
|
||||||
|
// Generate unique ID for the new claim request
|
||||||
|
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
||||||
|
|
||||||
|
// Create full request object
|
||||||
|
const newRequest = {
|
||||||
|
id: requestId,
|
||||||
|
title: `${claimData.activityName} - Claim Request`,
|
||||||
|
description: claimData.requestDescription,
|
||||||
|
category: 'Dealer Operations',
|
||||||
|
subcategory: 'Claim Management',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'standard',
|
||||||
|
amount: 'TBD',
|
||||||
|
slaProgress: 0,
|
||||||
|
slaRemaining: '7 days',
|
||||||
|
slaEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
currentStep: 1,
|
||||||
|
totalSteps: 8,
|
||||||
|
templateType: 'claim-management',
|
||||||
|
templateName: 'Claim Management',
|
||||||
|
initiator: {
|
||||||
|
name: 'Current User',
|
||||||
|
role: 'Regional Marketing Coordinator',
|
||||||
|
department: 'Marketing',
|
||||||
|
email: 'current.user@royalenfield.com',
|
||||||
|
phone: '+91 98765 43290',
|
||||||
|
avatar: 'CU'
|
||||||
|
},
|
||||||
|
department: 'Marketing',
|
||||||
|
createdAt: new Date().toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
hour12: true
|
||||||
|
}),
|
||||||
|
updatedAt: new Date().toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
hour12: true
|
||||||
|
}),
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
conclusionRemark: '',
|
||||||
|
claimDetails: {
|
||||||
|
activityName: claimData.activityName,
|
||||||
|
activityType: claimData.activityType,
|
||||||
|
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
|
||||||
|
location: claimData.location,
|
||||||
|
dealerCode: claimData.dealerCode,
|
||||||
|
dealerName: claimData.dealerName,
|
||||||
|
dealerEmail: claimData.dealerEmail || 'N/A',
|
||||||
|
dealerPhone: claimData.dealerPhone || 'N/A',
|
||||||
|
dealerAddress: claimData.dealerAddress || 'N/A',
|
||||||
|
requestDescription: claimData.requestDescription,
|
||||||
|
estimatedBudget: claimData.estimatedBudget || 'TBD',
|
||||||
|
periodStart: claimData.periodStartDate ? new Date(claimData.periodStartDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
|
||||||
|
periodEnd: claimData.periodEndDate ? new Date(claimData.periodEndDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
|
||||||
|
},
|
||||||
|
approvalFlow: claimData.workflowSteps || [
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
approver: `${claimData.dealerName} (Dealer)`,
|
||||||
|
role: 'Dealer - Document Upload',
|
||||||
|
status: 'pending',
|
||||||
|
tatHours: 72,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: new Date().toISOString(),
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
approver: 'Current User (Initiator)',
|
||||||
|
role: 'Initiator Evaluation',
|
||||||
|
status: 'waiting',
|
||||||
|
tatHours: 48,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: null,
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Initiator reviews dealer documents and approves or requests modifications'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
approver: 'System Auto-Process',
|
||||||
|
role: 'IO Confirmation',
|
||||||
|
status: 'waiting',
|
||||||
|
tatHours: 1,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: null,
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Automatic IO (Internal Order) confirmation generated upon initiator approval'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
approver: 'Rajesh Kumar',
|
||||||
|
role: 'Department Lead Approval',
|
||||||
|
status: 'waiting',
|
||||||
|
tatHours: 72,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: null,
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Department head approves and blocks budget in IO for this activity'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 5,
|
||||||
|
approver: `${claimData.dealerName} (Dealer)`,
|
||||||
|
role: 'Dealer - Completion Documents',
|
||||||
|
status: 'waiting',
|
||||||
|
tatHours: 120,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: null,
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Dealer submits activity completion documents and description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 6,
|
||||||
|
approver: 'Current User (Initiator)',
|
||||||
|
role: 'Initiator Verification',
|
||||||
|
status: 'waiting',
|
||||||
|
tatHours: 48,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: null,
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Initiator verifies completion documents and can modify approved amount'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 7,
|
||||||
|
approver: 'System Auto-Process',
|
||||||
|
role: 'E-Invoice Generation',
|
||||||
|
status: 'waiting',
|
||||||
|
tatHours: 1,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: null,
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Auto-generate e-invoice based on final approved amount'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 8,
|
||||||
|
approver: 'Finance Team',
|
||||||
|
role: 'Credit Note Issuance',
|
||||||
|
status: 'waiting',
|
||||||
|
tatHours: 48,
|
||||||
|
elapsedHours: 0,
|
||||||
|
assignedAt: null,
|
||||||
|
comment: null,
|
||||||
|
timestamp: null,
|
||||||
|
description: 'Finance team issues credit note to dealer'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
documents: [],
|
||||||
|
spectators: [],
|
||||||
|
auditTrail: [
|
||||||
|
{
|
||||||
|
type: 'created',
|
||||||
|
action: 'Request Created',
|
||||||
|
details: `Claim request for ${claimData.activityName} created`,
|
||||||
|
user: 'Current User',
|
||||||
|
timestamp: new Date().toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
hour12: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to dynamic requests
|
||||||
|
setDynamicRequests(prev => [...prev, newRequest]);
|
||||||
|
|
||||||
|
// Also add to REQUEST_DATABASE for immediate viewing
|
||||||
|
(REQUEST_DATABASE as any)[requestId] = newRequest;
|
||||||
|
|
||||||
|
toast.success('Claim Request Submitted', {
|
||||||
|
description: 'Your claim management request has been created successfully.',
|
||||||
|
});
|
||||||
|
navigate('/my-requests');
|
||||||
|
*/
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,18 +2,18 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@ -75,7 +75,7 @@ export function UserManagement() {
|
|||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
|
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
|
||||||
|
|
||||||
// Pagination and filtering
|
// Pagination and filtering
|
||||||
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
|
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@ -135,14 +135,14 @@ export function UserManagement() {
|
|||||||
// We'll search with a broader filter to find the user
|
// We'll search with a broader filter to find the user
|
||||||
const response = await userApi.getUsersByRole('ALL', 1, 1000);
|
const response = await userApi.getUsersByRole('ALL', 1, 1000);
|
||||||
const allUsers = response.data?.data?.users || [];
|
const allUsers = response.data?.data?.users || [];
|
||||||
const foundUser = allUsers.find((u: any) =>
|
const foundUser = allUsers.find((u: any) =>
|
||||||
u.email?.toLowerCase() === email.toLowerCase()
|
u.email?.toLowerCase() === email.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (foundUser && foundUser.role) {
|
if (foundUser && foundUser.role) {
|
||||||
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
|
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // User not found in system, no role assigned
|
return null; // User not found in system, no role assigned
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch user role:', error);
|
console.error('Failed to fetch user role:', error);
|
||||||
@ -156,7 +156,7 @@ export function UserManagement() {
|
|||||||
setSearchQuery(user.email);
|
setSearchQuery(user.email);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setFetchingRole(true);
|
setFetchingRole(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch and set the user's current role if they have one
|
// Fetch and set the user's current role if they have one
|
||||||
const currentRole = await fetchUserRole(user.email);
|
const currentRole = await fetchUserRole(user.email);
|
||||||
@ -186,7 +186,7 @@ export function UserManagement() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await userApi.assignRole(selectedUser.email, selectedRole);
|
await userApi.assignRole(selectedUser.email, selectedRole);
|
||||||
|
|
||||||
setMessage({
|
setMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
|
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
|
||||||
@ -200,7 +200,7 @@ export function UserManagement() {
|
|||||||
// Refresh the users list
|
// Refresh the users list
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
await fetchRoleStatistics();
|
await fetchRoleStatistics();
|
||||||
|
|
||||||
toast.success(`Role assigned successfully`);
|
toast.success(`Role assigned successfully`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Role assignment failed:', error);
|
console.error('Role assignment failed:', error);
|
||||||
@ -220,7 +220,7 @@ export function UserManagement() {
|
|||||||
setLoadingUsers(true);
|
setLoadingUsers(true);
|
||||||
try {
|
try {
|
||||||
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
||||||
|
|
||||||
const usersData = response.data?.data?.users || [];
|
const usersData = response.data?.data?.users || [];
|
||||||
const paginationData = response.data?.data?.pagination;
|
const paginationData = response.data?.data?.pagination;
|
||||||
const summaryData = response.data?.data?.summary;
|
const summaryData = response.data?.data?.summary;
|
||||||
@ -234,13 +234,13 @@ export function UserManagement() {
|
|||||||
designation: u.designation,
|
designation: u.designation,
|
||||||
isActive: u.isActive !== false // Default to true if not specified
|
isActive: u.isActive !== false // Default to true if not specified
|
||||||
})));
|
})));
|
||||||
|
|
||||||
if (paginationData) {
|
if (paginationData) {
|
||||||
setCurrentPage(paginationData.currentPage);
|
setCurrentPage(paginationData.currentPage);
|
||||||
setTotalPages(paginationData.totalPages);
|
setTotalPages(paginationData.totalPages);
|
||||||
setTotalUsers(paginationData.totalUsers);
|
setTotalUsers(paginationData.totalUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update summary stats if available
|
// Update summary stats if available
|
||||||
if (summaryData) {
|
if (summaryData) {
|
||||||
setRoleStats(prev => ({
|
setRoleStats(prev => ({
|
||||||
@ -264,13 +264,13 @@ export function UserManagement() {
|
|||||||
try {
|
try {
|
||||||
const response = await userApi.getRoleStatistics();
|
const response = await userApi.getRoleStatistics();
|
||||||
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
||||||
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
|
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
|
||||||
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
|
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
|
||||||
};
|
};
|
||||||
|
|
||||||
setRoleStats(prev => ({
|
setRoleStats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
...stats,
|
...stats,
|
||||||
@ -317,8 +317,8 @@ export function UserManagement() {
|
|||||||
const handleToggleUserStatus = async (userId: string) => {
|
const handleToggleUserStatus = async (userId: string) => {
|
||||||
const user = users.find(u => u.userId === userId);
|
const user = users.find(u => u.userId === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
|
// TODO: Implement backend API for toggling user status
|
||||||
toast.info('User status toggle functionality coming soon');
|
toast.info('User status toggle functionality coming soon');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -326,12 +326,13 @@ export function UserManagement() {
|
|||||||
const handleDeleteUser = async (userId: string) => {
|
const handleDeleteUser = async (userId: string) => {
|
||||||
const user = users.find(u => u.userId === userId);
|
const user = users.find(u => u.userId === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
if (user.role === 'ADMIN') {
|
if (user.role === 'ADMIN') {
|
||||||
toast.error('Cannot delete admin user');
|
toast.error('Cannot delete admin user');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Implement backend API for deleting users
|
||||||
toast.info('User deletion functionality coming soon');
|
toast.info('User deletion functionality coming soon');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -514,10 +515,11 @@ export function UserManagement() {
|
|||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`border-2 rounded-lg p-4 ${message.type === 'success'
|
<div className={`border-2 rounded-lg p-4 ${
|
||||||
? 'border-green-200 bg-green-50'
|
message.type === 'success'
|
||||||
: 'border-red-200 bg-red-50'
|
? 'border-green-200 bg-green-50'
|
||||||
}`}>
|
: 'border-red-200 bg-red-50'
|
||||||
|
}`}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{message.type === 'success' ? (
|
{message.type === 'success' ? (
|
||||||
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
||||||
@ -600,7 +602,7 @@ export function UserManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-gray-700">No users found</p>
|
<p className="font-medium text-gray-700">No users found</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
{roleFilter === 'ELEVATED'
|
{roleFilter === 'ELEVATED'
|
||||||
? 'Assign ADMIN or MANAGEMENT roles to see users here'
|
? 'Assign ADMIN or MANAGEMENT roles to see users here'
|
||||||
: 'No users match the selected filter'
|
: 'No users match the selected filter'
|
||||||
}
|
}
|
||||||
@ -662,10 +664,11 @@ export function UserManagement() {
|
|||||||
variant={currentPage === pageNum ? "default" : "outline"}
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
className={`w-9 h-9 p-0 ${currentPage === pageNum
|
className={`w-9 h-9 p-0 ${
|
||||||
? 'bg-re-green hover:bg-re-green/90'
|
currentPage === pageNum
|
||||||
: ''
|
? 'bg-re-green hover:bg-re-green/90'
|
||||||
}`}
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -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);
|
||||||
@ -182,19 +182,19 @@ export function ClaimApproverSelectionStep({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add back all additional approvers
|
// Add back all additional approvers
|
||||||
additionalApprovers.forEach((addApprover: ClaimApprover) => {
|
additionalApprovers.forEach((addApprover: ClaimApprover) => {
|
||||||
newApprovers.push(addApprover);
|
newApprovers.push(addApprover);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by level
|
// Sort by level
|
||||||
newApprovers.sort((a, b) => a.level - b.level);
|
newApprovers.sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
// Only update if there are actual changes (to avoid infinite loops)
|
// Only update if there are actual changes (to avoid infinite loops)
|
||||||
const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !==
|
const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !==
|
||||||
JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel })));
|
JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel })));
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
updateFormData('approvers', newApprovers);
|
updateFormData('approvers', newApprovers);
|
||||||
}
|
}
|
||||||
@ -246,10 +246,10 @@ export function ClaimApproverSelectionStep({
|
|||||||
const handleApproverEmailChange = (level: number, value: string) => {
|
const handleApproverEmailChange = (level: number, value: string) => {
|
||||||
const approvers = [...(formData.approvers || [])];
|
const approvers = [...(formData.approvers || [])];
|
||||||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||||||
const index = approvers.findIndex((a: ClaimApprover) =>
|
const index = approvers.findIndex((a: ClaimApprover) =>
|
||||||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
// Create new approver entry
|
// Create new approver entry
|
||||||
const step = CLAIM_STEPS.find(s => s.level === level);
|
const step = CLAIM_STEPS.find(s => s.level === level);
|
||||||
@ -304,8 +304,8 @@ export function ClaimApproverSelectionStep({
|
|||||||
// Check for duplicates across other steps
|
// Check for duplicates across other steps
|
||||||
const approvers = formData.approvers || [];
|
const approvers = formData.approvers || [];
|
||||||
const isDuplicate = approvers.some(
|
const isDuplicate = approvers.some(
|
||||||
(a: ClaimApprover) =>
|
(a: ClaimApprover) =>
|
||||||
a.level !== level &&
|
a.level !== level &&
|
||||||
(a.userId === selectedUser.userId || a.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
(a.userId === selectedUser.userId || a.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -343,10 +343,10 @@ export function ClaimApproverSelectionStep({
|
|||||||
// Update approver in array
|
// Update approver in array
|
||||||
const updatedApprovers = [...(formData.approvers || [])];
|
const updatedApprovers = [...(formData.approvers || [])];
|
||||||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||||||
const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) =>
|
const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) =>
|
||||||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (approverIndex === -1) {
|
if (approverIndex === -1) {
|
||||||
const step = CLAIM_STEPS.find(s => s.level === level);
|
const step = CLAIM_STEPS.find(s => s.level === level);
|
||||||
updatedApprovers.push({
|
updatedApprovers.push({
|
||||||
@ -391,10 +391,10 @@ export function ClaimApproverSelectionStep({
|
|||||||
const handleTatChange = (level: number, tat: number | string) => {
|
const handleTatChange = (level: number, tat: number | string) => {
|
||||||
const approvers = [...(formData.approvers || [])];
|
const approvers = [...(formData.approvers || [])];
|
||||||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||||||
const index = approvers.findIndex((a: ClaimApprover) =>
|
const index = approvers.findIndex((a: ClaimApprover) =>
|
||||||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const existingApprover = approvers[index];
|
const existingApprover = approvers[index];
|
||||||
if (existingApprover) {
|
if (existingApprover) {
|
||||||
@ -410,10 +410,10 @@ export function ClaimApproverSelectionStep({
|
|||||||
const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => {
|
const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => {
|
||||||
const approvers = [...(formData.approvers || [])];
|
const approvers = [...(formData.approvers || [])];
|
||||||
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
// Find by originalStepLevel first, then fallback to level for backwards compatibility
|
||||||
const index = approvers.findIndex((a: ClaimApprover) =>
|
const index = approvers.findIndex((a: ClaimApprover) =>
|
||||||
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const existingApprover = approvers[index];
|
const existingApprover = approvers[index];
|
||||||
if (existingApprover) {
|
if (existingApprover) {
|
||||||
@ -430,12 +430,12 @@ export function ClaimApproverSelectionStep({
|
|||||||
// Handle adding additional approver between steps
|
// Handle adding additional approver between steps
|
||||||
const handleAddApproverEmailChange = (value: string) => {
|
const handleAddApproverEmailChange = (value: string) => {
|
||||||
setAddApproverEmail(value);
|
setAddApproverEmail(value);
|
||||||
|
|
||||||
// Clear selectedUser when manually editing
|
// Clear selectedUser when manually editing
|
||||||
if (selectedAddApproverUser && selectedAddApproverUser.email.toLowerCase() !== value.toLowerCase()) {
|
if (selectedAddApproverUser && selectedAddApproverUser.email.toLowerCase() !== value.toLowerCase()) {
|
||||||
setSelectedAddApproverUser(null);
|
setSelectedAddApproverUser(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (addApproverSearchTimer.current) {
|
if (addApproverSearchTimer.current) {
|
||||||
clearTimeout(addApproverSearchTimer.current);
|
clearTimeout(addApproverSearchTimer.current);
|
||||||
@ -484,7 +484,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
secondEmail: user.secondEmail,
|
secondEmail: user.secondEmail,
|
||||||
location: user.location
|
location: user.location
|
||||||
});
|
});
|
||||||
|
|
||||||
setAddApproverEmail(user.email);
|
setAddApproverEmail(user.email);
|
||||||
setSelectedAddApproverUser(user);
|
setSelectedAddApproverUser(user);
|
||||||
setAddApproverSearchResults([]);
|
setAddApproverSearchResults([]);
|
||||||
@ -497,7 +497,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
|
|
||||||
const handleConfirmAddApprover = async () => {
|
const handleConfirmAddApprover = async () => {
|
||||||
const emailToAdd = addApproverEmail.trim().toLowerCase();
|
const emailToAdd = addApproverEmail.trim().toLowerCase();
|
||||||
|
|
||||||
if (!emailToAdd) {
|
if (!emailToAdd) {
|
||||||
toast.error('Please enter an email address');
|
toast.error('Please enter an email address');
|
||||||
return;
|
return;
|
||||||
@ -540,7 +540,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
const approvers = formData.approvers || [];
|
const approvers = formData.approvers || [];
|
||||||
const isDuplicate = approvers.some(
|
const isDuplicate = approvers.some(
|
||||||
(a: ClaimApprover) =>
|
(a: ClaimApprover) =>
|
||||||
(a.userId && selectedAddApproverUser?.userId && a.userId === selectedAddApproverUser.userId) ||
|
(a.userId && selectedAddApproverUser?.userId && a.userId === selectedAddApproverUser.userId) ||
|
||||||
a.email?.toLowerCase() === emailToAdd
|
a.email?.toLowerCase() === emailToAdd
|
||||||
);
|
);
|
||||||
@ -552,15 +552,15 @@ export function ClaimApproverSelectionStep({
|
|||||||
|
|
||||||
// Find the approver for the selected step by its originalStepLevel
|
// Find the approver for the selected step by its originalStepLevel
|
||||||
// This handles cases where steps have been shifted due to previous additional approvers
|
// This handles cases where steps have been shifted due to previous additional approvers
|
||||||
const approverAfter = approvers.find((a: ClaimApprover) =>
|
const approverAfter = approvers.find((a: ClaimApprover) =>
|
||||||
a.originalStepLevel === addApproverInsertAfter ||
|
a.originalStepLevel === addApproverInsertAfter ||
|
||||||
(!a.originalStepLevel && !a.isAdditional && a.level === addApproverInsertAfter)
|
(!a.originalStepLevel && !a.isAdditional && a.level === addApproverInsertAfter)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the current level of the approver we're inserting after
|
// Get the current level of the approver we're inserting after
|
||||||
// If the step has been shifted, use its current level; otherwise use the original level
|
// If the step has been shifted, use its current level; otherwise use the original level
|
||||||
const currentLevelAfter = approverAfter ? approverAfter.level : addApproverInsertAfter;
|
const currentLevelAfter = approverAfter ? approverAfter.level : addApproverInsertAfter;
|
||||||
|
|
||||||
// Calculate insert level based on current shifted level
|
// Calculate insert level based on current shifted level
|
||||||
const insertLevel = currentLevelAfter + 1;
|
const insertLevel = currentLevelAfter + 1;
|
||||||
|
|
||||||
@ -570,7 +570,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
// After shifting, we'll have the same number of unique levels + 1 (the new approver)
|
// After shifting, we'll have the same number of unique levels + 1 (the new approver)
|
||||||
const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size;
|
const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size;
|
||||||
const newTotalLevels = currentUniqueLevels + 1;
|
const newTotalLevels = currentUniqueLevels + 1;
|
||||||
|
|
||||||
if (newTotalLevels > maxApprovalLevels) {
|
if (newTotalLevels > maxApprovalLevels) {
|
||||||
const violations = [{
|
const violations = [{
|
||||||
type: 'max_approval_levels',
|
type: 'max_approval_levels',
|
||||||
@ -578,7 +578,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
currentValue: newTotalLevels,
|
currentValue: newTotalLevels,
|
||||||
maxValue: maxApprovalLevels
|
maxValue: maxApprovalLevels
|
||||||
}];
|
}];
|
||||||
|
|
||||||
if (onPolicyViolation) {
|
if (onPolicyViolation) {
|
||||||
onPolicyViolation(violations);
|
onPolicyViolation(violations);
|
||||||
} else {
|
} else {
|
||||||
@ -593,12 +593,12 @@ export function ClaimApproverSelectionStep({
|
|||||||
try {
|
try {
|
||||||
const response = await searchUsers(emailToAdd, 1);
|
const response = await searchUsers(emailToAdd, 1);
|
||||||
const searchOktaResults = response.data?.data || [];
|
const searchOktaResults = response.data?.data || [];
|
||||||
|
|
||||||
if (searchOktaResults.length === 0) {
|
if (searchOktaResults.length === 0) {
|
||||||
toast.error('User not found in organization directory. Please use @ to search for users.');
|
toast.error('User not found in organization directory. Please use @ to search for users.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const foundUser = searchOktaResults[0];
|
const foundUser = searchOktaResults[0];
|
||||||
await ensureUserExists({
|
await ensureUserExists({
|
||||||
userId: foundUser.userId,
|
userId: foundUser.userId,
|
||||||
@ -617,7 +617,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
secondEmail: foundUser.secondEmail,
|
secondEmail: foundUser.secondEmail,
|
||||||
location: foundUser.location
|
location: foundUser.location
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use found user - insert at integer level and shift subsequent approvers
|
// Use found user - insert at integer level and shift subsequent approvers
|
||||||
// insertLevel is already calculated above based on current shifted level
|
// insertLevel is already calculated above based on current shifted level
|
||||||
const newApprover: ClaimApprover = {
|
const newApprover: ClaimApprover = {
|
||||||
@ -631,7 +631,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
|
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
|
||||||
stepName: `Additional Approver - ${foundUser.displayName || foundUser.email}`,
|
stepName: `Additional Approver - ${foundUser.displayName || foundUser.email}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
|
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
|
||||||
const updatedApprovers = approvers.map((a: ClaimApprover) => {
|
const updatedApprovers = approvers.map((a: ClaimApprover) => {
|
||||||
if (a.level >= insertLevel) {
|
if (a.level >= insertLevel) {
|
||||||
@ -639,13 +639,13 @@ export function ClaimApproverSelectionStep({
|
|||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert the new approver
|
// Insert the new approver
|
||||||
updatedApprovers.push(newApprover);
|
updatedApprovers.push(newApprover);
|
||||||
|
|
||||||
// Sort by level to maintain order
|
// Sort by level to maintain order
|
||||||
updatedApprovers.sort((a, b) => a.level - b.level);
|
updatedApprovers.sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
updateFormData('approvers', updatedApprovers);
|
updateFormData('approvers', updatedApprovers);
|
||||||
toast.success(`Additional approver added and subsequent steps shifted`);
|
toast.success(`Additional approver added and subsequent steps shifted`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -667,7 +667,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
|
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
|
||||||
stepName: `Additional Approver - ${selectedAddApproverUser.displayName || selectedAddApproverUser.email}`,
|
stepName: `Additional Approver - ${selectedAddApproverUser.displayName || selectedAddApproverUser.email}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
|
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
|
||||||
const updatedApprovers = approvers.map((a: ClaimApprover) => {
|
const updatedApprovers = approvers.map((a: ClaimApprover) => {
|
||||||
if (a.level >= insertLevel) {
|
if (a.level >= insertLevel) {
|
||||||
@ -675,13 +675,13 @@ export function ClaimApproverSelectionStep({
|
|||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert the new approver
|
// Insert the new approver
|
||||||
updatedApprovers.push(newApprover);
|
updatedApprovers.push(newApprover);
|
||||||
|
|
||||||
// Sort by level to maintain order
|
// Sort by level to maintain order
|
||||||
updatedApprovers.sort((a, b) => a.level - b.level);
|
updatedApprovers.sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
updateFormData('approvers', updatedApprovers);
|
updateFormData('approvers', updatedApprovers);
|
||||||
toast.success(`Additional approver added and subsequent steps shifted`);
|
toast.success(`Additional approver added and subsequent steps shifted`);
|
||||||
}
|
}
|
||||||
@ -699,12 +699,12 @@ export function ClaimApproverSelectionStep({
|
|||||||
const handleRemoveAdditionalApprover = (level: number) => {
|
const handleRemoveAdditionalApprover = (level: number) => {
|
||||||
const approvers = [...(formData.approvers || [])];
|
const approvers = [...(formData.approvers || [])];
|
||||||
const approverToRemove = approvers.find((a: ClaimApprover) => a.level === level);
|
const approverToRemove = approvers.find((a: ClaimApprover) => a.level === level);
|
||||||
|
|
||||||
if (!approverToRemove) return;
|
if (!approverToRemove) return;
|
||||||
|
|
||||||
// Remove the additional approver
|
// Remove the additional approver
|
||||||
const filtered = approvers.filter((a: ClaimApprover) => a.level !== level);
|
const filtered = approvers.filter((a: ClaimApprover) => a.level !== level);
|
||||||
|
|
||||||
// Shift all approvers with level > removed level down by 1
|
// Shift all approvers with level > removed level down by 1
|
||||||
const updatedApprovers = filtered.map((a: ClaimApprover) => {
|
const updatedApprovers = filtered.map((a: ClaimApprover) => {
|
||||||
if (a.level > level && !a.isAdditional) {
|
if (a.level > level && !a.isAdditional) {
|
||||||
@ -712,10 +712,10 @@ export function ClaimApproverSelectionStep({
|
|||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by level to maintain order
|
// Sort by level to maintain order
|
||||||
updatedApprovers.sort((a, b) => a.level - b.level);
|
updatedApprovers.sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
updateFormData('approvers', updatedApprovers);
|
updateFormData('approvers', updatedApprovers);
|
||||||
toast.success('Additional approver removed and subsequent steps shifted back');
|
toast.success('Additional approver removed and subsequent steps shifted back');
|
||||||
};
|
};
|
||||||
@ -829,15 +829,15 @@ export function ClaimApproverSelectionStep({
|
|||||||
{/* Dynamic Approver Cards - Filter out system steps (auto-processed) */}
|
{/* Dynamic Approver Cards - Filter out system steps (auto-processed) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Count additional approvers before first step
|
// Count additional approvers before first step
|
||||||
const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) =>
|
const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) =>
|
||||||
a.isAdditional && a.insertAfterLevel === 0
|
a.isAdditional && a.insertAfterLevel === 0
|
||||||
);
|
);
|
||||||
|
|
||||||
let displayIndex = additionalBeforeFirst.length; // Start index after additional approvers before first step
|
let displayIndex = additionalBeforeFirst.length; // Start index after additional approvers before first step
|
||||||
|
|
||||||
return CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => {
|
return CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => {
|
||||||
// Find approver by originalStepLevel first, then fallback to level
|
// Find approver by originalStepLevel first, then fallback to level
|
||||||
const approver = approvers.find((a: ClaimApprover) =>
|
const approver = approvers.find((a: ClaimApprover) =>
|
||||||
a.originalStepLevel === step.level || (!a.originalStepLevel && a.level === step.level && !a.isAdditional)
|
a.originalStepLevel === step.level || (!a.originalStepLevel && a.level === step.level && !a.isAdditional)
|
||||||
) || {
|
) || {
|
||||||
email: '',
|
email: '',
|
||||||
@ -856,17 +856,17 @@ export function ClaimApproverSelectionStep({
|
|||||||
// Additional approvers inserted after this step will have insertAfterLevel === step.level
|
// Additional approvers inserted after this step will have insertAfterLevel === step.level
|
||||||
// and their level will be step.level + 1 (or higher if multiple are added)
|
// and their level will be step.level + 1 (or higher if multiple are added)
|
||||||
const additionalApproversAfter = sortedApprovers.filter(
|
const additionalApproversAfter = sortedApprovers.filter(
|
||||||
(a: ClaimApprover) =>
|
(a: ClaimApprover) =>
|
||||||
a.isAdditional &&
|
a.isAdditional &&
|
||||||
a.insertAfterLevel === step.level
|
a.insertAfterLevel === step.level
|
||||||
).sort((a, b) => a.level - b.level);
|
).sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
// Calculate current step's display number
|
// Calculate current step's display number
|
||||||
const currentStepDisplayNumber = displayIndex + 1;
|
const currentStepDisplayNumber = displayIndex + 1;
|
||||||
|
|
||||||
// Increment display index for this step
|
// Increment display index for this step
|
||||||
displayIndex++;
|
displayIndex++;
|
||||||
|
|
||||||
// Increment display index for each additional approver after this step
|
// Increment display index for each additional approver after this step
|
||||||
displayIndex += additionalApproversAfter.length;
|
displayIndex += additionalApproversAfter.length;
|
||||||
|
|
||||||
@ -875,259 +875,238 @@ export function ClaimApproverSelectionStep({
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-px h-3 bg-gray-300"></div>
|
<div className="w-px h-3 bg-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Render additional approvers before this step if any */}
|
{/* Render additional approvers before this step if any */}
|
||||||
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
|
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
|
||||||
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
|
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
|
||||||
return (
|
return (
|
||||||
<div key={`additional-${addApprover.level}`} className="space-y-1">
|
<div key={`additional-${addApprover.level}`} className="space-y-1">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-px h-3 bg-gray-300"></div>
|
<div className="w-px h-3 bg-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
|
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
|
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
|
||||||
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
|
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<span className="font-semibold text-gray-900 text-sm">
|
<span className="font-semibold text-gray-900 text-sm">
|
||||||
Additional Approver
|
Additional Approver
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
|
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
|
||||||
ADDITIONAL
|
ADDITIONAL
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
|
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
|
||||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 mb-2">
|
<p className="text-xs text-gray-600 mb-2">
|
||||||
{addApprover.name || addApprover.email}
|
{addApprover.name || addApprover.email}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
<div>Email: {addApprover.email}</div>
|
<div>Email: {addApprover.email}</div>
|
||||||
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
|
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className={`p-3 rounded-lg border-2 transition-all ${approver.email && approver.userId
|
|
||||||
? 'border-green-200 bg-green-50'
|
|
||||||
: isPreFilled
|
|
||||||
? 'border-blue-200 bg-blue-50'
|
|
||||||
: 'border-gray-200 bg-gray-50'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${approver.email && approver.userId
|
|
||||||
? 'bg-green-600'
|
|
||||||
: isPreFilled
|
|
||||||
? 'bg-blue-600'
|
|
||||||
: 'bg-gray-400'
|
|
||||||
}`}>
|
|
||||||
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="font-semibold text-gray-900 text-sm">
|
|
||||||
{step.name}
|
|
||||||
</span>
|
|
||||||
{isLast && (
|
|
||||||
<Badge variant="destructive" className="text-xs">FINAL</Badge>
|
|
||||||
)}
|
|
||||||
{isPreFilled && (
|
|
||||||
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
|
|
||||||
|
|
||||||
{isEditable && (() => {
|
|
||||||
const isVerified = !!(approver.email && approver.userId);
|
|
||||||
const isEmpty = !approver.email && !isPreFilled;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<Label htmlFor={`approver-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
|
|
||||||
}`}>
|
|
||||||
Approver Email {!isPreFilled && '*'}
|
|
||||||
{isEmpty && <span className="ml-2 text-[10px] font-semibold italic text-blue-600">(Required)</span>}
|
|
||||||
</Label>
|
|
||||||
{isVerified && (
|
|
||||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
|
||||||
Verified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id={`approver-${step.level}`}
|
|
||||||
type="text"
|
|
||||||
placeholder={isPreFilled ? approver.email : "@username or email..."}
|
|
||||||
value={approver.email || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newValue = e.target.value;
|
|
||||||
if (!isPreFilled) {
|
|
||||||
handleApproverEmailChange(step.level, newValue);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isPreFilled || step.isAuto}
|
|
||||||
className={`h-9 border-2 transition-all mt-1 w-full text-sm ${isPreFilled
|
|
||||||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
|
|
||||||
: isVerified
|
|
||||||
? 'bg-green-50/50 border-green-600 focus:border-green-700 ring-offset-green-50 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
|
|
||||||
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{/* Search suggestions dropdown */}
|
|
||||||
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
|
|
||||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
|
|
||||||
{userSearchLoading[step.level - 1] ? (
|
|
||||||
<div className="p-2 text-xs text-gray-500">Searching...</div>
|
|
||||||
) : (
|
|
||||||
<ul className="max-h-56 overflow-auto divide-y">
|
|
||||||
{userSearchResults[step.level - 1]?.map((u) => (
|
|
||||||
<li
|
|
||||||
key={u.userId}
|
|
||||||
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
|
||||||
onClick={() => handleUserSelect(step.level, u)}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
|
|
||||||
<div className="text-xs text-gray-600">{u.email}</div>
|
|
||||||
{u.department && (
|
|
||||||
<div className="text-xs text-gray-500">{u.department}</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{approver.name && (
|
|
||||||
<p className="text-xs text-green-600 mt-1">
|
|
||||||
Selected: <span className="font-semibold">{approver.name}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={`tat-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
|
|
||||||
}`}>
|
|
||||||
TAT (Turn Around Time) *
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Input
|
|
||||||
id={`tat-${step.level}`}
|
|
||||||
type="number"
|
|
||||||
placeholder={approver.tatType === 'days' ? '7' : '24'}
|
|
||||||
min="1"
|
|
||||||
max={approver.tatType === 'days' ? '30' : '720'}
|
|
||||||
value={approver.tat || ''}
|
|
||||||
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
|
|
||||||
disabled={step.isAuto}
|
|
||||||
className={`h-9 border-2 transition-all flex-1 text-sm ${isPreFilled
|
|
||||||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
|
|
||||||
: isVerified
|
|
||||||
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
|
|
||||||
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={approver.tatType || 'hours'}
|
|
||||||
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
|
|
||||||
disabled={step.isAuto}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={`w-20 h-9 border-2 transition-all text-sm ${isPreFilled
|
|
||||||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed'
|
|
||||||
: isVerified
|
|
||||||
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 text-gray-900 font-medium'
|
|
||||||
: 'bg-white border-blue-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
|
||||||
}`}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="hours">Hours</SelectItem>
|
|
||||||
<SelectItem value="days">Days</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
{/* Render additional approvers after this step */}
|
|
||||||
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => {
|
<div className={`p-3 rounded-lg border-2 transition-all ${
|
||||||
// Additional approvers come after the current step, so they should be numbered after it
|
approver.email && approver.userId
|
||||||
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1;
|
? 'border-green-200 bg-green-50'
|
||||||
return (
|
: isPreFilled
|
||||||
<div key={`additional-${addApprover.level}`} className="space-y-1">
|
? 'border-blue-200 bg-blue-50'
|
||||||
<div className="flex justify-center">
|
: 'border-gray-200 bg-gray-50'
|
||||||
<div className="w-px h-3 bg-gray-300"></div>
|
}`}>
|
||||||
</div>
|
<div className="flex items-start gap-3">
|
||||||
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
<div className="flex items-start gap-3">
|
approver.email && approver.userId
|
||||||
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
|
? 'bg-green-600'
|
||||||
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
|
: isPreFilled
|
||||||
</div>
|
? 'bg-blue-600'
|
||||||
<div className="flex-1 min-w-0">
|
: 'bg-gray-400'
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
}`}>
|
||||||
<span className="font-semibold text-gray-900 text-sm">
|
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
|
||||||
{addApprover.stepName || 'Additional Approver'}
|
</div>
|
||||||
</span>
|
<div className="flex-1 min-w-0">
|
||||||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
ADDITIONAL
|
<span className="font-semibold text-gray-900 text-sm">
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
{isLast && (
|
||||||
|
<Badge variant="destructive" className="text-xs">FINAL</Badge>
|
||||||
|
)}
|
||||||
|
{isPreFilled && (
|
||||||
|
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
|
||||||
|
|
||||||
|
{isEditable && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<Label htmlFor={`approver-${step.level}`} className="text-xs font-medium">
|
||||||
|
Email Address {!isPreFilled && '*'}
|
||||||
|
</Label>
|
||||||
|
{approver.email && approver.userId && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
||||||
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||||||
|
Verified
|
||||||
</Badge>
|
</Badge>
|
||||||
{addApprover.email && addApprover.userId && (
|
)}
|
||||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
</div>
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
<div className="relative">
|
||||||
Verified
|
<Input
|
||||||
</Badge>
|
id={`approver-${step.level}`}
|
||||||
)}
|
type="text"
|
||||||
<Button
|
placeholder={isPreFilled ? approver.email : "@approver@royalenfield.com"}
|
||||||
type="button"
|
value={approver.email || ''}
|
||||||
variant="ghost"
|
onChange={(e) => {
|
||||||
size="sm"
|
const newValue = e.target.value;
|
||||||
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
|
if (!isPreFilled) {
|
||||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
handleApproverEmailChange(step.level, newValue);
|
||||||
>
|
}
|
||||||
<X className="w-3 h-3" />
|
}}
|
||||||
</Button>
|
disabled={isPreFilled || step.isAuto}
|
||||||
</div>
|
className="h-9 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full text-sm"
|
||||||
<p className="text-xs text-gray-600 mb-2">
|
/>
|
||||||
{addApprover.name || addApprover.email || 'No approver assigned'}
|
{/* Search suggestions dropdown */}
|
||||||
</p>
|
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
|
||||||
{addApprover.email && (
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
{userSearchLoading[step.level - 1] ? (
|
||||||
<div>Email: {addApprover.email}</div>
|
<div className="p-2 text-xs text-gray-500">Searching...</div>
|
||||||
{addApprover.tat && (
|
) : (
|
||||||
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
|
<ul className="max-h-56 overflow-auto divide-y">
|
||||||
|
{userSearchResults[step.level - 1]?.map((u) => (
|
||||||
|
<li
|
||||||
|
key={u.userId}
|
||||||
|
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
||||||
|
onClick={() => handleUserSelect(step.level, u)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
|
||||||
|
<div className="text-xs text-gray-600">{u.email}</div>
|
||||||
|
{u.department && (
|
||||||
|
<div className="text-xs text-gray-500">{u.department}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{approver.name && (
|
||||||
|
<p className="text-xs text-green-600 mt-1">
|
||||||
|
Selected: <span className="font-semibold">{approver.name}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={`tat-${step.level}`} className="text-xs font-medium">
|
||||||
|
TAT (Turn Around Time) *
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
id={`tat-${step.level}`}
|
||||||
|
type="number"
|
||||||
|
placeholder={approver.tatType === 'days' ? '7' : '24'}
|
||||||
|
min="1"
|
||||||
|
max={approver.tatType === 'days' ? '30' : '720'}
|
||||||
|
value={approver.tat || ''}
|
||||||
|
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
|
||||||
|
disabled={step.isAuto}
|
||||||
|
className="h-9 border-2 border-gray-300 focus:border-blue-500 flex-1 text-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={approver.tatType || 'hours'}
|
||||||
|
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
|
||||||
|
disabled={step.isAuto}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-20 h-9 border-2 border-gray-300 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hours">Hours</SelectItem>
|
||||||
|
<SelectItem value="days">Days</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render additional approvers after this step */}
|
||||||
|
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => {
|
||||||
|
// Additional approvers come after the current step, so they should be numbered after it
|
||||||
|
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1;
|
||||||
|
return (
|
||||||
|
<div key={`additional-${addApprover.level}`} className="space-y-1">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-px h-3 bg-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
|
||||||
|
<span className="text-white font-semibold text-sm">{addDisplayNumber}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className="font-semibold text-gray-900 text-sm">
|
||||||
|
{addApprover.stepName || 'Additional Approver'}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
|
||||||
|
ADDITIONAL
|
||||||
|
</Badge>
|
||||||
|
{addApprover.email && addApprover.userId && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
||||||
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
|
||||||
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mb-2">
|
||||||
|
{addApprover.name || addApprover.email || 'No approver assigned'}
|
||||||
|
</p>
|
||||||
|
{addApprover.email && (
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
<div>Email: {addApprover.email}</div>
|
||||||
|
{addApprover.tat && (
|
||||||
|
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
})()}
|
})()}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -1146,17 +1125,17 @@ export function ClaimApproverSelectionStep({
|
|||||||
{sortedApprovers.map((approver: ClaimApprover) => {
|
{sortedApprovers.map((approver: ClaimApprover) => {
|
||||||
// Skip system/auto steps
|
// Skip system/auto steps
|
||||||
// Find step by originalStepLevel first, then fallback to level
|
// Find step by originalStepLevel first, then fallback to level
|
||||||
const step = approver.originalStepLevel
|
const step = approver.originalStepLevel
|
||||||
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
|
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
|
||||||
: CLAIM_STEPS.find(s => s.level === approver.level && !approver.isAdditional);
|
: CLAIM_STEPS.find(s => s.level === approver.level && !approver.isAdditional);
|
||||||
|
|
||||||
if (step?.isAuto) return null;
|
if (step?.isAuto) return null;
|
||||||
|
|
||||||
const tat = Number(approver.tat || 0);
|
const tat = Number(approver.tat || 0);
|
||||||
const tatType = approver.tatType || 'hours';
|
const tatType = approver.tatType || 'hours';
|
||||||
const hours = tatType === 'days' ? tat * 24 : tat;
|
const hours = tatType === 'days' ? tat * 24 : tat;
|
||||||
if (!tat) return null;
|
if (!tat) return null;
|
||||||
|
|
||||||
// Handle additional approvers
|
// Handle additional approvers
|
||||||
if (approver.isAdditional) {
|
if (approver.isAdditional) {
|
||||||
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel);
|
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel);
|
||||||
@ -1169,7 +1148,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={approver.level} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
<div key={approver.level} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
<span className="text-sm font-medium">{step?.name || 'Unknown'}</span>
|
<span className="text-sm font-medium">{step?.name || 'Unknown'}</span>
|
||||||
@ -1194,13 +1173,13 @@ export function ClaimApproverSelectionStep({
|
|||||||
Add an additional approver between workflow steps. The approver will be inserted after the selected step. Additional approvers cannot be added after "Requestor Claim Approval".
|
Add an additional approver between workflow steps. The approver will be inserted after the selected step. Additional approvers cannot be added after "Requestor Claim Approval".
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
{/* Insert After Level Selection */}
|
{/* Insert After Level Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Insert After Step *</Label>
|
<Label className="text-sm font-medium">Insert After Step *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={addApproverInsertAfter.toString()}
|
value={addApproverInsertAfter.toString()}
|
||||||
onValueChange={(value) => setAddApproverInsertAfter(Number(value))}
|
onValueChange={(value) => setAddApproverInsertAfter(Number(value))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-11 border-gray-300">
|
<SelectTrigger className="h-11 border-gray-300">
|
||||||
@ -1232,7 +1211,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
<p className="text-xs text-amber-600 font-medium">
|
<p className="text-xs text-amber-600 font-medium">
|
||||||
⚠️ Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
⚠️ Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Max Approval Levels Note */}
|
{/* Max Approval Levels Note */}
|
||||||
{maxApprovalLevels && (
|
{maxApprovalLevels && (
|
||||||
<p className="text-xs text-gray-600 mt-2">
|
<p className="text-xs text-gray-600 mt-2">
|
||||||
@ -1311,7 +1290,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
className="pl-10 h-11 border-gray-300"
|
className="pl-10 h-11 border-gray-300"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Search Results Dropdown */}
|
{/* Search Results Dropdown */}
|
||||||
{(isSearchingApprover || addApproverSearchResults.length > 0) && (
|
{(isSearchingApprover || addApproverSearchResults.length > 0) && (
|
||||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto">
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ProcessDetailsCard Component
|
* ProcessDetailsCard Component
|
||||||
* Displays process-related details: IO Number, E-Invoice, Claim Amount, and Budget Breakdowns
|
* Displays process-related details: IO Number, DMS Number, Claim Amount, and Budget Breakdowns
|
||||||
* Visibility controlled by user role
|
* Visibility controlled by user role
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -172,18 +172,21 @@ export function ProcessDetailsCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* E-Invoice Details */}
|
{/* DMS Details */}
|
||||||
{visibility.showDMSDetails && dmsDetails && (
|
{visibility.showDMSDetails && dmsDetails && (
|
||||||
<div className="bg-white rounded-lg p-3 border border-purple-200">
|
<div className="bg-white rounded-lg p-3 border border-purple-200">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Activity className="w-4 h-4 text-purple-600" />
|
<Activity className="w-4 h-4 text-purple-600" />
|
||||||
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
|
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
|
||||||
E-Invoice Details
|
DMS & E-Invoice Details
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-2">
|
<div className="grid grid-cols-2 gap-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase">DMS Number</p>
|
||||||
|
<p className="font-bold text-sm text-gray-900">{dmsDetails.dmsNumber || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
{dmsDetails.ackNo && (
|
{dmsDetails.ackNo && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-gray-500 uppercase">Ack No</p>
|
<p className="text-[10px] text-gray-500 uppercase">Ack No</p>
|
||||||
|
|||||||
@ -22,7 +22,6 @@ interface ProposalCostItem {
|
|||||||
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;
|
||||||
@ -36,9 +35,8 @@ interface ProposalDetailsCardProps {
|
|||||||
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
|
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
|
||||||
// Calculate estimated total from costBreakup if not provided
|
// Calculate estimated total from costBreakup if not provided
|
||||||
const calculateEstimatedTotal = () => {
|
const calculateEstimatedTotal = () => {
|
||||||
const total = proposalDetails.totalEstimatedBudget ?? proposalDetails.estimatedBudgetTotal;
|
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
|
||||||
if (total !== undefined && total !== null) {
|
return proposalDetails.estimatedBudgetTotal;
|
||||||
return total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate sum from costBreakup items
|
// Calculate sum from costBreakup items
|
||||||
|
|||||||
@ -1,16 +1,12 @@
|
|||||||
.settlement-push-modal {
|
.dms-push-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 1000px !important;
|
max-width: 90vw !important;
|
||||||
min-width: 320px !important;
|
|
||||||
max-height: 95vh !important;
|
max-height: 95vh !important;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsive */
|
/* Mobile responsive */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settlement-push-modal {
|
.dms-push-modal {
|
||||||
width: 95vw !important;
|
width: 95vw !important;
|
||||||
max-width: 95vw !important;
|
max-width: 95vw !important;
|
||||||
max-height: 95vh !important;
|
max-height: 95vh !important;
|
||||||
@ -19,48 +15,25 @@
|
|||||||
|
|
||||||
/* Tablet and small desktop */
|
/* Tablet and small desktop */
|
||||||
@media (min-width: 641px) and (max-width: 1023px) {
|
@media (min-width: 641px) and (max-width: 1023px) {
|
||||||
.settlement-push-modal {
|
.dms-push-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 900px !important;
|
max-width: 90vw !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollable content area */
|
/* Large screens - fixed max-width for better readability */
|
||||||
.settlement-push-modal .flex-1 {
|
@media (min-width: 1024px) {
|
||||||
overflow-y: auto;
|
.dms-push-modal {
|
||||||
padding-right: 4px;
|
width: 90vw !important;
|
||||||
|
max-width: 1000px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for the modal content */
|
/* Extra large screens */
|
||||||
.settlement-push-modal .flex-1::-webkit-scrollbar {
|
@media (min-width: 1536px) {
|
||||||
width: 6px;
|
.dms-push-modal {
|
||||||
|
width: 90vw !important;
|
||||||
|
max-width: 1000px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settlement-push-modal .flex-1::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settlement-push-modal .flex-1::-webkit-scrollbar-thumb {
|
|
||||||
background: #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settlement-push-modal .flex-1::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-dialog {
|
|
||||||
width: 95vw !important;
|
|
||||||
max-width: 1200px !important;
|
|
||||||
max-height: 95vh !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-preview-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@ -149,11 +149,11 @@ export function DMSPushModal({
|
|||||||
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 - fetch as blob to avoid CSP issues
|
// Handle document preview - fetch as blob to avoid CSP issues
|
||||||
@ -228,7 +228,7 @@ export function DMSPushModal({
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!comments.trim()) {
|
if (!comments.trim()) {
|
||||||
toast.error('Please provide comments before proceeding');
|
toast.error('Please provide comments before pushing to DMS');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,8 +238,8 @@ export function DMSPushModal({
|
|||||||
handleReset();
|
handleReset();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate e-invoice:', error);
|
console.error('Failed to push to DMS:', error);
|
||||||
toast.error('Failed to generate e-invoice. Please try again.');
|
toast.error('Failed to push to DMS. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -257,408 +257,211 @@ export function DMSPushModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<DialogContent className="dms-push-modal overflow-hidden flex flex-col">
|
||||||
<DialogContent className="settlement-push-modal overflow-hidden flex flex-col w-full max-w-none">
|
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
|
||||||
<DialogHeader className="px-6 pt-6 pb-3 flex-shrink-0">
|
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
||||||
<div className="flex items-center gap-2 sm:gap-3 mb-2">
|
<div className="p-1.5 sm:p-2 rounded-lg bg-indigo-100">
|
||||||
<div className="p-1.5 sm:p-2 rounded-lg bg-indigo-100">
|
<Activity className="w-4 h-4 sm:w-5 sm:h-5 sm:w-6 sm:h-6 text-indigo-600" />
|
||||||
<Activity className="w-4 h-4 sm:w-5 sm:h-5 sm:w-6 sm:h-6 text-indigo-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<DialogTitle className="font-semibold text-lg sm:text-xl">
|
|
||||||
E-Invoice Generation & Sync
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs sm:text-sm mt-1">
|
|
||||||
Review completion details and expenses before generating e-invoice and initiating SAP settlement
|
|
||||||
</DialogDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<DialogTitle className="font-semibold text-lg sm:text-xl">
|
||||||
|
Push to DMS - Verification
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm mt-1">
|
||||||
|
Review completion details and expenses before pushing to DMS for e-invoice generation
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Request Info Card - Grid layout */}
|
{/* Request Info Card - Grid layout */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg border">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg border">
|
||||||
<div className="flex items-center justify-between sm:flex-col sm:items-start sm:gap-1">
|
<div className="flex items-center justify-between sm:flex-col sm:items-start sm:gap-1">
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Workflow Step:</span>
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Workflow Step:</span>
|
||||||
<Badge variant="outline" className="font-mono text-xs">Requestor Claim Approval</Badge>
|
<Badge variant="outline" className="font-mono text-xs">Requestor Claim Approval</Badge>
|
||||||
</div>
|
</div>
|
||||||
{requestNumber && (
|
{requestNumber && (
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Request Number:</span>
|
|
||||||
<p className="text-gray-700 font-mono text-xs sm:text-sm">{requestNumber}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Title:</span>
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Request Number:</span>
|
||||||
<p className="text-gray-700 text-xs sm:text-sm line-clamp-2">{requestTitle || '—'}</p>
|
<p className="text-gray-700 font-mono text-xs sm:text-sm">{requestNumber}</p>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 sm:flex-col sm:items-start sm:gap-1">
|
|
||||||
<span className="font-medium text-xs sm:text-sm text-gray-600">Action:</span>
|
|
||||||
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs">
|
|
||||||
<Activity className="w-3 h-3 mr-1" />
|
|
||||||
SYNC TO SAP
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Title:</span>
|
||||||
|
<p className="text-gray-700 text-xs sm:text-sm line-clamp-2">{requestTitle || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
<div className="flex items-center gap-2 sm:flex-col sm:items-start sm:gap-1">
|
||||||
|
<span className="font-medium text-xs sm:text-sm text-gray-600">Action:</span>
|
||||||
|
<Badge className="bg-indigo-100 text-indigo-800 border-indigo-200 text-xs">
|
||||||
|
<Activity className="w-3 h-3 mr-1" />
|
||||||
|
PUSH TO DMS
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-3">
|
||||||
<div className="space-y-3 sm:space-y-4 max-w-7xl mx-auto">
|
<div className="space-y-3 sm:space-y-4 max-w-7xl mx-auto">
|
||||||
{/* Grid layout for all three cards on larger screens */}
|
{/* Grid layout for all three cards on larger screens */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||||
{/* Completion Details Card */}
|
{/* Completion Details Card */}
|
||||||
{completionDetails && (
|
{completionDetails && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
||||||
Completion Details
|
Completion Details
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
Review activity completion information
|
Review activity completion information
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 sm:space-y-3">
|
<CardContent className="space-y-2 sm:space-y-3">
|
||||||
{completionDetails.activityCompletionDate && (
|
{completionDetails.activityCompletionDate && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Activity Completion Date:</span>
|
<span className="text-xs sm:text-sm text-gray-600">Activity Completion Date:</span>
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
||||||
{formatDate(completionDetails.activityCompletionDate)}
|
{formatDate(completionDetails.activityCompletionDate)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{completionDetails.numberOfParticipants !== undefined && (
|
{completionDetails.numberOfParticipants !== undefined && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Number of Participants:</span>
|
<span className="text-xs sm:text-sm text-gray-600">Number of Participants:</span>
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
||||||
{completionDetails.numberOfParticipants}
|
{completionDetails.numberOfParticipants}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{completionDetails.completionDescription && (
|
{completionDetails.completionDescription && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<p className="text-xs text-gray-600 mb-1">Completion Description:</p>
|
<p className="text-xs text-gray-600 mb-1">Completion Description:</p>
|
||||||
<p className="text-xs sm:text-sm text-gray-900 line-clamp-3">
|
<p className="text-xs sm:text-sm text-gray-900 line-clamp-3">
|
||||||
{completionDetails.completionDescription}
|
{completionDetails.completionDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* IO Details Card */}
|
||||||
|
{ioDetails && ioDetails.ioNumber && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
||||||
|
IO Details
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
|
Internal Order information for budget reference
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 sm:space-y-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
||||||
|
<span className="text-xs sm:text-sm text-gray-600">IO Number:</span>
|
||||||
|
<span className="text-xs sm:text-sm font-semibold text-gray-900 font-mono">
|
||||||
|
{ioDetails.ioNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ioDetails.blockedAmount !== undefined && ioDetails.blockedAmount > 0 && (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
||||||
|
<span className="text-xs sm:text-sm text-gray-600">Blocked Amount:</span>
|
||||||
|
<span className="text-xs sm:text-sm font-bold text-green-700">
|
||||||
|
{formatCurrency(ioDetails.blockedAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ioDetails.remainingBalance !== undefined && ioDetails.remainingBalance !== null && (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2">
|
||||||
|
<span className="text-xs sm:text-sm text-gray-600">Remaining Balance:</span>
|
||||||
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
||||||
|
{formatCurrency(ioDetails.remainingBalance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expense Breakdown Card */}
|
||||||
|
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<DollarSign className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
||||||
|
Expense Breakdown
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
|
Review closed expenses before pushing to DMS
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1.5 sm:space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
|
{completionDetails.closedExpenses.map((expense, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between py-1.5 sm:py-2 px-2 sm:px-3 bg-gray-50 rounded border"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 pr-2">
|
||||||
|
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">
|
||||||
|
{expense.description || `Expense ${index + 1}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="ml-2 flex-shrink-0">
|
||||||
</CardContent>
|
<p className="text-xs sm:text-sm font-semibold text-gray-900">
|
||||||
</Card>
|
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)}
|
||||||
)}
|
</p>
|
||||||
|
|
||||||
{/* IO Details Card */}
|
|
||||||
{ioDetails && ioDetails.ioNumber && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
||||||
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
|
||||||
IO Details
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
Internal Order information for budget reference
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 sm:space-y-3">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
||||||
<span className="text-xs sm:text-sm text-gray-600">IO Number:</span>
|
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900 font-mono">
|
|
||||||
{ioDetails.ioNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{ioDetails.blockedAmount !== undefined && ioDetails.blockedAmount > 0 && (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2 border-b">
|
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Blocked Amount:</span>
|
|
||||||
<span className="text-xs sm:text-sm font-bold text-green-700">
|
|
||||||
{formatCurrency(ioDetails.blockedAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{ioDetails.remainingBalance !== undefined && ioDetails.remainingBalance !== null && (
|
))}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 py-1.5 sm:py-2">
|
</div>
|
||||||
<span className="text-xs sm:text-sm text-gray-600">Remaining Balance:</span>
|
<div className="flex items-center justify-between py-2 sm:py-3 px-2 sm:px-3 bg-blue-50 rounded border-2 border-blue-200 mt-2 sm:mt-3">
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">
|
<span className="text-xs sm:text-sm font-semibold text-gray-900">Total:</span>
|
||||||
{formatCurrency(ioDetails.remainingBalance)}
|
<span className="text-sm sm:text-base font-bold text-blue-700">
|
||||||
</span>
|
{formatCurrency(totalClosedExpenses)}
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Expense Breakdown Card */}
|
{/* Completion Documents Section */}
|
||||||
{completionDetails?.closedExpenses && completionDetails.closedExpenses.length > 0 && (
|
{completionDocuments && (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader className="pb-3">
|
{/* Completion Documents */}
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
{completionDocuments.completionDocuments && completionDocuments.completionDocuments.length > 0 && (
|
||||||
<DollarSign className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
<div className="space-y-2">
|
||||||
Expense Breakdown
|
<div className="flex items-center gap-2">
|
||||||
</CardTitle>
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
||||||
Review closed expenses before generation
|
Completion Documents
|
||||||
</CardDescription>
|
</h3>
|
||||||
</CardHeader>
|
<Badge variant="secondary" className="text-xs">
|
||||||
<CardContent>
|
{completionDocuments.completionDocuments.length} file(s)
|
||||||
<div className="space-y-1.5 sm:space-y-2 max-h-[200px] overflow-y-auto">
|
</Badge>
|
||||||
{completionDetails.closedExpenses.map((expense, index) => (
|
</div>
|
||||||
<div
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
key={index}
|
{completionDocuments.completionDocuments.map((doc, index) => (
|
||||||
className="flex items-center justify-between py-1.5 sm:py-2 px-2 sm:px-3 bg-gray-50 rounded border"
|
<div
|
||||||
>
|
key={index}
|
||||||
<div className="flex-1 min-w-0 pr-2">
|
className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
||||||
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">
|
>
|
||||||
{expense.description || `Expense ${index + 1}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 flex-shrink-0">
|
|
||||||
<p className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
||||||
{formatCurrency(typeof expense === 'object' ? (expense.amount || 0) : 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 sm:py-3 px-2 sm:px-3 bg-blue-50 rounded border-2 border-blue-200 mt-2 sm:mt-3">
|
|
||||||
<span className="text-xs sm:text-sm font-semibold text-gray-900">Total:</span>
|
|
||||||
<span className="text-sm sm:text-base font-bold text-blue-700">
|
|
||||||
{formatCurrency(totalClosedExpenses)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Completion Documents Section */}
|
|
||||||
{completionDocuments && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Completion Documents */}
|
|
||||||
{completionDocuments.completionDocuments && completionDocuments.completionDocuments.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4 sm:w-5 sm:h-5 text-green-600" />
|
|
||||||
Completion Documents
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{completionDocuments.completionDocuments.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
||||||
{completionDocuments.completionDocuments.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
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">
|
|
||||||
<CheckCircle2 className="w-4 h-4 lg:w-5 lg:h-5 text-green-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={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 lg:w-5 lg: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-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity Photos */}
|
|
||||||
{completionDocuments.activityPhotos && completionDocuments.activityPhotos.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
||||||
Activity Photos
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{completionDocuments.activityPhotos.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
||||||
{completionDocuments.activityPhotos.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
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">
|
|
||||||
<Activity className="w-4 h-4 lg:w-5 lg:h-5 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={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Preview photo"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 lg:w-5 lg: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 photo"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Invoices / Receipts */}
|
|
||||||
{completionDocuments.invoicesReceipts && completionDocuments.invoicesReceipts.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
|
||||||
Invoices / Receipts
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{completionDocuments.invoicesReceipts.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
||||||
{completionDocuments.invoicesReceipts.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
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">
|
|
||||||
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-purple-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={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
disabled={previewLoading}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
{previewLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 lg:w-5 lg: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-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Attendance Sheet */}
|
|
||||||
{completionDocuments.attendanceSheet && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
|
||||||
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-600" />
|
|
||||||
Attendance Sheet
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<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">
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
||||||
<Activity className="w-4 h-4 lg:w-5 lg:h-5 text-indigo-600 flex-shrink-0" />
|
<CheckCircle2 className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={completionDocuments.attendanceSheet.name}>
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
||||||
{completionDocuments.attendanceSheet.name}
|
{doc.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{completionDocuments.attendanceSheet.id && (
|
{doc.id && (
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{canPreviewDocument(completionDocuments.attendanceSheet) && (
|
{canPreviewDocument(doc) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePreviewDocument(completionDocuments.attendanceSheet!)}
|
onClick={() => handlePreviewDocument(doc)}
|
||||||
disabled={previewLoading}
|
disabled={previewLoading}
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
title="Preview document"
|
title="Preview document"
|
||||||
@ -674,8 +477,8 @@ export function DMSPushModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
if (completionDocuments.attendanceSheet?.id) {
|
if (doc.id) {
|
||||||
await downloadDocument(completionDocuments.attendanceSheet.id);
|
await downloadDocument(doc.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to download document:', error);
|
console.error('Failed to download document:', error);
|
||||||
@ -690,81 +493,275 @@ export function DMSPushModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Verification Warning */}
|
{/* Activity Photos */}
|
||||||
<div className="p-2.5 sm:p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
{completionDocuments.activityPhotos && completionDocuments.activityPhotos.length > 0 && (
|
||||||
<div className="flex items-start gap-2">
|
<div className="space-y-2">
|
||||||
<TriangleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
||||||
<p className="text-xs sm:text-sm font-semibold text-yellow-900">
|
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
||||||
Please verify all details before generation
|
Activity Photos
|
||||||
</p>
|
</h3>
|
||||||
<p className="text-xs text-yellow-700 mt-1">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Once submitted, the system will generate an e-invoice and initiate the SAP settlement process.
|
{completionDocuments.activityPhotos.length} file(s)
|
||||||
</p>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
|
{completionDocuments.activityPhotos.map((doc, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
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">
|
||||||
|
<Activity className="w-4 h-4 lg:w-5 lg:h-5 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={doc.name}>
|
||||||
|
{doc.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{doc.id && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{canPreviewDocument(doc) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreviewDocument(doc)}
|
||||||
|
disabled={previewLoading}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Preview photo"
|
||||||
|
>
|
||||||
|
{previewLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4 lg:w-5 lg: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 photo"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Comments & Remarks */}
|
{/* Invoices / Receipts */}
|
||||||
<div className="space-y-1.5 max-w-2xl">
|
{completionDocuments.invoicesReceipts && completionDocuments.invoicesReceipts.length > 0 && (
|
||||||
<Label htmlFor="comment" className="text-xs sm:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
Comments & Remarks <span className="text-red-500">*</span>
|
<div className="flex items-center gap-2">
|
||||||
</Label>
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
||||||
<Textarea
|
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
|
||||||
id="comment"
|
Invoices / Receipts
|
||||||
placeholder="Enter your comments about e-invoice generation (e.g., verified expenses, ready for settlement)..."
|
</h3>
|
||||||
value={comments}
|
<Badge variant="secondary" className="text-xs">
|
||||||
onChange={(e) => {
|
{completionDocuments.invoicesReceipts.length} file(s)
|
||||||
const value = e.target.value;
|
</Badge>
|
||||||
if (value.length <= maxCommentsChars) {
|
</div>
|
||||||
setComments(value);
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||||
}
|
{completionDocuments.invoicesReceipts.map((doc, index) => (
|
||||||
}}
|
<div
|
||||||
rows={4}
|
key={index}
|
||||||
className="text-sm min-h-[80px] resize-none"
|
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 justify-between text-xs text-gray-500">
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1">
|
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 flex-shrink-0" />
|
||||||
<TriangleAlert className="w-3 h-3" />
|
<div className="min-w-0 flex-1">
|
||||||
Required and visible to all
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={doc.name}>
|
||||||
|
{doc.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{doc.id && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{canPreviewDocument(doc) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreviewDocument(doc)}
|
||||||
|
disabled={previewLoading}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Preview document"
|
||||||
|
>
|
||||||
|
{previewLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4 lg:w-5 lg: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-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span>{commentsChars}/{maxCommentsChars}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attendance Sheet */}
|
||||||
|
{completionDocuments.attendanceSheet && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-sm sm:text-base flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-600" />
|
||||||
|
Attendance Sheet
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Activity className="w-4 h-4 lg:w-5 lg:h-5 text-indigo-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={completionDocuments.attendanceSheet.name}>
|
||||||
|
{completionDocuments.attendanceSheet.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{completionDocuments.attendanceSheet.id && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{canPreviewDocument(completionDocuments.attendanceSheet) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreviewDocument(completionDocuments.attendanceSheet!)}
|
||||||
|
disabled={previewLoading}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Preview document"
|
||||||
|
>
|
||||||
|
{previewLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (completionDocuments.attendanceSheet?.id) {
|
||||||
|
await downloadDocument(completionDocuments.attendanceSheet.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-4 h-4 lg:w-5 lg:h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verification Warning */}
|
||||||
|
<div className="p-2.5 sm:p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<TriangleAlert className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs sm:text-sm font-semibold text-yellow-900">
|
||||||
|
Please verify all details before pushing to DMS
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-yellow-700 mt-1">
|
||||||
|
Once pushed, the system will automatically generate an e-invoice and log it as an activity.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
|
{/* Comments & Remarks */}
|
||||||
<Button
|
<div className="space-y-1.5 max-w-2xl">
|
||||||
variant="outline"
|
<Label htmlFor="comment" className="text-xs sm:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
onClick={handleClose}
|
Comments & Remarks <span className="text-red-500">*</span>
|
||||||
disabled={submitting}
|
</Label>
|
||||||
>
|
<Textarea
|
||||||
Cancel
|
id="comment"
|
||||||
</Button>
|
placeholder="Enter your comments about pushing to DMS (e.g., verified expenses, ready for invoice generation)..."
|
||||||
<Button
|
value={comments}
|
||||||
onClick={handleSubmit}
|
onChange={(e) => {
|
||||||
disabled={!comments.trim() || submitting}
|
const value = e.target.value;
|
||||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
if (value.length <= maxCommentsChars) {
|
||||||
>
|
setComments(value);
|
||||||
{submitting ? (
|
}
|
||||||
'Processing...'
|
}}
|
||||||
) : (
|
rows={4}
|
||||||
<>
|
className="text-sm min-h-[80px] resize-none"
|
||||||
<Activity className="w-4 h-4 mr-2" />
|
/>
|
||||||
Generate & Sync
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
</>
|
<div className="flex items-center gap-1">
|
||||||
)}
|
<TriangleAlert className="w-3 h-3" />
|
||||||
</Button>
|
Required and visible to all
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
<span>{commentsChars}/{maxCommentsChars}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</Dialog>
|
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pt-3 pb-6 border-t flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!comments.trim() || submitting}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
'Pushing to DMS...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Activity className="w-4 h-4 mr-2" />
|
||||||
|
Push to DMS
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
{/* File Preview Modal - Matching DocumentsTab style */}
|
{/* File Preview Modal - Matching DocumentsTab style */}
|
||||||
{previewDocument && (
|
{previewDocument && (
|
||||||
@ -869,7 +866,7 @@ export function DMSPushModal({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,7 +153,7 @@ 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
|
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
|
||||||
const showIOTab = isInitiator;
|
const showIOTab = isInitiator;
|
||||||
@ -177,7 +177,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
|
|
||||||
// State to temporarily store approval level for modal (used for additional approvers)
|
// State to temporarily store approval level for modal (used for additional approvers)
|
||||||
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
|
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
|
||||||
|
|
||||||
// Use temporary level if set, otherwise use currentApprovalLevel
|
// Use temporary level if set, otherwise use currentApprovalLevel
|
||||||
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
|
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
|
||||||
|
|
||||||
@ -220,7 +220,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
// Check both lowercase and uppercase status values
|
// Check both lowercase and uppercase status values
|
||||||
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
||||||
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
|
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
|
||||||
|
|
||||||
// Closure check completed
|
// Closure check completed
|
||||||
const {
|
const {
|
||||||
conclusionRemark,
|
conclusionRemark,
|
||||||
@ -335,7 +335,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
try {
|
try {
|
||||||
setLoadingSummary(true);
|
setLoadingSummary(true);
|
||||||
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
||||||
|
|
||||||
if (summary?.summaryId) {
|
if (summary?.summaryId) {
|
||||||
setSummaryId(summary.summaryId);
|
setSummaryId(summary.summaryId);
|
||||||
try {
|
try {
|
||||||
@ -376,9 +376,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
|
|
||||||
const notifRequestId = notif.requestId || notif.request_id;
|
const notifRequestId = notif.requestId || notif.request_id;
|
||||||
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
|
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
|
||||||
if (notifRequestId !== apiRequest.requestId &&
|
if (notifRequestId !== apiRequest.requestId &&
|
||||||
notifRequestNumber !== requestIdentifier &&
|
notifRequestNumber !== requestIdentifier &&
|
||||||
notifRequestNumber !== apiRequest.requestNumber) return;
|
notifRequestNumber !== apiRequest.requestNumber) return;
|
||||||
|
|
||||||
// Check for credit note metadata
|
// Check for credit note metadata
|
||||||
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
|
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
|
||||||
@ -427,15 +427,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
{accessDenied.message}
|
{accessDenied.message}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBack || (() => window.history.back())}
|
onClick={onBack || (() => window.history.back())}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
@ -460,15 +460,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
The dealer claim request you're looking for doesn't exist or may have been deleted.
|
The dealer claim request you're looking for doesn't exist or may have been deleted.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBack || (() => window.history.back())}
|
onClick={onBack || (() => window.history.back())}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
@ -598,8 +598,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
|
|
||||||
{isClosed && (
|
{isClosed && (
|
||||||
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
||||||
<SummaryTab
|
<SummaryTab
|
||||||
summary={summaryDetails}
|
summary={summaryDetails}
|
||||||
loading={loadingSummary}
|
loading={loadingSummary}
|
||||||
onShare={handleShareSummary}
|
onShare={handleShareSummary}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
@ -673,7 +673,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
request={request}
|
request={request}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
isSpectator={isSpectator}
|
isSpectator={isSpectator}
|
||||||
currentApprovalLevel={currentApprovalLevel}
|
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel}
|
||||||
onAddApprover={() => setShowAddApproverModal(true)}
|
onAddApprover={() => setShowAddApproverModal(true)}
|
||||||
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
||||||
onApprove={() => setShowApproveModal(true)}
|
onApprove={() => setShowApproveModal(true)}
|
||||||
|
|||||||
@ -175,14 +175,14 @@ export function mapToClaimManagementRequest(
|
|||||||
// Get closed expenses breakdown from new completionExpenses table
|
// Get closed expenses breakdown from new completionExpenses table
|
||||||
const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0
|
const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0
|
||||||
? completionExpenses.map((exp: any) => ({
|
? completionExpenses.map((exp: any) => ({
|
||||||
description: exp.description || exp.itemDescription || exp.item_description || '',
|
description: exp.description || exp.itemDescription || '',
|
||||||
amount: Number(exp.amount) || 0,
|
amount: Number(exp.amount) || 0,
|
||||||
gstRate: exp.gstRate ?? exp.gst_rate,
|
gstRate: exp.gstRate,
|
||||||
gstAmt: exp.gstAmt ?? exp.gst_amt,
|
gstAmt: exp.gstAmt,
|
||||||
cgstAmt: exp.cgstAmt ?? exp.cgst_amt,
|
cgstAmt: exp.cgstAmt,
|
||||||
sgstAmt: exp.sgstAmt ?? exp.sgst_amt,
|
sgstAmt: exp.sgstAmt,
|
||||||
igstAmt: exp.igstAmt ?? exp.igst_amt,
|
igstAmt: exp.igstAmt,
|
||||||
totalAmt: exp.totalAmt ?? exp.total_amt
|
totalAmt: exp.totalAmt
|
||||||
}))
|
}))
|
||||||
: (completionDetails?.closedExpenses ||
|
: (completionDetails?.closedExpenses ||
|
||||||
completionDetails?.closed_expenses ||
|
completionDetails?.closed_expenses ||
|
||||||
@ -232,14 +232,14 @@ export function mapToClaimManagementRequest(
|
|||||||
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
|
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
|
||||||
costBreakup: Array.isArray(proposalDetails.costBreakup || proposalDetails.cost_breakup)
|
costBreakup: Array.isArray(proposalDetails.costBreakup || proposalDetails.cost_breakup)
|
||||||
? (proposalDetails.costBreakup || proposalDetails.cost_breakup).map((item: any) => ({
|
? (proposalDetails.costBreakup || proposalDetails.cost_breakup).map((item: any) => ({
|
||||||
description: item.description || item.itemDescription || item.item_description || '',
|
description: item.description || '',
|
||||||
amount: Number(item.amount) || 0,
|
amount: Number(item.amount) || 0,
|
||||||
gstRate: item.gstRate ?? item.gst_rate,
|
gstRate: item.gstRate,
|
||||||
gstAmt: item.gstAmt ?? item.gst_amt,
|
gstAmt: item.gstAmt,
|
||||||
cgstAmt: item.cgstAmt ?? item.cgst_amt,
|
cgstAmt: item.cgstAmt,
|
||||||
sgstAmt: item.sgstAmt ?? item.sgst_amt,
|
sgstAmt: item.sgstAmt,
|
||||||
igstAmt: item.igstAmt ?? item.igst_amt,
|
igstAmt: item.igstAmt,
|
||||||
totalAmt: item.totalAmt ?? item.total_amt
|
totalAmt: item.totalAmt
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
|
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
|
||||||
|
|||||||
@ -16,7 +16,7 @@ let configLoaded = false;
|
|||||||
// Lazy initialization of configuration
|
// Lazy initialization of configuration
|
||||||
async function ensureConfigLoaded() {
|
async function ensureConfigLoaded() {
|
||||||
if (configLoaded) return;
|
if (configLoaded) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await configService.getConfig();
|
const config = await configService.getConfig();
|
||||||
WORK_START_HOUR = config.workingHours.START_HOUR;
|
WORK_START_HOUR = config.workingHours.START_HOUR;
|
||||||
@ -30,7 +30,7 @@ async function ensureConfigLoaded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize config on first import (non-blocking)
|
// Initialize config on first import (non-blocking)
|
||||||
ensureConfigLoaded().catch(() => { });
|
ensureConfigLoaded().catch(() => {});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current time is within working hours
|
* Check if current time is within working hours
|
||||||
@ -40,7 +40,7 @@ ensureConfigLoaded().catch(() => { });
|
|||||||
export function isWorkingTime(date: Date = new Date(), priority: string = 'standard'): boolean {
|
export function isWorkingTime(date: Date = new Date(), priority: string = 'standard'): boolean {
|
||||||
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
|
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
|
||||||
const hour = date.getHours();
|
const hour = date.getHours();
|
||||||
|
|
||||||
// For standard priority: exclude weekends
|
// For standard priority: exclude weekends
|
||||||
// For express priority: include weekends (calendar days)
|
// For express priority: include weekends (calendar days)
|
||||||
if (priority === 'standard') {
|
if (priority === 'standard') {
|
||||||
@ -48,13 +48,14 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Working hours check (applies to both priorities)
|
// Working hours check (applies to both priorities)
|
||||||
if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) {
|
if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Add holiday check if holiday API is available
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,12 +66,12 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
|
|||||||
*/
|
*/
|
||||||
export function getNextWorkingTime(date: Date = new Date(), priority: string = 'standard'): Date {
|
export function getNextWorkingTime(date: Date = new Date(), priority: string = 'standard'): Date {
|
||||||
const result = new Date(date);
|
const result = new Date(date);
|
||||||
|
|
||||||
// If already in working time, return as is
|
// If already in working time, return as is
|
||||||
if (isWorkingTime(result, priority)) {
|
if (isWorkingTime(result, priority)) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For standard priority: skip weekends
|
// For standard priority: skip weekends
|
||||||
if (priority === 'standard') {
|
if (priority === 'standard') {
|
||||||
const day = result.getDay();
|
const day = result.getDay();
|
||||||
@ -85,13 +86,13 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If before work hours, move to work start
|
// If before work hours, move to work start
|
||||||
if (result.getHours() < WORK_START_HOUR) {
|
if (result.getHours() < WORK_START_HOUR) {
|
||||||
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
result.setHours(WORK_START_HOUR, 0, 0, 0);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If after work hours, move to next day work start
|
// If after work hours, move to next day work start
|
||||||
if (result.getHours() >= WORK_END_HOUR) {
|
if (result.getHours() >= WORK_END_HOUR) {
|
||||||
result.setDate(result.getDate() + 1);
|
result.setDate(result.getDate() + 1);
|
||||||
@ -99,7 +100,7 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
|
|||||||
// Check if next day is weekend (only for standard priority)
|
// Check if next day is weekend (only for standard priority)
|
||||||
return getNextWorkingTime(result, priority);
|
return getNextWorkingTime(result, priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,19 +114,19 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
|
|||||||
let current = new Date(startDate);
|
let current = new Date(startDate);
|
||||||
const end = new Date(endDate);
|
const end = new Date(endDate);
|
||||||
let elapsedMinutes = 0;
|
let elapsedMinutes = 0;
|
||||||
|
|
||||||
// Move minute by minute and count only working minutes
|
// Move minute by minute and count only working minutes
|
||||||
while (current < end) {
|
while (current < end) {
|
||||||
if (isWorkingTime(current, priority)) {
|
if (isWorkingTime(current, priority)) {
|
||||||
elapsedMinutes++;
|
elapsedMinutes++;
|
||||||
}
|
}
|
||||||
current.setMinutes(current.getMinutes() + 1);
|
current.setMinutes(current.getMinutes() + 1);
|
||||||
|
|
||||||
// Safety: stop if calculating more than 1 year
|
// Safety: stop if calculating more than 1 year
|
||||||
const hoursSoFar = elapsedMinutes / 60;
|
const hoursSoFar = elapsedMinutes / 60;
|
||||||
if (hoursSoFar > 8760) break;
|
if (hoursSoFar > 8760) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert minutes to hours (with decimal precision)
|
// Convert minutes to hours (with decimal precision)
|
||||||
return elapsedMinutes / 60;
|
return elapsedMinutes / 60;
|
||||||
}
|
}
|
||||||
@ -139,12 +140,12 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
|
|||||||
export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date(), priority: string = 'standard'): number {
|
export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date(), priority: string = 'standard'): number {
|
||||||
const deadlineTime = new Date(deadline).getTime();
|
const deadlineTime = new Date(deadline).getTime();
|
||||||
const currentTime = new Date(fromDate).getTime();
|
const currentTime = new Date(fromDate).getTime();
|
||||||
|
|
||||||
// If deadline has passed
|
// If deadline has passed
|
||||||
if (deadlineTime <= currentTime) {
|
if (deadlineTime <= currentTime) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate remaining working hours
|
// Calculate remaining working hours
|
||||||
return calculateElapsedWorkingHours(fromDate, deadline, priority);
|
return calculateElapsedWorkingHours(fromDate, deadline, priority);
|
||||||
}
|
}
|
||||||
@ -159,9 +160,9 @@ export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date =
|
|||||||
export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date(), priority: string = 'standard'): number {
|
export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date(), priority: string = 'standard'): number {
|
||||||
const totalHours = calculateElapsedWorkingHours(startDate, deadline, priority);
|
const totalHours = calculateElapsedWorkingHours(startDate, deadline, priority);
|
||||||
const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate, priority);
|
const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate, priority);
|
||||||
|
|
||||||
if (totalHours === 0) return 0;
|
if (totalHours === 0) return 0;
|
||||||
|
|
||||||
const progress = (elapsedHours / totalHours) * 100;
|
const progress = (elapsedHours / totalHours) * 100;
|
||||||
return Math.min(Math.max(progress, 0), 100); // Clamp between 0 and 100
|
return Math.min(Math.max(progress, 0), 100); // Clamp between 0 and 100
|
||||||
}
|
}
|
||||||
@ -184,17 +185,17 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
|
|||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
const end = new Date(deadline);
|
const end = new Date(deadline);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const isWorking = isWorkingTime(now, priority);
|
const isWorking = isWorkingTime(now, priority);
|
||||||
const elapsedHours = calculateElapsedWorkingHours(start, now, priority);
|
const elapsedHours = calculateElapsedWorkingHours(start, now, priority);
|
||||||
const totalHours = calculateElapsedWorkingHours(start, end, priority);
|
const totalHours = calculateElapsedWorkingHours(start, end, priority);
|
||||||
const remainingHours = Math.max(0, totalHours - elapsedHours);
|
const remainingHours = Math.max(0, totalHours - elapsedHours);
|
||||||
const progress = calculateSLAProgress(start, end, now, priority);
|
const progress = calculateSLAProgress(start, end, now, priority);
|
||||||
|
|
||||||
let statusText = '';
|
let statusText = '';
|
||||||
if (!isWorking) {
|
if (!isWorking) {
|
||||||
statusText = priority === 'express'
|
statusText = priority === 'express'
|
||||||
? 'SLA tracking paused (outside working hours)'
|
? 'SLA tracking paused (outside working hours)'
|
||||||
: 'SLA tracking paused (outside working hours/days)';
|
: 'SLA tracking paused (outside working hours/days)';
|
||||||
} else if (remainingHours === 0) {
|
} else if (remainingHours === 0) {
|
||||||
statusText = 'SLA deadline reached';
|
statusText = 'SLA deadline reached';
|
||||||
@ -207,7 +208,7 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
|
|||||||
} else {
|
} else {
|
||||||
statusText = 'On track';
|
statusText = 'On track';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isWorkingTime: isWorking,
|
isWorkingTime: isWorking,
|
||||||
progress,
|
progress,
|
||||||
@ -230,38 +231,38 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
|
|||||||
export function formatHoursMinutes(hours: number | null | undefined): string {
|
export function formatHoursMinutes(hours: number | null | undefined): string {
|
||||||
if (hours === null || hours === undefined || hours < 0) return '0 hours';
|
if (hours === null || hours === undefined || hours < 0) return '0 hours';
|
||||||
if (hours === 0) return '0 hours';
|
if (hours === 0) return '0 hours';
|
||||||
|
|
||||||
const WORKING_HOURS_PER_DAY = 8;
|
const WORKING_HOURS_PER_DAY = 8;
|
||||||
|
|
||||||
// If less than 1 hour, show minutes only
|
// If less than 1 hour, show minutes only
|
||||||
if (hours < 1) {
|
if (hours < 1) {
|
||||||
const m = Math.round(hours * 60);
|
const m = Math.round(hours * 60);
|
||||||
return m > 0 ? `${m}m` : '0 hours';
|
return m > 0 ? `${m}m` : '0 hours';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate days and remaining hours (8 hours = 1 day)
|
// Calculate days and remaining hours (8 hours = 1 day)
|
||||||
// Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts
|
// Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts
|
||||||
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
||||||
const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY);
|
const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY);
|
||||||
const minutes = Math.round((hours % 1) * 60);
|
const minutes = Math.round((hours % 1) * 60);
|
||||||
|
|
||||||
// If we have days, format with days (matching backend format)
|
// If we have days, format with days (matching backend format)
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
const dayLabel = days === 1 ? 'day' : 'days';
|
const dayLabel = days === 1 ? 'day' : 'days';
|
||||||
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
|
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
|
||||||
const minuteLabel = minutes === 1 ? 'min' : 'm';
|
const minuteLabel = minutes === 1 ? 'min' : 'm';
|
||||||
|
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
|
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
|
||||||
} else {
|
} else {
|
||||||
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`;
|
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No days, just hours and minutes
|
// No days, just hours and minutes
|
||||||
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
|
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
|
||||||
const minuteLabel = minutes === 1 ? 'min' : 'm';
|
const minuteLabel = minutes === 1 ? 'min' : 'm';
|
||||||
|
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
return `${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
|
return `${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
|
||||||
} else {
|
} else {
|
||||||
@ -275,13 +276,13 @@ export function formatHoursMinutes(hours: number | null | undefined): string {
|
|||||||
export function formatWorkingHours(hours: number): string {
|
export function formatWorkingHours(hours: number): string {
|
||||||
if (hours === 0) return '0h';
|
if (hours === 0) return '0h';
|
||||||
if (hours < 0) return '0h';
|
if (hours < 0) return '0h';
|
||||||
|
|
||||||
const totalMinutes = Math.round(hours * 60);
|
const totalMinutes = Math.round(hours * 60);
|
||||||
const days = Math.floor(totalMinutes / (8 * 60)); // 8 working hours per day
|
const days = Math.floor(totalMinutes / (8 * 60)); // 8 working hours per day
|
||||||
const remainingMinutes = totalMinutes % (8 * 60);
|
const remainingMinutes = totalMinutes % (8 * 60);
|
||||||
const remainingHours = Math.floor(remainingMinutes / 60);
|
const remainingHours = Math.floor(remainingMinutes / 60);
|
||||||
const minutes = remainingMinutes % 60;
|
const minutes = remainingMinutes % 60;
|
||||||
|
|
||||||
if (days > 0 && remainingHours > 0 && minutes > 0) {
|
if (days > 0 && remainingHours > 0 && minutes > 0) {
|
||||||
return `${days}d ${remainingHours}h ${minutes}m`;
|
return `${days}d ${remainingHours}h ${minutes}m`;
|
||||||
} else if (days > 0 && remainingHours > 0) {
|
} else if (days > 0 && remainingHours > 0) {
|
||||||
@ -305,14 +306,14 @@ export function getTimeUntilNextWorking(priority: string = 'standard'): string {
|
|||||||
if (isWorkingTime(new Date(), priority)) {
|
if (isWorkingTime(new Date(), priority)) {
|
||||||
return 'In working hours';
|
return 'In working hours';
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const next = getNextWorkingTime(now, priority);
|
const next = getNextWorkingTime(now, priority);
|
||||||
const diff = next.getTime() - now.getTime();
|
const diff = next.getTime() - now.getTime();
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
if (hours > 24) {
|
if (hours > 24) {
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
return `Resumes in ${days}d ${hours % 24}h`;
|
return `Resumes in ${days}d ${hours % 24}h`;
|
||||||
|
|||||||
@ -57,14 +57,14 @@ export const cookieUtils = {
|
|||||||
*/
|
*/
|
||||||
clearAll(): void {
|
clearAll(): void {
|
||||||
const cookieNames = [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY, USER_DATA_KEY];
|
const cookieNames = [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY, USER_DATA_KEY];
|
||||||
|
|
||||||
cookieNames.forEach(name => {
|
cookieNames.forEach(name => {
|
||||||
// Remove with default path
|
// Remove with default path
|
||||||
this.remove(name);
|
this.remove(name);
|
||||||
|
|
||||||
// Remove with root path explicitly
|
// Remove with root path explicitly
|
||||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
|
||||||
// Remove with domain (if applicable)
|
// Remove with domain (if applicable)
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
||||||
@ -75,60 +75,82 @@ export const cookieUtils = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token Manager - Handles token storage and retrieval
|
||||||
|
*
|
||||||
|
* SECURITY MODES:
|
||||||
|
* - Production: Tokens stored in httpOnly cookies by backend only
|
||||||
|
* Frontend does NOT store access/refresh tokens anywhere
|
||||||
|
* All API requests rely on cookies being sent automatically
|
||||||
|
*
|
||||||
|
* - Development: Tokens stored in localStorage for debugging
|
||||||
|
* Needed because frontend/backend run on different ports
|
||||||
|
*/
|
||||||
export class TokenManager {
|
export class TokenManager {
|
||||||
/**
|
/**
|
||||||
* Store access token
|
* Store access token
|
||||||
|
* In production: No-op (backend handles via httpOnly cookies)
|
||||||
|
* In development: Store in localStorage for Authorization header
|
||||||
*/
|
*/
|
||||||
static setAccessToken(token: string): void {
|
static setAccessToken(token: string): void {
|
||||||
|
// SECURITY: In production, don't store tokens client-side
|
||||||
|
// Backend sets httpOnly cookies that are sent automatically
|
||||||
if (isProduction()) {
|
if (isProduction()) {
|
||||||
return; // No-op - rely on httpOnly cookies
|
return; // No-op - rely on httpOnly cookies
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development only: Store for debugging and cross-port requests
|
// Development only: Store for debugging and cross-port requests
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get access token
|
* Get access token
|
||||||
*
|
* In production: Returns null (cookies are sent automatically)
|
||||||
|
* In development: Returns from localStorage
|
||||||
*/
|
*/
|
||||||
static getAccessToken(): string | null {
|
static getAccessToken(): string | null {
|
||||||
|
// SECURITY: In production, return null - cookies are used instead
|
||||||
if (isProduction()) {
|
if (isProduction()) {
|
||||||
return null;
|
return null; // API calls use cookies via withCredentials: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development: Return from localStorage
|
// Development: Return from localStorage
|
||||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store refresh token
|
* Store refresh token
|
||||||
|
* In production: No-op (backend handles via httpOnly cookies)
|
||||||
|
* In development: Store in localStorage
|
||||||
*/
|
*/
|
||||||
static setRefreshToken(token: string): void {
|
static setRefreshToken(token: string): void {
|
||||||
// SECURITY: In production, don't store tokens client-side
|
// SECURITY: In production, don't store tokens client-side
|
||||||
if (isProduction()) {
|
if (isProduction()) {
|
||||||
return; // No-op - rely on httpOnly cookies
|
return; // No-op - rely on httpOnly cookies
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development only
|
// Development only
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get refresh token
|
* Get refresh token
|
||||||
|
* In production: Returns null (cookies are used)
|
||||||
|
* In development: Returns from localStorage
|
||||||
*/
|
*/
|
||||||
static getRefreshToken(): string | null {
|
static getRefreshToken(): string | null {
|
||||||
// SECURITY: In production, return null - backend reads from cookie
|
// SECURITY: In production, return null - backend reads from cookie
|
||||||
if (isProduction()) {
|
if (isProduction()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store ID token (from Okta) - needed for logout
|
||||||
|
* Stored in sessionStorage (cleared when tab closes)
|
||||||
|
*/
|
||||||
static setIdToken(token: string): void {
|
static setIdToken(token: string): void {
|
||||||
// ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
|
// ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
|
||||||
sessionStorage.setItem(ID_TOKEN_KEY, token);
|
sessionStorage.setItem(ID_TOKEN_KEY, token);
|
||||||
@ -161,7 +183,18 @@ 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)
|
// CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
|
||||||
// This flag survives the redirect and prevents auto-authentication
|
// This flag survives the redirect and prevents auto-authentication
|
||||||
@ -171,7 +204,7 @@ export class TokenManager {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not set logout flags:', e);
|
console.warn('Could not set logout flags:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear user data (stored in both modes)
|
// Clear user data (stored in both modes)
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(USER_DATA_KEY);
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
@ -179,7 +212,7 @@ export class TokenManager {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Error clearing user data:', e);
|
console.warn('Error clearing user data:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, httpOnly cookies are cleared by backend
|
// In production, httpOnly cookies are cleared by backend
|
||||||
// Only need to clear user data above
|
// Only need to clear user data above
|
||||||
if (isProduction()) {
|
if (isProduction()) {
|
||||||
@ -192,7 +225,7 @@ export class TokenManager {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEVELOPMENT MODE: Clear everything
|
// DEVELOPMENT MODE: Clear everything
|
||||||
const authKeys = [
|
const authKeys = [
|
||||||
ACCESS_TOKEN_KEY,
|
ACCESS_TOKEN_KEY,
|
||||||
@ -213,7 +246,7 @@ export class TokenManager {
|
|||||||
'persist:auth',
|
'persist:auth',
|
||||||
'redux-persist',
|
'redux-persist',
|
||||||
];
|
];
|
||||||
|
|
||||||
authKeys.forEach(key => {
|
authKeys.forEach(key => {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
@ -222,14 +255,14 @@ export class TokenManager {
|
|||||||
console.warn(`Error removing ${key}:`, e);
|
console.warn(`Error removing ${key}:`, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear ALL localStorage
|
// Clear ALL localStorage
|
||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error clearing localStorage:', e);
|
console.error('Error clearing localStorage:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear ALL sessionStorage except logout flags
|
// Clear ALL sessionStorage except logout flags
|
||||||
try {
|
try {
|
||||||
const keysToKeep = ['__logout_in_progress__', '__force_logout__'];
|
const keysToKeep = ['__logout_in_progress__', '__force_logout__'];
|
||||||
@ -244,7 +277,7 @@ export class TokenManager {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error clearing sessionStorage:', e);
|
console.error('Error clearing sessionStorage:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear client-side cookies (development only)
|
// Clear client-side cookies (development only)
|
||||||
cookieUtils.clearAll();
|
cookieUtils.clearAll();
|
||||||
}
|
}
|
||||||
@ -263,7 +296,11 @@ export class TokenManager {
|
|||||||
return !!this.getAccessToken();
|
return !!this.getAccessToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if refresh token exists
|
||||||
|
* In production: Always returns true if user data exists
|
||||||
|
* In development: Checks localStorage
|
||||||
|
*/
|
||||||
static hasRefreshToken(): boolean {
|
static hasRefreshToken(): boolean {
|
||||||
if (isProduction()) {
|
if (isProduction()) {
|
||||||
return !!this.getUserData();
|
return !!this.getUserData();
|
||||||
@ -281,7 +318,7 @@ export class TokenManager {
|
|||||||
window.location.hostname === ''
|
window.location.hostname === ''
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're in production mode
|
* Check if we're in production mode
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user